AWS host phase 3: AWS connections and tiered permission check #80
No files matched your search
@@ -304,6 +304,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
|
||||
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
|
||||
(`DOCKER_ONLY`); shrink it, never grow it.
|
||||
- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs`
|
||||
builds a saved `HostConnection`'s `SdkConfig` **by hand, never with
|
||||
`aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider`
|
||||
for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's
|
||||
endpoint/service settings can't redirect or re-credential a connection's signed requests.
|
||||
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
|
||||
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
|
||||
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
|
||||
`policies.rs` embeds `aws/policies/*.json` (the two tier policies and the permissions boundary)
|
||||
and the table of checks the simulator is asked; `permissions.rs` runs it
|
||||
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
|
||||
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
|
||||
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
|
||||
evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped,
|
||||
the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character
|
||||
managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`,
|
||||
admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored
|
||||
--test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles,
|
||||
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
|
||||
`aws/README.md` ("Live verification").
|
||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
@@ -793,6 +814,12 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **AWS connection keys travel in `ExportedSecrets::aws_connection_keys`** (keyed by connection id),
|
||||
exported and restored like the marketplace tokens. `hosts` are **store-owned**: `update_settings`
|
||||
restores them via `restore_host_fields`, and `apply_settings_import` re-applies them through
|
||||
`StoreOwned`. `validate_imported_hosts` runs before any keychain write. The import preview
|
||||
discloses AWS connections, key counts, overwrites (naming the local connection), failed key
|
||||
checks and removals.
|
||||
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
|
||||
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
|
||||
they follow the same "carve out of the keychain, restore before the settings replace, only
|
||||
|
||||
Generated
+106
@@ -366,6 +366,32 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-cloudformation"
|
||||
version = "1.139.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ecs"
|
||||
version = "1.159.0"
|
||||
@@ -389,6 +415,78 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-iam"
|
||||
version = "1.136.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f53b6d3c4f3f04b734a5ae6eea34491e5c356be99bc677fa7fd84eba0cc671b"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-kms"
|
||||
version = "1.131.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6309b510ca62e64bafb147a08413d81665fdcaa2b607423d348c576b5d4a89"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-secretsmanager"
|
||||
version = "1.128.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d77ef7832d9e7c60fa7b8201bb2038ccc9821b6cf88746caab6dc1c8767882b5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssm"
|
||||
version = "1.137.0"
|
||||
@@ -7139,8 +7237,16 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sdk-cloudformation",
|
||||
"aws-sdk-ecs",
|
||||
"aws-sdk-iam",
|
||||
"aws-sdk-kms",
|
||||
"aws-sdk-secretsmanager",
|
||||
"aws-sdk-ssm",
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
|
||||
@@ -62,6 +62,18 @@ gix = { version = "0.88", default-features = false, features = ["blocking-networ
|
||||
aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] }
|
||||
aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Named by src/aws/connection.rs: the credentials-provider trait (its profile
|
||||
# wrapper) and the profile-file set (aws-config's alias for it is deprecated).
|
||||
# Both are the versions aws-config already resolves.
|
||||
aws-credential-types = { version = "1.3.1", default-features = false }
|
||||
aws-runtime = { version = "1.10.4", default-features = false }
|
||||
# The default sleep and time source for the hand-built SdkConfig (no env/profile
|
||||
# loading); the version and feature already in the tree.
|
||||
aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] }
|
||||
|
||||
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
|
||||
# streams and answers the permission request (see src/linux_media.rs). Same
|
||||
@@ -74,6 +86,9 @@ webkit2gtk = "2.0.2"
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
# Only `aws::live_tests` (ignored): it creates a throwaway environment key to
|
||||
# check that Secrets Manager and the permissions boundary see its tags.
|
||||
aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -57,6 +57,14 @@
|
||||
"allow-detect-aws-config",
|
||||
"allow-inspect-ca-cert-path",
|
||||
"allow-list-aws-profiles",
|
||||
"allow-add-aws-profile-connection",
|
||||
"allow-add-aws-keys-connection",
|
||||
"allow-update-aws-connection",
|
||||
"allow-remove-host-connection",
|
||||
"allow-aws-connection-identity",
|
||||
"allow-aws-connection-sso-login",
|
||||
"allow-check-aws-permissions",
|
||||
"allow-aws-policy-document",
|
||||
"allow-detect-host-timezone",
|
||||
"allow-export-settings",
|
||||
"allow-preview-settings-import",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","descriptionLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
@@ -350,6 +350,18 @@
|
||||
"const": "allow-acquire-claude-token",
|
||||
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-keys-connection",
|
||||
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-profile-connection",
|
||||
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -392,6 +404,24 @@
|
||||
"const": "allow-apply-settings-import",
|
||||
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-identity",
|
||||
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-sso-login",
|
||||
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-policy-document",
|
||||
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -428,6 +458,12 @@
|
||||
"const": "allow-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-aws-permissions",
|
||||
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +902,12 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-host-connection",
|
||||
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1082,6 +1124,12 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1208,18 @@
|
||||
"const": "deny-acquire-claude-token",
|
||||
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-keys-connection",
|
||||
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-profile-connection",
|
||||
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1202,6 +1262,24 @@
|
||||
"const": "deny-apply-settings-import",
|
||||
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-identity",
|
||||
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-sso-login",
|
||||
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-policy-document",
|
||||
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1238,6 +1316,12 @@
|
||||
"const": "deny-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-aws-permissions",
|
||||
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1760,12 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-host-connection",
|
||||
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1892,6 +1982,12 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -350,6 +350,18 @@
|
||||
"const": "allow-acquire-claude-token",
|
||||
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-keys-connection",
|
||||
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-profile-connection",
|
||||
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -392,6 +404,24 @@
|
||||
"const": "allow-apply-settings-import",
|
||||
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-identity",
|
||||
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-sso-login",
|
||||
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-policy-document",
|
||||
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -428,6 +458,12 @@
|
||||
"const": "allow-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-aws-permissions",
|
||||
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +902,12 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-host-connection",
|
||||
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1082,6 +1124,12 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1208,18 @@
|
||||
"const": "deny-acquire-claude-token",
|
||||
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-keys-connection",
|
||||
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-profile-connection",
|
||||
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1202,6 +1262,24 @@
|
||||
"const": "deny-apply-settings-import",
|
||||
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-identity",
|
||||
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-sso-login",
|
||||
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-policy-document",
|
||||
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1238,6 +1316,12 @@
|
||||
"const": "deny-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-aws-permissions",
|
||||
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1760,12 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-host-connection",
|
||||
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1892,6 +1982,12 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! An `SdkConfig` built from a saved `HostConnection`, never from the
|
||||
//! process's default credentials chain.
|
||||
|
||||
use aws_config::identity::IdentityCache;
|
||||
use aws_config::profile::ProfileFileCredentialsProvider;
|
||||
use aws_config::provider_config::ProviderConfig;
|
||||
use aws_config::retry::RetryConfig;
|
||||
use aws_config::{BehaviorVersion, Region, SdkConfig};
|
||||
use aws_credential_types::provider::error::CredentialsError;
|
||||
use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider};
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_runtime::env_config::file::EnvConfigFiles;
|
||||
use aws_smithy_async::rt::sleep::default_async_sleep;
|
||||
use aws_smithy_async::time::SharedTimeSource;
|
||||
|
||||
use super::error::{AwsError, ProfileCredentialsError};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::storage::secure;
|
||||
|
||||
/// The SDK config for `conn` in `region`. Credentials are resolved lazily,
|
||||
/// so a missing profile or an expired SSO session surfaces on the first
|
||||
/// call and is classified there (`classify_sdk_error`).
|
||||
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<SdkConfig, AwsError> {
|
||||
sdk_config_with(conn, region, None).await
|
||||
}
|
||||
|
||||
/// `sdk_config` with the profile files overridden (tests pass temp files so
|
||||
/// neither the process environment nor `~/.aws` is consulted).
|
||||
pub(crate) async fn sdk_config_with(
|
||||
conn: &HostConnection,
|
||||
region: &str,
|
||||
profile_files: Option<EnvConfigFiles>,
|
||||
) -> Result<SdkConfig, AwsError> {
|
||||
let HostKind::Aws { auth, .. } = &conn.kind;
|
||||
let region = Region::new(region.to_string());
|
||||
let credentials = match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
// The profile is read for credentials only (static keys, SSO,
|
||||
// assume-role, credential_process), never for endpoints or
|
||||
// service settings.
|
||||
let mut builder = ProfileFileCredentialsProvider::builder()
|
||||
.configure(&ProviderConfig::default().with_region(Some(region.clone())))
|
||||
.profile_name(name);
|
||||
if let Some(files) = profile_files {
|
||||
builder = builder.profile_files(files);
|
||||
}
|
||||
SharedCredentialsProvider::new(NamedProfileProvider {
|
||||
profile: name.clone(),
|
||||
inner: builder.build(),
|
||||
})
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } => {
|
||||
let keys = secure::get_aws_keys(keyring_ref)
|
||||
.map_err(|message| AwsError::Other { message })?
|
||||
.ok_or(AwsError::KeysMissing)?;
|
||||
keys_credentials(&keys)
|
||||
}
|
||||
};
|
||||
Ok(build_sdk_config(region, credentials))
|
||||
}
|
||||
|
||||
/// The SDK config for keys not yet saved (the add-connection check runs
|
||||
/// before anything touches the keychain).
|
||||
pub fn sdk_config_for_keys(keys: &secure::AwsKeys, region: &str) -> SdkConfig {
|
||||
build_sdk_config(Region::new(region.to_string()), keys_credentials(keys))
|
||||
}
|
||||
|
||||
fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider {
|
||||
SharedCredentialsProvider::new(Credentials::new(
|
||||
keys.access_key_id.as_str(),
|
||||
keys.secret_access_key.as_str(),
|
||||
keys.session_token.clone(),
|
||||
None,
|
||||
"triple-c-connection",
|
||||
))
|
||||
}
|
||||
|
||||
/// Built by hand rather than with `aws_config::defaults(..).load()`, which
|
||||
/// would read `AWS_ENDPOINT_URL[_<SERVICE>]`, FIPS/dual-stack, retry and
|
||||
/// other settings from the process environment and the default profile. A
|
||||
/// stray endpoint there would send every connection's signed requests (and
|
||||
/// the permission check) somewhere other than AWS. Connections talk to the
|
||||
/// real regional endpoints; a profile's own `endpoint_url` is ignored too.
|
||||
/// The HTTP client, timeouts and stalled-stream protection are each service
|
||||
/// client's defaults (the `default-https-client` stack every AWS crate here
|
||||
/// is built with).
|
||||
fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig {
|
||||
let mut builder = SdkConfig::builder()
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.region(region)
|
||||
.credentials_provider(credentials)
|
||||
.identity_cache(IdentityCache::lazy().build())
|
||||
.retry_config(RetryConfig::standard())
|
||||
.time_source(SharedTimeSource::default());
|
||||
if let Some(sleep) = default_async_sleep() {
|
||||
builder = builder.sleep_impl(sleep);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Puts the profile's name into the source chain of every credentials error,
|
||||
/// so a failure deep inside the SDK can be reported against the profile.
|
||||
#[derive(Debug)]
|
||||
struct NamedProfileProvider {
|
||||
profile: String,
|
||||
inner: ProfileFileCredentialsProvider,
|
||||
}
|
||||
|
||||
impl NamedProfileProvider {
|
||||
async fn load(&self) -> Result<Credentials, CredentialsError> {
|
||||
self.inner.provide_credentials().await.map_err(|e| {
|
||||
let not_loaded = matches!(e, CredentialsError::CredentialsNotLoaded(_));
|
||||
let timed_out = matches!(e, CredentialsError::ProviderTimedOut(_));
|
||||
if timed_out {
|
||||
return e;
|
||||
}
|
||||
let invalid = matches!(e, CredentialsError::InvalidConfiguration(_));
|
||||
let wrapped = ProfileCredentialsError {
|
||||
profile: self.profile.clone(),
|
||||
not_loaded,
|
||||
source: e,
|
||||
};
|
||||
// Keep the error's kind; only its context changes.
|
||||
if not_loaded {
|
||||
CredentialsError::not_loaded(wrapped)
|
||||
} else if invalid {
|
||||
CredentialsError::invalid_configuration(wrapped)
|
||||
} else {
|
||||
CredentialsError::provider_error(wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProvideCredentials for NamedProfileProvider {
|
||||
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
{
|
||||
future::ProvideCredentials::new(self.load())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aws::identity::caller_identity;
|
||||
use crate::aws::AwsError;
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use aws_runtime::env_config::file::{EnvConfigFileKind, EnvConfigFiles};
|
||||
|
||||
fn profile_conn(name: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c1".into(),
|
||||
name: "Test".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: name.into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty config and credentials files, passed explicitly so neither the
|
||||
/// process environment nor ~/.aws is consulted.
|
||||
#[tokio::test]
|
||||
async fn a_missing_profile_is_reported_by_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
let creds = dir.path().join("credentials");
|
||||
std::fs::write(&config, "").unwrap();
|
||||
std::fs::write(&creds, "").unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.with_file(EnvConfigFileKind::Credentials, &creds)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = caller_identity(&cfg).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
AwsError::ProfileNotFound {
|
||||
profile: "nope".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile that the files do define but under another name is still
|
||||
/// "not found" for the name the connection asked for.
|
||||
#[tokio::test]
|
||||
async fn a_profile_absent_from_populated_files_is_reported_by_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"[profile other]\naws_access_key_id = AKIDTEST\naws_secret_access_key = s\n",
|
||||
)
|
||||
.unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = caller_identity(&cfg).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
AwsError::ProfileNotFound {
|
||||
profile: "nope".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_region_comes_from_the_argument() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(&config, "[profile p]\nregion = eu-west-1\n").unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("p"), "ap-south-1", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("ap-south-1"));
|
||||
}
|
||||
|
||||
/// Config files that try to redirect every call. A connection always
|
||||
/// talks to the real regional AWS endpoints, so none of this may reach
|
||||
/// the config: not the named profile's `endpoint_url`, not `[default]`'s,
|
||||
/// not a per-service `services` section.
|
||||
const REDIRECTING_CONFIG: &str = "[default]
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
retry_mode = adaptive
|
||||
max_attempts = 9
|
||||
use_fips_endpoint = true
|
||||
|
||||
[profile p]
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
retry_mode = adaptive
|
||||
max_attempts = 9
|
||||
use_dualstack_endpoint = true
|
||||
services = redirect
|
||||
aws_access_key_id = AKIDTEST
|
||||
aws_secret_access_key = s
|
||||
|
||||
[services redirect]
|
||||
sts =
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
";
|
||||
|
||||
fn assert_isolated(cfg: &aws_config::SdkConfig) {
|
||||
assert_eq!(cfg.endpoint_url(), None);
|
||||
assert!(
|
||||
cfg.service_config().is_none(),
|
||||
"service config loaded from env/profile"
|
||||
);
|
||||
assert_eq!(cfg.use_fips(), None);
|
||||
assert_eq!(cfg.use_dual_stack(), None);
|
||||
let retry = cfg.retry_config().expect("retry config");
|
||||
assert_eq!(retry.mode(), aws_config::retry::RetryMode::Standard);
|
||||
assert_eq!(retry.max_attempts(), 3);
|
||||
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("us-west-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_profile_connection_ignores_endpoint_and_service_settings_in_the_profile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(&config, REDIRECTING_CONFIG).unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("p"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_isolated(&cfg);
|
||||
}
|
||||
|
||||
/// The keys arm reads no files or environment at all: same isolation.
|
||||
#[test]
|
||||
fn a_keys_connection_config_is_isolated() {
|
||||
let keys = crate::storage::secure::AwsKeys {
|
||||
access_key_id: "AKIDTEST".into(),
|
||||
secret_access_key: "s".into(),
|
||||
session_token: None,
|
||||
};
|
||||
let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys));
|
||||
assert_isolated(&cfg);
|
||||
assert!(cfg.credentials_provider().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! What can go wrong talking to AWS, in the terms the UI acts on.
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use aws_sdk_sts::error::{DisplayErrorContext, ErrorMetadata};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AwsError {
|
||||
/// The UI offers "Sign in".
|
||||
SsoExpired {
|
||||
profile: String,
|
||||
},
|
||||
ProfileNotFound {
|
||||
profile: String,
|
||||
},
|
||||
/// The keychain entry for a keys connection is gone.
|
||||
KeysMissing,
|
||||
InvalidCredentials {
|
||||
message: String,
|
||||
},
|
||||
AccessDenied {
|
||||
action: String,
|
||||
message: String,
|
||||
},
|
||||
Other {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AwsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AwsError::SsoExpired { profile } if profile.is_empty() => {
|
||||
write!(f, "The AWS SSO session has expired; sign in again")
|
||||
}
|
||||
AwsError::SsoExpired { profile } => write!(
|
||||
f,
|
||||
"The AWS SSO session for profile \"{profile}\" has expired; sign in again"
|
||||
),
|
||||
AwsError::ProfileNotFound { profile } => write!(
|
||||
f,
|
||||
"AWS profile \"{profile}\" was not found, or has no credentials, in the AWS config and credentials files"
|
||||
),
|
||||
AwsError::KeysMissing => write!(
|
||||
f,
|
||||
"The access keys for this connection are no longer in the system keychain; enter them again"
|
||||
),
|
||||
AwsError::InvalidCredentials { message } => {
|
||||
write!(f, "AWS rejected the credentials: {message}")
|
||||
}
|
||||
AwsError::AccessDenied { action, message } => {
|
||||
write!(f, "Access denied for {action}: {message}")
|
||||
}
|
||||
AwsError::Other { message } => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carried in the source chain of every credentials error from a profile
|
||||
/// connection, so classification knows which profile failed and whether it
|
||||
/// produced no credentials at all (absent, or present without credentials).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProfileCredentialsError {
|
||||
pub profile: String,
|
||||
pub not_loaded: bool,
|
||||
pub source: aws_credential_types::provider::error::CredentialsError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProfileCredentialsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "credentials for profile `{}`", self.profile)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ProfileCredentialsError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&self.source)
|
||||
}
|
||||
}
|
||||
|
||||
const SSO_EXPIRED: &[&str] = &[
|
||||
"The SSO session associated with this profile has expired",
|
||||
"Token has expired",
|
||||
"the SSO token has expired",
|
||||
];
|
||||
const SSO_REVOKED: &[&str] = &[
|
||||
"UnauthorizedException",
|
||||
"Session token not found or invalid",
|
||||
];
|
||||
const DENIED_CODES: &[&str] = &[
|
||||
"AccessDeniedException",
|
||||
"AccessDenied",
|
||||
"UnauthorizedOperation",
|
||||
];
|
||||
const BAD_CREDENTIAL_CODES: &[&str] = &[
|
||||
"ExpiredToken",
|
||||
"InvalidClientTokenId",
|
||||
"SignatureDoesNotMatch",
|
||||
"UnrecognizedClientException",
|
||||
];
|
||||
|
||||
/// Classify any SDK error (an `SdkError`, a credentials error, ...) from
|
||||
/// calling `action` (e.g. `iam:GetRole`). Matching is on the full display
|
||||
/// chain, which carries both provider messages and service error codes.
|
||||
pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError {
|
||||
let text = DisplayErrorContext(err).to_string();
|
||||
let mut chain = std::iter::successors(Some(err), |e| (*e).source());
|
||||
let profile = chain
|
||||
.clone()
|
||||
.find_map(|e| e.downcast_ref::<ProfileCredentialsError>());
|
||||
// The service's own message, when one is in the chain.
|
||||
let service_message = chain
|
||||
.find_map(|e| e.downcast_ref::<ErrorMetadata>())
|
||||
.and_then(|m| m.message())
|
||||
.map(str::to_string);
|
||||
let message = service_message.unwrap_or_else(|| text.clone());
|
||||
|
||||
if SSO_EXPIRED.iter().any(|s| text.contains(s)) {
|
||||
return AwsError::SsoExpired {
|
||||
profile: profile.map(|p| p.profile.clone()).unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
// SSO's GetRoleCredentials on a revoked or signed-out session. Only
|
||||
// inside a profile's credentials: other services use this code too.
|
||||
if let Some(p) = profile.filter(|_| SSO_REVOKED.iter().any(|s| text.contains(s))) {
|
||||
return AwsError::SsoExpired {
|
||||
profile: p.profile.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(p) = profile.filter(|p| p.not_loaded) {
|
||||
return AwsError::ProfileNotFound {
|
||||
profile: p.profile.clone(),
|
||||
};
|
||||
}
|
||||
if DENIED_CODES.iter().any(|c| text.contains(c)) {
|
||||
return AwsError::AccessDenied {
|
||||
action: action.to_string(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
if BAD_CREDENTIAL_CODES.iter().any(|c| text.contains(c)) {
|
||||
return AwsError::InvalidCredentials { message };
|
||||
}
|
||||
AwsError::Other { message: text }
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Who the connection's credentials are, and which IAM principal the
|
||||
//! policy simulator should be asked about.
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct CallerIdentity {
|
||||
pub account: String,
|
||||
pub arn: String,
|
||||
/// STS `UserId`: the principal's unique id (for a role session,
|
||||
/// `<role id>:<session name>`).
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// `sts:GetCallerIdentity`, which needs no IAM permission: it is the
|
||||
/// connection test itself.
|
||||
pub async fn caller_identity(cfg: &SdkConfig) -> Result<CallerIdentity, AwsError> {
|
||||
const ACTION: &str = "sts:GetCallerIdentity";
|
||||
let out = aws_sdk_sts::Client::new(cfg)
|
||||
.get_caller_identity()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let field = |v: Option<&str>, name: &str| {
|
||||
v.map(str::to_string).ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no {name}"),
|
||||
})
|
||||
};
|
||||
Ok(CallerIdentity {
|
||||
account: field(out.account(), "Account")?,
|
||||
arn: field(out.arn(), "Arn")?,
|
||||
user_id: field(out.user_id(), "UserId")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The principal to simulate, as far as the caller's ARN tells us.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PrincipalArn {
|
||||
/// An IAM user or role ARN, usable as `PolicySourceArn` directly.
|
||||
Exact(String),
|
||||
/// A role session: the role's ARN carries a path the session ARN lacks,
|
||||
/// so it has to be looked up (`resolve_principal`).
|
||||
AssumedRole { account: String, role_name: String },
|
||||
}
|
||||
|
||||
/// Map the caller's ARN to a simulatable principal. Root, federated users
|
||||
/// and anything unrecognised cannot be simulated; the caller falls back to
|
||||
/// probing instead.
|
||||
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError> {
|
||||
let unsupported = || AwsError::Other {
|
||||
message: format!("IAM policy simulation isn't possible for the principal {caller_arn}"),
|
||||
};
|
||||
// arn:<partition>:<service>:<region>:<account>:<resource>
|
||||
let mut parts = caller_arn.splitn(6, ':');
|
||||
let (Some("arn"), Some(partition), Some(service), Some(_region), Some(account), Some(resource)) = (
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
) else {
|
||||
return Err(unsupported());
|
||||
};
|
||||
if partition.is_empty() || account.is_empty() {
|
||||
return Err(unsupported());
|
||||
}
|
||||
match service {
|
||||
"iam" if resource.starts_with("user/") || resource.starts_with("role/") => {
|
||||
Ok(PrincipalArn::Exact(caller_arn.to_string()))
|
||||
}
|
||||
"sts" => match resource
|
||||
.strip_prefix("assumed-role/")
|
||||
.and_then(|r| r.split_once('/'))
|
||||
{
|
||||
Some((role_name, _session)) if !role_name.is_empty() => Ok(PrincipalArn::AssumedRole {
|
||||
account: account.to_string(),
|
||||
role_name: role_name.to_string(),
|
||||
}),
|
||||
_ => Err(unsupported()),
|
||||
},
|
||||
_ => Err(unsupported()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The full ARN to simulate. An assumed role needs `iam:GetRole` to recover
|
||||
/// the role's path (SSO roles live under `/aws-reserved/...`).
|
||||
pub async fn resolve_principal(
|
||||
iam: &aws_sdk_iam::Client,
|
||||
p: PrincipalArn,
|
||||
) -> Result<String, AwsError> {
|
||||
const ACTION: &str = "iam:GetRole";
|
||||
let (account, role_name) = match p {
|
||||
PrincipalArn::Exact(arn) => return Ok(arn),
|
||||
PrincipalArn::AssumedRole { account, role_name } => (account, role_name),
|
||||
};
|
||||
let out = iam
|
||||
.get_role()
|
||||
.role_name(&role_name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let arn = out
|
||||
.role()
|
||||
.map(|r| r.arn().to_string())
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no role for {role_name}"),
|
||||
})?;
|
||||
// The role must be the session's own: same account.
|
||||
if arn.split(':').nth(4) != Some(account.as_str()) {
|
||||
return Err(AwsError::Other {
|
||||
message: format!("{ACTION} for {role_name} returned {arn}, outside account {account}"),
|
||||
});
|
||||
}
|
||||
Ok(arn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aws::error::classify_sdk_error;
|
||||
use crate::aws::AwsError;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn iam_user_arn_is_used_as_is() {
|
||||
let arn = "arn:aws:iam::111122223333:user/alice";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::Exact(arn.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iam_role_arn_is_used_as_is() {
|
||||
let arn = "arn:aws:iam::111122223333:role/ops/deployer";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::Exact(arn.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assumed_role_arn_maps_to_role_name() {
|
||||
let arn = "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "AWSReservedSSO_Admin_abc".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_federated_arns_are_rejected_for_simulation() {
|
||||
for arn in [
|
||||
"arn:aws:iam::1:root",
|
||||
"arn:aws:sts::1:federated-user/bob",
|
||||
"not-an-arn",
|
||||
] {
|
||||
match principal_for_simulation(arn) {
|
||||
Err(AwsError::Other { message }) => {
|
||||
assert!(
|
||||
message.contains("simulation isn't possible"),
|
||||
"{arn}: {message}"
|
||||
)
|
||||
}
|
||||
other => panic!("{arn}: expected Other, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-shot-per-connection HTTP server that answers every request with
|
||||
/// `status` and `body`, and reports each request it saw.
|
||||
async fn fake_endpoint(
|
||||
status: &'static str,
|
||||
body: &'static str,
|
||||
) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut req = Vec::new();
|
||||
loop {
|
||||
let n = match sock.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
req.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&req).into_owned();
|
||||
let Some(head_end) = text.find("\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let len = text[..head_end]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case("content-length")
|
||||
.then(|| v.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if req.len() < head_end + 4 + len {
|
||||
continue;
|
||||
}
|
||||
let _ = tx.send(text);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
req.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(format!("http://127.0.0.1:{port}"), rx)
|
||||
}
|
||||
|
||||
fn iam_client(endpoint: String) -> aws_sdk_iam::Client {
|
||||
let conf = aws_sdk_iam::Config::builder()
|
||||
.behavior_version(aws_sdk_iam::config::BehaviorVersion::latest())
|
||||
.region(aws_sdk_iam::config::Region::new("us-east-1"))
|
||||
.credentials_provider(aws_sdk_iam::config::Credentials::new(
|
||||
"AKIDTEST", "secret", None, None, "test",
|
||||
))
|
||||
.endpoint_url(endpoint)
|
||||
.build();
|
||||
aws_sdk_iam::Client::from_conf(conf)
|
||||
}
|
||||
|
||||
const ROLE_ARN: &str = "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc";
|
||||
|
||||
#[tokio::test]
|
||||
async fn assumed_role_arn_maps_to_role_arn_with_path() {
|
||||
const BODY: &str = r#"<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
|
||||
<GetRoleResult>
|
||||
<Role>
|
||||
<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>
|
||||
<RoleName>AWSReservedSSO_Admin_abc</RoleName>
|
||||
<RoleId>AROAEXAMPLEID</RoleId>
|
||||
<Arn>arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc</Arn>
|
||||
<CreateDate>2024-01-01T00:00:00Z</CreateDate>
|
||||
</Role>
|
||||
</GetRoleResult>
|
||||
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
|
||||
</GetRoleResponse>"#;
|
||||
let (endpoint, mut seen) = fake_endpoint("200 OK", BODY).await;
|
||||
let p = PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "AWSReservedSSO_Admin_abc".into(),
|
||||
};
|
||||
let arn = resolve_principal(&iam_client(endpoint), p).await.unwrap();
|
||||
assert_eq!(arn, ROLE_ARN);
|
||||
let req = seen.recv().await.unwrap();
|
||||
assert!(req.contains("Action=GetRole"), "{req}");
|
||||
assert!(req.contains("RoleName=AWSReservedSSO_Admin_abc"), "{req}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exact_principal_needs_no_lookup() {
|
||||
// No endpoint answers: any request would fail the test.
|
||||
let client = iam_client("http://127.0.0.1:9".into());
|
||||
let arn = "arn:aws:iam::111122223333:user/alice";
|
||||
let got = resolve_principal(&client, PrincipalArn::Exact(arn.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, arn);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_denied_get_role_is_access_denied_for_get_role() {
|
||||
const BODY: &str = r#"<ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
|
||||
<Error>
|
||||
<Type>Sender</Type>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>User: arn:aws:sts::111122223333:assumed-role/R/alice is not authorized to perform: iam:GetRole</Message>
|
||||
</Error>
|
||||
<RequestId>req-2</RequestId>
|
||||
</ErrorResponse>"#;
|
||||
let (endpoint, _seen) = fake_endpoint("403 Forbidden", BODY).await;
|
||||
let p = PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "R".into(),
|
||||
};
|
||||
match resolve_principal(&iam_client(endpoint), p).await {
|
||||
Err(AwsError::AccessDenied { action, message }) => {
|
||||
assert_eq!(action, "iam:GetRole");
|
||||
assert!(
|
||||
message.contains("is not authorized to perform: iam:GetRole"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AccessDenied, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Msg(&'static str, Option<Box<dyn std::error::Error + 'static>>);
|
||||
impl std::fmt::Display for Msg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Msg {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.1.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_sso_token_is_reported_as_sso_expired() {
|
||||
for leaf in [
|
||||
"The SSO session associated with this profile has expired or is otherwise invalid",
|
||||
"Token has expired and refresh failed",
|
||||
] {
|
||||
// Deep in the chain, as the SDK reports it.
|
||||
let err = Msg("dispatch failure", Some(Box::new(Msg(leaf, None))));
|
||||
assert!(
|
||||
matches!(
|
||||
classify_sdk_error("sts:GetCallerIdentity", &err),
|
||||
AwsError::SsoExpired { .. }
|
||||
),
|
||||
"{leaf}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// SSO's GetRoleCredentials on a revoked or signed-out session. Inside a
|
||||
/// profile connection's credentials that means "sign in again".
|
||||
#[test]
|
||||
fn a_revoked_sso_session_is_reported_as_sso_expired() {
|
||||
use crate::aws::error::ProfileCredentialsError;
|
||||
use aws_credential_types::provider::error::CredentialsError;
|
||||
let leaf = CredentialsError::provider_error(
|
||||
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
|
||||
);
|
||||
let err = CredentialsError::provider_error(ProfileCredentialsError {
|
||||
profile: "dev".into(),
|
||||
not_loaded: false,
|
||||
source: leaf,
|
||||
});
|
||||
assert_eq!(
|
||||
classify_sdk_error(
|
||||
"sts:GetCallerIdentity",
|
||||
&Msg("dispatch failure", Some(Box::new(err)))
|
||||
),
|
||||
AwsError::SsoExpired {
|
||||
profile: "dev".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The same error code from some other service, outside any profile's
|
||||
/// credentials, is not an SSO problem.
|
||||
#[test]
|
||||
fn an_unauthorized_exception_outside_profile_credentials_is_not_sso() {
|
||||
let err = Msg(
|
||||
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
|
||||
None,
|
||||
);
|
||||
assert!(!matches!(
|
||||
classify_sdk_error("ecs:ListClusters", &err),
|
||||
AwsError::SsoExpired { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_error_codes_are_classified() {
|
||||
let denied = Msg(
|
||||
"service error: unhandled error (AccessDeniedException)",
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_sdk_error("ecs:ListClusters", &denied),
|
||||
AwsError::AccessDenied { action, .. } if action == "ecs:ListClusters"
|
||||
));
|
||||
for code in [
|
||||
"ExpiredToken",
|
||||
"InvalidClientTokenId",
|
||||
"SignatureDoesNotMatch",
|
||||
] {
|
||||
let text: &'static str =
|
||||
Box::leak(format!("service error: unhandled error ({code})").into_boxed_str());
|
||||
assert!(
|
||||
matches!(
|
||||
classify_sdk_error("sts:GetCallerIdentity", &Msg(text, None)),
|
||||
AwsError::InvalidCredentials { .. }
|
||||
),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_sdk_error(
|
||||
"sts:GetCallerIdentity",
|
||||
&Msg("io error: connection refused", None)
|
||||
),
|
||||
AwsError::Other { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
//! The tier policies against real IAM: `check_permissions` run against
|
||||
//! throwaway roles that hold the shipped policies, and the few statements
|
||||
//! whose behaviour only a real request shows (Secrets Manager's KMS calls,
|
||||
//! the permissions boundary, the admin tier's role and key fences).
|
||||
//!
|
||||
//! Ignored; needs credentials for a **disposable** account that may create
|
||||
//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets:
|
||||
//!
|
||||
//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials
|
||||
//! files (read the way a saved connection reads it, so `AWS_*` key
|
||||
//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and
|
||||
//! `AWS_CONFIG_FILE` still choose the files);
|
||||
//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd app/src-tauri
|
||||
//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
|
||||
//! cargo test aws::live_tests -- --ignored --test-threads=1
|
||||
//! ```
|
||||
//!
|
||||
//! Every resource is named `triple-c-e2e-<id>…` (secrets
|
||||
//! `triple-c/triple-c-e2e-<id>/…`, because the policies only cover
|
||||
//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test
|
||||
//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days).
|
||||
//! IAM is free; a run costs a few cents at most (secrets for minutes, keys
|
||||
//! pending deletion).
|
||||
|
||||
use std::future::Future;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use futures_util::FutureExt;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::permissions::{
|
||||
check_permissions, simulation_request, Method, PermissionApi, PermissionReport,
|
||||
SdkPermissionApi, Summary, Verdict,
|
||||
};
|
||||
use super::policies::{
|
||||
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
|
||||
};
|
||||
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::storage::secure::AwsKeys;
|
||||
|
||||
const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3");
|
||||
const PATH: &str = "/triple-c-e2e/";
|
||||
const SESSION: &str = "triple-c-e2e";
|
||||
|
||||
fn var(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
|
||||
}
|
||||
|
||||
/// What one test created, for the guard to delete.
|
||||
#[derive(Default)]
|
||||
struct Created {
|
||||
roles: Vec<String>,
|
||||
policies: Vec<String>,
|
||||
secrets: Vec<String>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
struct E2e {
|
||||
cfg: SdkConfig,
|
||||
iam: aws_sdk_iam::Client,
|
||||
sts: aws_sdk_sts::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
kms: aws_sdk_kms::Client,
|
||||
region: String,
|
||||
account: String,
|
||||
/// `triple-c-e2e-<random>`: the prefix of everything this test creates.
|
||||
prefix: String,
|
||||
created: Mutex<Created>,
|
||||
}
|
||||
|
||||
/// A role this test created.
|
||||
struct Role {
|
||||
name: String,
|
||||
arn: String,
|
||||
/// `RoleId`: an assumed session's `aws:userid` is `<id>:<session>`.
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
fn session_user_id(&self) -> String {
|
||||
format!("{}:{SESSION}", self.id)
|
||||
}
|
||||
|
||||
/// The identity `check_permissions` sees for the role itself (not a
|
||||
/// session of it): an IAM role ARN, simulated as-is.
|
||||
fn identity(&self, account: &str) -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: account.to_string(),
|
||||
arn: self.arn.clone(),
|
||||
user_id: self.session_user_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` with a fresh fixture and deletes what it created afterwards,
|
||||
/// whether or not `body` panicked.
|
||||
async fn e2e<F, Fut>(body: F)
|
||||
where
|
||||
F: FnOnce(Arc<E2e>) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let region = var("TRIPLEC_AWS_E2E_REGION");
|
||||
let conn = HostConnection {
|
||||
id: "e2e".into(),
|
||||
name: "e2e".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile {
|
||||
name: var("TRIPLEC_AWS_E2E_PROFILE"),
|
||||
},
|
||||
default_region: region.clone(),
|
||||
},
|
||||
};
|
||||
let cfg = sdk_config(&conn, ®ion).await.expect("sdk config");
|
||||
let me = caller_identity(&cfg)
|
||||
.await
|
||||
.expect("the e2e profile's identity");
|
||||
let e = Arc::new(E2e {
|
||||
iam: aws_sdk_iam::Client::new(&cfg),
|
||||
sts: aws_sdk_sts::Client::new(&cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(&cfg),
|
||||
kms: aws_sdk_kms::Client::new(&cfg),
|
||||
cfg,
|
||||
region,
|
||||
account: me.account,
|
||||
prefix: format!(
|
||||
"triple-c-e2e-{}",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..12]
|
||||
),
|
||||
created: Mutex::default(),
|
||||
});
|
||||
let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await;
|
||||
e.cleanup().await;
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag {
|
||||
aws_sdk_iam::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag {
|
||||
aws_sdk_secretsmanager::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// `Err(AccessDenied)` for a denied call; panics on anything else.
|
||||
fn denied<T: std::fmt::Debug, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) {
|
||||
match r {
|
||||
Err(e) => match classify_sdk_error(what, &e) {
|
||||
AwsError::AccessDenied { .. } => {}
|
||||
other => panic!("{what}: expected AccessDenied, got {other}"),
|
||||
},
|
||||
Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok<T, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) -> T {
|
||||
r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e)))
|
||||
}
|
||||
|
||||
impl E2e {
|
||||
fn name(&self, suffix: &str) -> String {
|
||||
format!("{}-{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
fn secret_name(&self, suffix: &str) -> String {
|
||||
format!("triple-c/{}/{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
/// A role assumable by this account, under `/triple-c-e2e/` (so its ARN
|
||||
/// has a path, as an SSO role's does), optionally bounded.
|
||||
async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role {
|
||||
let name = self.name(suffix);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
self.account
|
||||
);
|
||||
let mut req = self
|
||||
.iam
|
||||
.create_role()
|
||||
.role_name(&name)
|
||||
.path(PATH)
|
||||
.assume_role_policy_document(trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(boundary.map(str::to_string));
|
||||
for (k, v) in tags {
|
||||
req = req.tags(iam_tag(k, v));
|
||||
}
|
||||
let out = ok("iam:CreateRole", req.send().await);
|
||||
self.created.lock().unwrap().roles.push(name.clone());
|
||||
let r = out.role().unwrap();
|
||||
Role {
|
||||
name,
|
||||
arn: r.arn().to_string(),
|
||||
id: r.role_id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tag_role(&self, role: &Role, k: &str, v: &str) {
|
||||
ok(
|
||||
"iam:TagRole",
|
||||
self.iam
|
||||
.tag_role()
|
||||
.role_name(&role.name)
|
||||
.tags(iam_tag(k, v))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A customer managed policy (the tier policies are too big to hold
|
||||
/// both inline on one role).
|
||||
async fn managed_policy(&self, suffix: &str, document: &str) -> String {
|
||||
let out = ok(
|
||||
"iam:CreatePolicy",
|
||||
self.iam
|
||||
.create_policy()
|
||||
.policy_name(self.name(suffix))
|
||||
.policy_document(document)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
let arn = out.policy().unwrap().arn().unwrap().to_string();
|
||||
self.created.lock().unwrap().policies.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
async fn attach(&self, role: &Role, policy_arn: &str) {
|
||||
ok(
|
||||
"iam:AttachRolePolicy",
|
||||
self.iam
|
||||
.attach_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_arn(policy_arn)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
async fn put_inline(&self, role: &Role, document: &str) {
|
||||
ok(
|
||||
"iam:PutRolePolicy",
|
||||
self.iam
|
||||
.put_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_name("under-test")
|
||||
.policy_document(document)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A role holding the given tier policies, tagged as owned by its own
|
||||
/// sessions (the strict-tenancy task role shape `iam:PassRole` checks).
|
||||
async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role {
|
||||
let role = self.role(suffix, None, &[]).await;
|
||||
self.tag_role(&role, "triple-c:owner", &role.session_user_id())
|
||||
.await;
|
||||
for &tier in tiers {
|
||||
let name = match tier {
|
||||
Tier::WorkspaceUser => "workspace",
|
||||
Tier::EnvironmentAdmin => "admin",
|
||||
};
|
||||
let arn = self
|
||||
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
|
||||
.await;
|
||||
self.attach(&role, &arn).await;
|
||||
}
|
||||
role
|
||||
}
|
||||
|
||||
/// An SDK config for a session of `role`, once IAM lets it be assumed
|
||||
/// and its policies have propagated.
|
||||
async fn assume(&self, role: &Role) -> SdkConfig {
|
||||
let mut last = None;
|
||||
for _ in 0..20 {
|
||||
match self
|
||||
.sts
|
||||
.assume_role()
|
||||
.role_arn(&role.arn)
|
||||
.role_session_name(SESSION)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => {
|
||||
let c = out.credentials().unwrap();
|
||||
let keys = AwsKeys {
|
||||
access_key_id: c.access_key_id().to_string(),
|
||||
secret_access_key: c.secret_access_key().to_string(),
|
||||
session_token: Some(c.session_token().to_string()),
|
||||
};
|
||||
// New sessions see a just-attached policy after a few
|
||||
// seconds; denials in that window would be noise.
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return sdk_config_for_keys(&keys, &self.region);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("could not assume {}: {last:?}", role.arn);
|
||||
}
|
||||
|
||||
/// `check_permissions` as the e2e profile, simulating `identity`'s
|
||||
/// principal. Retried once if a check failed (IAM is eventually
|
||||
/// consistent for a role created a moment ago).
|
||||
async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport {
|
||||
let api = SdkPermissionApi::new(&self.cfg);
|
||||
for attempt in 0..3 {
|
||||
let report = check_permissions(&api, identity, &self.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let failed = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.any(|r| r.note.is_some());
|
||||
if !failed || attempt == 2 {
|
||||
return report;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String {
|
||||
let tag = |k: &str, v: &str| {
|
||||
aws_sdk_kms::types::Tag::builder()
|
||||
.tag_key(k)
|
||||
.tag_value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let r = kms
|
||||
.create_key()
|
||||
.description(self.name(suffix))
|
||||
.tags(tag("triple-c:component", "environment"))
|
||||
.tags(tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await;
|
||||
let arn = ok("kms:CreateKey", r)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.created.lock().unwrap().keys.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
fn record_secret(&self, arn: &str) {
|
||||
self.created.lock().unwrap().secrets.push(arn.to_string());
|
||||
}
|
||||
|
||||
/// An admin-created secret encrypted with `key`.
|
||||
async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String {
|
||||
let mut req = self
|
||||
.secrets
|
||||
.create_secret()
|
||||
.name(self.secret_name(suffix))
|
||||
.kms_key_id(key)
|
||||
.secret_string("e2e")
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1));
|
||||
if let Some((k, v)) = tag {
|
||||
req = req.tags(sm_tag(k, v));
|
||||
}
|
||||
let arn = ok("secretsmanager:CreateSecret", req.send().await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.record_secret(&arn);
|
||||
arn
|
||||
}
|
||||
|
||||
/// Deletes everything recorded, logging (not failing on) errors so one
|
||||
/// failure doesn't strand the rest.
|
||||
async fn cleanup(&self) {
|
||||
let created = std::mem::take(&mut *self.created.lock().unwrap());
|
||||
let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}");
|
||||
for arn in &created.secrets {
|
||||
if let Err(e) = self
|
||||
.secrets
|
||||
.delete_secret()
|
||||
.secret_id(arn)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("secretsmanager:DeleteSecret", &e);
|
||||
if !e.to_string().contains("ResourceNotFound") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for arn in &created.keys {
|
||||
if let Err(e) = self
|
||||
.kms
|
||||
.schedule_key_deletion()
|
||||
.key_id(arn)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e);
|
||||
if !e.to_string().contains("pending") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &created.roles {
|
||||
if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await {
|
||||
for p in out.policy_names() {
|
||||
let _ = self
|
||||
.iam
|
||||
.delete_role_policy()
|
||||
.role_name(name)
|
||||
.policy_name(p)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Ok(out) = self
|
||||
.iam
|
||||
.list_attached_role_policies()
|
||||
.role_name(name)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
for p in out.attached_policies() {
|
||||
let _ = self
|
||||
.iam
|
||||
.detach_role_policy()
|
||||
.role_name(name)
|
||||
.policy_arn(p.policy_arn().unwrap_or_default())
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.iam.delete_role().role_name(name).send().await {
|
||||
warn(name, &classify_sdk_error("iam:DeleteRole", &e));
|
||||
}
|
||||
}
|
||||
for arn in &created.policies {
|
||||
if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await {
|
||||
warn(arn, &classify_sdk_error("iam:DeletePolicy", &e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
report
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|t| t.tier == tier)
|
||||
.unwrap()
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| (r.action.clone(), r.verdict.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
verdicts(report, tier)
|
||||
.into_iter()
|
||||
.filter(|(_, v)| *v != Verdict::Allowed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_the_workspace_policy_checks_as_workspace_user() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(
|
||||
not_allowed(&report, Tier::WorkspaceUser),
|
||||
[],
|
||||
"every workspace check must be allowed"
|
||||
);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert!(
|
||||
verdicts(&report, Tier::EnvironmentAdmin)
|
||||
.iter()
|
||||
.any(|(_, v)| *v == Verdict::Denied),
|
||||
"the workspace policy alone grants admin actions"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_both_policies_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let role = e
|
||||
.tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []);
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
|
||||
e2e(|e| async move {
|
||||
let role = e.role("none", None, &[]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] {
|
||||
let rows = verdicts(&report, tier);
|
||||
for c in checks(tier) {
|
||||
assert!(
|
||||
rows.iter().any(|(a, _)| a == c.action),
|
||||
"{tier:?}: no row for {}",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
for (action, v) in rows {
|
||||
assert_eq!(v, Verdict::Denied, "{tier:?} {action}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The workspace policy without `InspectOwnPrincipal`, assumed and checked
|
||||
/// *as* itself: `iam:GetRole` is denied, so the check probes.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_without_simulate_permission_falls_back_to_probes() {
|
||||
e2e(|e| async move {
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
|
||||
doc["Statement"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|s| s["Sid"] != "InspectOwnPrincipal");
|
||||
let role = e.role("nosim", None, &[]).await;
|
||||
let arn = e.managed_policy("nosim", &doc.to_string()).await;
|
||||
e.attach(&role, &arn).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert_eq!(me.user_id, role.session_user_id());
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let Method::Probed { reason } = &report.method else {
|
||||
panic!("expected probes, got {:?}", report.method);
|
||||
};
|
||||
assert!(reason.contains("iam:GetRole"), "{reason}");
|
||||
let rows = verdicts(&report, Tier::WorkspaceUser);
|
||||
let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone();
|
||||
for probed in [
|
||||
"ecs:ListClusters",
|
||||
"ssm:GetParametersByPath",
|
||||
"cloudformation:DescribeStacks",
|
||||
"secretsmanager:ListSecrets",
|
||||
] {
|
||||
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
|
||||
}
|
||||
assert_eq!(verdict("iam:GetRole"), Verdict::Denied);
|
||||
assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified);
|
||||
// A probed run never claims the admin tier, and the denied
|
||||
// own-principal check keeps it from claiming the workspace tier.
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The whole workspace policy, assumed: the real `iam:GetRole` on its own
|
||||
/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn an_assumed_workspace_role_checks_itself_by_simulation() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("self", &[Tier::WorkspaceUser]).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert!(me.arn.contains(":assumed-role/"), "{}", me.arn);
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The e2e profile itself (an account administrator): every check allowed.
|
||||
/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/`
|
||||
/// role path through `iam:GetRole`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_e2e_profile_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let me = caller_identity(&e.cfg).await.expect("identity");
|
||||
let report = e.check_as(&me).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let failed: Vec<_> = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.filter(|r| r.verdict != Verdict::Allowed)
|
||||
.collect();
|
||||
assert!(failed.is_empty(), "{failed:#?}");
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The spec's most important rule: no shell, stop or session in someone
|
||||
/// else's task.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_owner_condition_denies_another_owners_task() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await;
|
||||
let me = role.session_user_id();
|
||||
let api = SdkPermissionApi::new(&e.cfg);
|
||||
let p = Placeholders {
|
||||
region: &e.region,
|
||||
account: &e.account,
|
||||
principal: &role.arn,
|
||||
};
|
||||
let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| {
|
||||
["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action)
|
||||
&& c.resource.contains(":task/")
|
||||
});
|
||||
let mut n = 0;
|
||||
for c in task_checks {
|
||||
n += 1;
|
||||
let mine = simulation_request(c, &p, &me);
|
||||
assert!(
|
||||
api.simulate(&mine).await.unwrap(),
|
||||
"{} on my own task",
|
||||
c.action
|
||||
);
|
||||
let mut theirs = mine.clone();
|
||||
for entry in &mut theirs.context {
|
||||
if entry.key == "aws:ResourceTag/triple-c:owner" {
|
||||
entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()];
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on another owner's task",
|
||||
c.action
|
||||
);
|
||||
theirs
|
||||
.context
|
||||
.retain(|x| x.key != "aws:ResourceTag/triple-c:owner");
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on an untagged task",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
assert_eq!(n, 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// SimulatePrincipalPolicy *does* apply the principal's own permissions
|
||||
/// boundary (it does not apply SCPs or session policies).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_simulator_applies_the_principals_permissions_boundary() {
|
||||
e2e(|e| async move {
|
||||
let boundary = e.managed_policy("boundary", permissions_boundary_json()).await;
|
||||
let role = e.role("bounded", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&role,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let sim = |name: &str| {
|
||||
e.iam
|
||||
.simulate_principal_policy()
|
||||
.policy_source_arn(&role.arn)
|
||||
.action_names("secretsmanager:GetSecretValue")
|
||||
.resource_arns(format!(
|
||||
"arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf",
|
||||
e.region, e.account
|
||||
))
|
||||
.send()
|
||||
};
|
||||
let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| {
|
||||
out.evaluation_results()[0].eval_decision().as_str().to_string()
|
||||
};
|
||||
assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed");
|
||||
assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The secret statements and the KMS rules behind them, with real calls:
|
||||
/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it),
|
||||
/// the explicit Deny on the AWS-managed key, owner and component fences,
|
||||
/// and the permissions boundary reading `aws:ResourceTag` of the key.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() {
|
||||
e2e(|e| async move {
|
||||
let key = e.environment_key(&e.kms, "env-key").await;
|
||||
let aws_managed = ok(
|
||||
"kms:DescribeKey",
|
||||
e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await,
|
||||
)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await;
|
||||
let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await;
|
||||
let task = e.role("secrets-task", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&task,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let environment = e
|
||||
.admin_secret("environment", &key, Some(("triple-c:component", "environment")))
|
||||
.await;
|
||||
let someone_elses = e
|
||||
.admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob")))
|
||||
.await;
|
||||
|
||||
let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await);
|
||||
let me = user.session_user_id();
|
||||
let create = |suffix: &str, key: Option<&str>| {
|
||||
as_user
|
||||
.create_secret()
|
||||
.name(e.secret_name(suffix))
|
||||
.set_kms_key_id(key.map(str::to_string))
|
||||
.secret_string("v1")
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
};
|
||||
|
||||
// Own secret on the environment key, named by ARN.
|
||||
let own = ok("create own secret", create("own", Some(&key)).await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
e.record_secret(&own);
|
||||
ok(
|
||||
"tag for cleanup",
|
||||
e.secrets
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
ok(
|
||||
"put own",
|
||||
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
|
||||
);
|
||||
|
||||
// Any other key is refused.
|
||||
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let r = create(&format!("refused-{i}"), k).await;
|
||||
if let Ok(out) = &r {
|
||||
e.record_secret(out.arn().unwrap());
|
||||
}
|
||||
denied(what, r);
|
||||
}
|
||||
// Only the ordinary tag keys, and never claiming or changing others'.
|
||||
denied(
|
||||
"tag own with triple-c:component",
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.tags(sm_tag("triple-c:component", "x"))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
|
||||
denied(
|
||||
&format!("put {what}"),
|
||||
as_user.put_secret_value().secret_id(arn).secret_string("x").send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("delete {what}"),
|
||||
as_user.delete_secret().secret_id(arn).send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("claim {what}"),
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(arn)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
// A task role under the boundary decrypts through Secrets Manager
|
||||
// only with the component-tagged key.
|
||||
let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await);
|
||||
let value = ok(
|
||||
"task reads own",
|
||||
as_task.get_secret_value().secret_id(&own).send().await,
|
||||
);
|
||||
assert_eq!(value.secret_string(), Some("v2"));
|
||||
|
||||
ok(
|
||||
"delete own",
|
||||
as_user
|
||||
.delete_secret()
|
||||
.secret_id(&own)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The admin tier's fences, for real: roles only with the Triple-C
|
||||
/// boundary, the boundary never removed or swapped or edited, and keys
|
||||
/// created only with `triple-c:component` (tag-on-create passes the
|
||||
/// `kms:TagResource` rule) and destroyed only if they carry it.
|
||||
///
|
||||
/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates
|
||||
/// that policy and deletes it again, unless the account already has one, in
|
||||
/// which case it uses it and leaves it alone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
|
||||
e2e(|e| async move {
|
||||
let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account);
|
||||
if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() {
|
||||
ok(
|
||||
"create the boundary",
|
||||
e.iam
|
||||
.create_policy()
|
||||
.policy_name(PERMISSIONS_BOUNDARY_NAME)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
e.created.lock().unwrap().policies.push(boundary.clone());
|
||||
}
|
||||
let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await;
|
||||
let admin = e
|
||||
.tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let cfg = e.assume(&admin).await;
|
||||
let iam = aws_sdk_iam::Client::new(&cfg);
|
||||
let kms = aws_sdk_kms::Client::new(&cfg);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
e.account
|
||||
);
|
||||
let create = |suffix: &str, b: Option<&str>| {
|
||||
iam.create_role()
|
||||
.role_name(e.name(suffix))
|
||||
.assume_role_policy_document(&trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(b.map(str::to_string))
|
||||
.send()
|
||||
};
|
||||
for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] {
|
||||
let r = create(suffix, b).await;
|
||||
if r.is_ok() {
|
||||
e.created.lock().unwrap().roles.push(e.name(suffix));
|
||||
}
|
||||
denied(&format!("create {suffix} role"), r);
|
||||
}
|
||||
let child = e.name("bounded");
|
||||
ok("create bounded role", create("bounded", Some(&boundary)).await);
|
||||
e.created.lock().unwrap().roles.push(child.clone());
|
||||
ok(
|
||||
"put a policy on the bounded role",
|
||||
iam.put_role_policy()
|
||||
.role_name(&child)
|
||||
.policy_name("p")
|
||||
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"remove the boundary",
|
||||
iam.delete_role_permissions_boundary().role_name(&child).send().await,
|
||||
);
|
||||
denied(
|
||||
"swap the boundary",
|
||||
iam.put_role_permissions_boundary()
|
||||
.role_name(&child)
|
||||
.permissions_boundary(&other_boundary)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"edit the boundary",
|
||||
iam.create_policy_version()
|
||||
.policy_arn(&boundary)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
|
||||
// Keys: tagged create passes (tag-on-create authorizes
|
||||
// kms:TagResource against the new key's requested tags).
|
||||
let untagged = kms.create_key().description(e.name("untagged")).send().await;
|
||||
if let Ok(out) = &untagged {
|
||||
e.created
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys
|
||||
.push(out.key_metadata().unwrap().arn().unwrap().to_string());
|
||||
}
|
||||
denied("create an untagged key", untagged);
|
||||
let key = e.environment_key(&kms, "admin-key").await;
|
||||
ok(
|
||||
"schedule the environment key's deletion",
|
||||
kms.schedule_key_deletion()
|
||||
.key_id(&key)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! AWS connection plumbing: an SDK config built from a saved
|
||||
//! `HostConnection`, the caller's identity, the tier policies and the
|
||||
//! permission check.
|
||||
//!
|
||||
|
||||
mod connection;
|
||||
mod error;
|
||||
mod identity;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
pub mod permissions;
|
||||
pub mod policies;
|
||||
|
||||
pub use connection::{sdk_config, sdk_config_for_keys};
|
||||
pub use error::AwsError;
|
||||
pub use identity::{caller_identity, CallerIdentity};
|
||||
@@ -0,0 +1,557 @@
|
||||
//! The permission check: which Triple-C tier the connection's credentials
|
||||
//! can act as, by simulating each tier's checks (`policies::checks`) with
|
||||
//! IAM's policy simulator, or — when the caller may not simulate itself —
|
||||
//! by a few read-only probes.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use futures_util::stream::{self, StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn};
|
||||
use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier};
|
||||
|
||||
/// How many simulations are in flight at once.
|
||||
const CONCURRENCY: usize = 2;
|
||||
|
||||
/// One `SimulatePrincipalPolicy` request, fully rendered.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SimulationRequest {
|
||||
pub principal: String,
|
||||
pub action: String,
|
||||
pub resource: String,
|
||||
pub context: Vec<ContextEntry>,
|
||||
}
|
||||
|
||||
/// A context key with every value it carries in the request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextEntry {
|
||||
pub key: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
impl ContextEntry {
|
||||
/// Sent as `StringList` when the key has more than one value, `String`
|
||||
/// otherwise.
|
||||
pub fn is_list(&self) -> bool {
|
||||
self.values.len() > 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the simulation for `check`: the resource and every literal context
|
||||
/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` =
|
||||
/// `user_id` added. A key appearing more than once becomes one entry holding
|
||||
/// all its values (in order, without duplicates).
|
||||
pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest {
|
||||
let mut context: Vec<ContextEntry> = Vec::new();
|
||||
let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) {
|
||||
Some(e) if e.values.contains(&value) => {}
|
||||
Some(e) => e.values.push(value),
|
||||
None => context.push(ContextEntry {
|
||||
key: key.to_string(),
|
||||
values: vec![value],
|
||||
}),
|
||||
};
|
||||
for (key, value) in check.context {
|
||||
let value = match value {
|
||||
ContextValue::OwnerId => user_id.to_string(),
|
||||
ContextValue::Literal(template) => render(template, p),
|
||||
};
|
||||
add(key, value);
|
||||
}
|
||||
add("aws:userid", user_id.to_string());
|
||||
SimulationRequest {
|
||||
principal: p.principal.to_string(),
|
||||
action: check.action.to_string(),
|
||||
resource: render(check.resource, p),
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
/// The read-only calls used when simulation isn't possible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Probe {
|
||||
EcsListClusters,
|
||||
SsmGetParametersByPath,
|
||||
CfnDescribeStacks,
|
||||
SecretsListSecrets,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
const ALL: [Probe; 4] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
/// The probe standing in for a read check's service, if there is one.
|
||||
fn for_action(action: &str) -> Option<Probe> {
|
||||
match action.split_once(':')?.0 {
|
||||
"ecs" => Some(Probe::EcsListClusters),
|
||||
"ssm" => Some(Probe::SsmGetParametersByPath),
|
||||
"cloudformation" => Some(Probe::CfnDescribeStacks),
|
||||
"secretsmanager" => Some(Probe::SecretsListSecrets),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn action(self) -> &'static str {
|
||||
match self {
|
||||
Probe::EcsListClusters => "ecs:ListClusters",
|
||||
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
|
||||
Probe::CfnDescribeStacks => "cloudformation:DescribeStacks",
|
||||
Probe::SecretsListSecrets => "secretsmanager:ListSecrets",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the permission check needs from AWS. The methods return `Send`
|
||||
/// futures so `check_permissions` is `Send` (a Tauri command needs that) for
|
||||
/// any implementation; an impl may still write them as `async fn`.
|
||||
pub trait PermissionApi: Send + Sync {
|
||||
/// The ARN to simulate for the caller's principal (`iam:GetRole` for a
|
||||
/// role session).
|
||||
fn resolve_principal(
|
||||
&self,
|
||||
principal: PrincipalArn,
|
||||
) -> impl Future<Output = Result<String, AwsError>> + Send;
|
||||
/// Whether the simulator says `allowed`.
|
||||
fn simulate(
|
||||
&self,
|
||||
request: &SimulationRequest,
|
||||
) -> impl Future<Output = Result<bool, AwsError>> + Send;
|
||||
fn probe(&self, probe: Probe) -> impl Future<Output = Result<(), AwsError>> + Send;
|
||||
}
|
||||
|
||||
/// The real thing, from a connection's `SdkConfig`.
|
||||
pub struct SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client,
|
||||
ecs: aws_sdk_ecs::Client,
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
cfn: aws_sdk_cloudformation::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
}
|
||||
|
||||
impl SdkPermissionApi {
|
||||
pub fn new(cfg: &aws_config::SdkConfig) -> Self {
|
||||
// IAM throttles SimulatePrincipalPolicy hard: a full check (about a
|
||||
// hundred simulations) failed a third of its rows with "Rate
|
||||
// exceeded" under standard retries (verified live). Adaptive retry
|
||||
// backs off client-side once throttled, with room to retry.
|
||||
let iam = aws_sdk_iam::config::Builder::from(cfg)
|
||||
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
|
||||
.build();
|
||||
SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client::from_conf(iam),
|
||||
ecs: aws_sdk_ecs::Client::new(cfg),
|
||||
ssm: aws_sdk_ssm::Client::new(cfg),
|
||||
cfn: aws_sdk_cloudformation::Client::new(cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(cfg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionApi for SdkPermissionApi {
|
||||
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
|
||||
identity::resolve_principal(&self.iam, principal).await
|
||||
}
|
||||
|
||||
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
|
||||
use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum};
|
||||
const ACTION: &str = "iam:SimulatePrincipalPolicy";
|
||||
let entries = request.context.iter().map(|e| {
|
||||
SdkEntry::builder()
|
||||
.context_key_name(&e.key)
|
||||
.set_context_key_values(Some(e.values.clone()))
|
||||
.context_key_type(if e.is_list() {
|
||||
ContextKeyTypeEnum::StringList
|
||||
} else {
|
||||
ContextKeyTypeEnum::String
|
||||
})
|
||||
.build()
|
||||
});
|
||||
let out = self
|
||||
.iam
|
||||
.simulate_principal_policy()
|
||||
.policy_source_arn(&request.principal)
|
||||
.action_names(&request.action)
|
||||
.resource_arns(&request.resource)
|
||||
.set_context_entries(Some(entries.collect()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let decision = out
|
||||
.evaluation_results()
|
||||
.first()
|
||||
.map(|r| r.eval_decision().as_str())
|
||||
.ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no result for {}", request.action),
|
||||
})?;
|
||||
Ok(decision == "allowed")
|
||||
}
|
||||
|
||||
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
|
||||
let action = probe.action();
|
||||
let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e);
|
||||
match probe {
|
||||
Probe::EcsListClusters => self
|
||||
.ecs
|
||||
.list_clusters()
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::SsmGetParametersByPath => self
|
||||
.ssm
|
||||
.get_parameters_by_path()
|
||||
.path("/triple-c/environments")
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::CfnDescribeStacks => self
|
||||
.cfn
|
||||
.describe_stacks()
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::SecretsListSecrets => self
|
||||
.secrets
|
||||
.list_secrets()
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
Allowed,
|
||||
Denied,
|
||||
Unverified,
|
||||
}
|
||||
|
||||
/// One row per action. An action checked against several resources (or
|
||||
/// with several contexts) is `Denied` if any of them is, else `Unverified`
|
||||
/// if any is, else `Allowed`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActionResult {
|
||||
pub action: String,
|
||||
pub verdict: Verdict,
|
||||
/// Set when the row is `Unverified` because a call failed (throttling,
|
||||
/// network, ...): the error, as `AwsError` displays it. Such a row
|
||||
/// blocks its tier's claim; an `Unverified` row without a note (a write
|
||||
/// in probe mode, an unprobed read) does not.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TierResult {
|
||||
pub tier: Tier,
|
||||
pub results: Vec<ActionResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Method {
|
||||
Simulated,
|
||||
/// Simulation wasn't possible; `reason` says why.
|
||||
Probed {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Summary {
|
||||
EnvironmentAdmin,
|
||||
WorkspaceUser,
|
||||
Insufficient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PermissionReport {
|
||||
pub identity: CallerIdentity,
|
||||
pub region: String,
|
||||
pub method: Method,
|
||||
pub summary: Summary,
|
||||
/// `[WorkspaceUser, EnvironmentAdmin]`.
|
||||
pub tiers: Vec<TierResult>,
|
||||
}
|
||||
|
||||
const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin];
|
||||
|
||||
/// One check's result, before checks are folded into per-action rows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Outcome {
|
||||
Allowed,
|
||||
Denied,
|
||||
/// Not checkable by design (a write in probe mode, an unprobed read).
|
||||
Unverified,
|
||||
/// The call failed for a reason that says nothing about the permission.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The worst of `outcomes`: Denied, then Failed (as `Unverified` with the
|
||||
/// first failure as its note), then Unverified, then Allowed.
|
||||
fn aggregate(outcomes: &[Outcome]) -> (Verdict, Option<String>) {
|
||||
if outcomes.contains(&Outcome::Denied) {
|
||||
return (Verdict::Denied, None);
|
||||
}
|
||||
if let Some(note) = outcomes.iter().find_map(|o| match o {
|
||||
Outcome::Failed(m) => Some(m.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return (Verdict::Unverified, Some(note));
|
||||
}
|
||||
if outcomes.contains(&Outcome::Unverified) {
|
||||
return (Verdict::Unverified, None);
|
||||
}
|
||||
(Verdict::Allowed, None)
|
||||
}
|
||||
|
||||
/// Folds per-check outcomes (in `checks(tier)` order) into one row per
|
||||
/// action, in first-appearance order.
|
||||
fn tier_result(tier: Tier, outcomes: Vec<Outcome>) -> TierResult {
|
||||
let mut rows: Vec<(&'static str, Vec<Outcome>)> = Vec::new();
|
||||
for (check, outcome) in checks(tier).iter().zip(outcomes) {
|
||||
match rows.iter_mut().find(|(a, _)| *a == check.action) {
|
||||
Some((_, os)) => os.push(outcome),
|
||||
None => rows.push((check.action, vec![outcome])),
|
||||
}
|
||||
}
|
||||
TierResult {
|
||||
tier,
|
||||
results: rows
|
||||
.into_iter()
|
||||
.map(|(action, os)| {
|
||||
let (verdict, note) = aggregate(&os);
|
||||
ActionResult {
|
||||
action: action.to_string(),
|
||||
verdict,
|
||||
note,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tier is claimed when none of its rows is `Denied` or failed (has a
|
||||
/// note). Design-level `Unverified` rows don't block. A probed run never
|
||||
/// claims the admin tier (R15): it is nearly all writes, which probes can't
|
||||
/// see, so "nothing denied" would mean nothing.
|
||||
fn summary(tiers: &[TierResult], probed: bool) -> Summary {
|
||||
let clear = |t: Tier| {
|
||||
tiers.iter().filter(|r| r.tier == t).all(|r| {
|
||||
r.results
|
||||
.iter()
|
||||
.all(|a| a.verdict != Verdict::Denied && a.note.is_none())
|
||||
})
|
||||
};
|
||||
if !clear(Tier::WorkspaceUser) {
|
||||
Summary::Insufficient
|
||||
} else if !probed && clear(Tier::EnvironmentAdmin) {
|
||||
Summary::EnvironmentAdmin
|
||||
} else {
|
||||
Summary::WorkspaceUser
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that mean the credentials themselves don't work: the UI acts on
|
||||
/// these (sign in, re-enter keys) rather than showing a report.
|
||||
fn is_credential_error(e: &AwsError) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
AwsError::SsoExpired { .. }
|
||||
| AwsError::InvalidCredentials { .. }
|
||||
| AwsError::ProfileNotFound { .. }
|
||||
| AwsError::KeysMissing
|
||||
)
|
||||
}
|
||||
|
||||
/// Which tiers the caller's credentials can act as, in `region`.
|
||||
///
|
||||
/// Simulates every check of both tiers against the caller's principal. It
|
||||
/// falls back to probes when the principal can't be simulated (root,
|
||||
/// federated user), resolving it is denied (`iam:GetRole`), or the first
|
||||
/// simulation is denied. In probe mode a read check whose service has a
|
||||
/// probe takes that probe's result; the check behind the fallback (the
|
||||
/// caller's own `iam:GetRole` / `iam:SimulatePrincipalPolicy`) is `Denied`;
|
||||
/// every other check is `Unverified`.
|
||||
///
|
||||
/// A credential error (SSO expired, invalid or missing credentials) from
|
||||
/// any call is returned as `Err`, as is any other failure resolving the
|
||||
/// principal. Other failures of individual checks (throttling, network)
|
||||
/// make that row `Unverified` with a note, which blocks its tier's claim.
|
||||
/// See [`summary`] for the claim rules.
|
||||
pub async fn check_permissions<A: PermissionApi>(
|
||||
api: &A,
|
||||
identity: &CallerIdentity,
|
||||
region: &str,
|
||||
) -> Result<PermissionReport, AwsError> {
|
||||
let (method, outcomes) = match simulate_all(api, identity, region).await? {
|
||||
Simulation::Done(outcomes) => (Method::Simulated, outcomes),
|
||||
Simulation::Fallback { reason, denied } => (
|
||||
Method::Probed { reason },
|
||||
probe_all(api, denied.as_deref()).await?,
|
||||
),
|
||||
};
|
||||
let failed: Vec<&str> = outcomes
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|o| match o {
|
||||
Outcome::Failed(m) => Some(m.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(first) = failed.first() {
|
||||
let total: usize = outcomes.iter().map(Vec::len).sum();
|
||||
log::warn!(
|
||||
"AWS permission check: {} of {total} checks could not be evaluated (first: {first})",
|
||||
failed.len()
|
||||
);
|
||||
}
|
||||
let probed = matches!(method, Method::Probed { .. });
|
||||
let tiers: Vec<TierResult> = TIERS
|
||||
.into_iter()
|
||||
.zip(outcomes)
|
||||
.map(|(tier, os)| tier_result(tier, os))
|
||||
.collect();
|
||||
Ok(PermissionReport {
|
||||
identity: identity.clone(),
|
||||
region: region.to_string(),
|
||||
method,
|
||||
summary: summary(&tiers, probed),
|
||||
tiers,
|
||||
})
|
||||
}
|
||||
|
||||
enum Simulation {
|
||||
Done([Vec<Outcome>; 2]),
|
||||
/// Simulation isn't possible. `denied` is the action whose denial
|
||||
/// caused it, if one did.
|
||||
Fallback {
|
||||
reason: String,
|
||||
denied: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
fn fallback(e: AwsError) -> Self {
|
||||
let denied = match &e {
|
||||
AwsError::AccessDenied { action, .. } => Some(action.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Simulation::Fallback {
|
||||
reason: e.to_string(),
|
||||
denied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated_outcome(r: Result<bool, AwsError>) -> Result<Outcome, AwsError> {
|
||||
match r {
|
||||
Ok(true) => Ok(Outcome::Allowed),
|
||||
Ok(false) => Ok(Outcome::Denied),
|
||||
Err(e) if is_credential_error(&e) => Err(e),
|
||||
Err(e) => Ok(Outcome::Failed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tier outcomes by simulation, or why simulation isn't possible.
|
||||
async fn simulate_all<A: PermissionApi>(
|
||||
api: &A,
|
||||
identity: &CallerIdentity,
|
||||
region: &str,
|
||||
) -> Result<Simulation, AwsError> {
|
||||
let principal = match principal_for_simulation(&identity.arn) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(Simulation::fallback(e)),
|
||||
};
|
||||
let principal = match api.resolve_principal(principal).await {
|
||||
Ok(p) => p,
|
||||
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let p = Placeholders {
|
||||
region,
|
||||
account: &identity.account,
|
||||
principal: &principal,
|
||||
};
|
||||
let mut requests = TIERS
|
||||
.into_iter()
|
||||
.flat_map(|t| checks(t).iter())
|
||||
.map(|c| simulation_request(c, &p, &identity.user_id))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter();
|
||||
let Some(first) = requests.next() else {
|
||||
return Ok(Simulation::Done([Vec::new(), Vec::new()]));
|
||||
};
|
||||
// The first simulation alone: if the caller may not simulate at all,
|
||||
// that's the moment to fall back, before spending the rest.
|
||||
let first = match api.simulate(&first).await {
|
||||
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
|
||||
r => simulated_outcome(r)?,
|
||||
};
|
||||
// Owned requests: a closure over borrowed ones makes the future's
|
||||
// `Send` unprovable (rustc's higher-ranked closure limitation).
|
||||
let rest: Vec<Outcome> = stream::iter(requests)
|
||||
.map(|r| async move { simulated_outcome(api.simulate(&r).await) })
|
||||
.buffered(CONCURRENCY)
|
||||
.try_collect()
|
||||
.await?;
|
||||
let mut all = std::iter::once(first).chain(rest);
|
||||
Ok(Simulation::Done(
|
||||
TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Per-tier outcomes from the four probes. `denied` is the action whose
|
||||
/// denial forced probing; the caller's own-principal checks of it are
|
||||
/// `Denied`.
|
||||
async fn probe_all<A: PermissionApi>(
|
||||
api: &A,
|
||||
denied: Option<&str>,
|
||||
) -> Result<[Vec<Outcome>; 2], AwsError> {
|
||||
let results =
|
||||
futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) }))
|
||||
.await;
|
||||
if let Some((_, Err(e))) = results
|
||||
.iter()
|
||||
.find(|(_, r)| matches!(r, Err(e) if is_credential_error(e)))
|
||||
{
|
||||
return Err(e.clone());
|
||||
}
|
||||
let probe_outcome = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) {
|
||||
Some((_, Ok(()))) => Outcome::Allowed,
|
||||
Some((_, Err(AwsError::AccessDenied { .. }))) => Outcome::Denied,
|
||||
Some((_, Err(e))) => Outcome::Failed(e.to_string()),
|
||||
None => Outcome::Unverified,
|
||||
};
|
||||
Ok(TIERS.map(|t| {
|
||||
checks(t)
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if denied == Some(c.action) && c.resource == "{principal}" {
|
||||
return Outcome::Denied;
|
||||
}
|
||||
match Probe::for_action(c.action) {
|
||||
Some(probe) if !c.write => probe_outcome(probe),
|
||||
_ => Outcome::Unverified,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,632 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier};
|
||||
|
||||
const USER_ID: &str = "AROAEXAMPLEID:alice@example.com";
|
||||
const ROLE_ARN: &str =
|
||||
"arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc";
|
||||
|
||||
fn identity() -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: "111122223333".into(),
|
||||
arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com"
|
||||
.into(),
|
||||
user_id: USER_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROBES: [Probe; 4] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
/// A simulation call as the fake saw it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Simulated {
|
||||
principal: String,
|
||||
action: String,
|
||||
resource: String,
|
||||
context: Vec<ContextEntry>,
|
||||
}
|
||||
|
||||
struct FakeApi {
|
||||
allow: HashSet<&'static str>,
|
||||
simulate_denied: bool,
|
||||
get_role_denied: bool,
|
||||
/// Returned by `resolve_principal` (after `get_role_denied`).
|
||||
resolve_error: Option<AwsError>,
|
||||
/// Returned by `simulate` for the named action.
|
||||
simulate_errors: Vec<(&'static str, AwsError)>,
|
||||
probes_ok: HashSet<Probe>,
|
||||
/// Returned by every probe not in `probes_ok` (default: AccessDenied).
|
||||
probe_error: Option<AwsError>,
|
||||
simulated: Mutex<Vec<Simulated>>,
|
||||
probed: Mutex<Vec<Probe>>,
|
||||
}
|
||||
|
||||
impl FakeApi {
|
||||
fn allowing(tiers: &[Tier]) -> Self {
|
||||
let allow = tiers
|
||||
.iter()
|
||||
.flat_map(|t| checks(*t).iter().map(|c| c.action))
|
||||
.collect();
|
||||
FakeApi {
|
||||
allow,
|
||||
simulate_denied: false,
|
||||
get_role_denied: false,
|
||||
resolve_error: None,
|
||||
simulate_errors: Vec::new(),
|
||||
probes_ok: ALL_PROBES.into_iter().collect(),
|
||||
probe_error: None,
|
||||
simulated: Mutex::new(Vec::new()),
|
||||
probed: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated(&self) -> Vec<Simulated> {
|
||||
self.simulated.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionApi for FakeApi {
|
||||
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
|
||||
if self.get_role_denied {
|
||||
return Err(AwsError::AccessDenied {
|
||||
action: "iam:GetRole".into(),
|
||||
message: "not authorized to perform: iam:GetRole".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = &self.resolve_error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(match principal {
|
||||
PrincipalArn::Exact(arn) => arn,
|
||||
PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
|
||||
self.simulated.lock().unwrap().push(Simulated {
|
||||
principal: request.principal.clone(),
|
||||
action: request.action.clone(),
|
||||
resource: request.resource.clone(),
|
||||
context: request.context.clone(),
|
||||
});
|
||||
if self.simulate_denied {
|
||||
return Err(AwsError::AccessDenied {
|
||||
action: "iam:SimulatePrincipalPolicy".into(),
|
||||
message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(),
|
||||
});
|
||||
}
|
||||
if let Some((_, e)) = self
|
||||
.simulate_errors
|
||||
.iter()
|
||||
.find(|(a, _)| *a == request.action)
|
||||
{
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(self.allow.contains(request.action.as_str()))
|
||||
}
|
||||
|
||||
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
|
||||
self.probed.lock().unwrap().push(probe);
|
||||
if self.probes_ok.contains(&probe) {
|
||||
Ok(())
|
||||
} else if let Some(e) = &self.probe_error {
|
||||
Err(e.clone())
|
||||
} else {
|
||||
Err(AwsError::AccessDenied {
|
||||
action: format!("{probe:?}"),
|
||||
message: "denied".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result<PermissionReport, AwsError> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(check_permissions(api, id, "eu-west-1"))
|
||||
}
|
||||
|
||||
fn run(api: &FakeApi) -> PermissionReport {
|
||||
try_run(api, &identity()).unwrap()
|
||||
}
|
||||
|
||||
fn row<'a>(report: &'a PermissionReport, t: Tier, action: &str) -> &'a ActionResult {
|
||||
tier(report, t)
|
||||
.results
|
||||
.iter()
|
||||
.find(|r| r.action == action)
|
||||
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
|
||||
}
|
||||
|
||||
fn throttled() -> AwsError {
|
||||
AwsError::Other {
|
||||
message: "Rate exceeded (Throttling)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tier(report: &PermissionReport, t: Tier) -> &TierResult {
|
||||
report.tiers.iter().find(|r| r.tier == t).unwrap()
|
||||
}
|
||||
|
||||
fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict {
|
||||
tier(report, t)
|
||||
.results
|
||||
.iter()
|
||||
.find(|r| r.action == action)
|
||||
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
|
||||
.verdict
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_workspace_policy_reports_workspace_user() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(report.region, "eu-west-1");
|
||||
assert_eq!(report.identity, identity());
|
||||
let tiers: Vec<Tier> = report.tiers.iter().map(|t| t.tier).collect();
|
||||
assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
assert!(tier(&report, Tier::WorkspaceUser)
|
||||
.results
|
||||
.iter()
|
||||
.all(|r| r.verdict == Verdict::Allowed));
|
||||
// Every check was simulated against the resolved role, not the session.
|
||||
let sims = api.simulated();
|
||||
let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len();
|
||||
assert_eq!(sims.len(), total);
|
||||
assert!(sims.iter().all(|s| s.principal == ROLE_ARN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_and_workspace_policies_report_environment_admin() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_action_reports_insufficient_and_names_it() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.allow.remove("iam:PassRole");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "iam:PassRole"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_admin_gap_still_reports_workspace_user() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.allow.remove("cloudformation:CreateStack");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack"
|
||||
),
|
||||
Verdict::Denied
|
||||
);
|
||||
}
|
||||
|
||||
/// An action checked against several resources is one row; any denial wins.
|
||||
#[test]
|
||||
fn an_action_checked_twice_is_one_row_and_denied_if_either_is() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let report = run(&api);
|
||||
let rows = tier(&report, Tier::WorkspaceUser)
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| r.action == "ssm:StartSession")
|
||||
.count();
|
||||
assert_eq!(rows, 1);
|
||||
let failed = || Outcome::Failed("Throttling".into());
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]),
|
||||
(Verdict::Denied, None)
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]),
|
||||
(Verdict::Unverified, Some("Throttling".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Unverified]),
|
||||
(Verdict::Unverified, None)
|
||||
);
|
||||
assert_eq!(aggregate(&[Outcome::Allowed]), (Verdict::Allowed, None));
|
||||
}
|
||||
|
||||
fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> {
|
||||
s.context.iter().find(|e| e.key == key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_context_is_sent_with_owner_scoped_checks() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
run(&api);
|
||||
let sims = api.simulated();
|
||||
let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap();
|
||||
let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap();
|
||||
assert_eq!(owner.values, [USER_ID]);
|
||||
// Literals are rendered like resources.
|
||||
let cluster = context_value(run_task, "ecs:cluster").unwrap();
|
||||
assert_eq!(
|
||||
cluster.values,
|
||||
["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"]
|
||||
);
|
||||
assert_eq!(
|
||||
run_task.resource,
|
||||
"arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1"
|
||||
);
|
||||
// aws:userid on every simulation.
|
||||
for s in &sims {
|
||||
let userid = context_value(s, "aws:userid")
|
||||
.unwrap_or_else(|| panic!("{} has no aws:userid", s.action));
|
||||
assert_eq!(userid.values, [USER_ID], "{}", s.action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_principal_placeholder_is_the_resolved_role() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
run(&api);
|
||||
let sims = api.simulated();
|
||||
let get_role = sims
|
||||
.iter()
|
||||
.find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN)
|
||||
.expect("iam:GetRole on the caller's own role");
|
||||
// The policies name a user's own ARN by `${aws:username}`.
|
||||
assert_eq!(
|
||||
context_value(get_role, "aws:username").unwrap().values,
|
||||
[ROLE_ARN.rsplit('/').next().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_context_key_is_one_string_list_entry() {
|
||||
static CTX: &[(&str, ContextValue)] = &[
|
||||
("aws:TagKeys", ContextValue::Literal("triple-c:owner")),
|
||||
(
|
||||
"ecs:cluster",
|
||||
ContextValue::Literal("arn:{region}:{account}"),
|
||||
),
|
||||
("aws:TagKeys", ContextValue::Literal("triple-c:component")),
|
||||
("aws:TagKeys", ContextValue::OwnerId),
|
||||
];
|
||||
let check = Check {
|
||||
action: "ecs:TagResource",
|
||||
resource: "arn:aws:ecs:{region}:{account}:task/x",
|
||||
context: CTX,
|
||||
write: true,
|
||||
};
|
||||
let p = Placeholders {
|
||||
region: "eu-west-1",
|
||||
account: "111122223333",
|
||||
principal: ROLE_ARN,
|
||||
};
|
||||
let req = simulation_request(&check, &p, USER_ID);
|
||||
assert_eq!(req.principal, ROLE_ARN);
|
||||
assert_eq!(req.action, "ecs:TagResource");
|
||||
assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x");
|
||||
assert_eq!(
|
||||
req.context,
|
||||
[
|
||||
ContextEntry {
|
||||
key: "aws:TagKeys".into(),
|
||||
values: vec![
|
||||
"triple-c:owner".into(),
|
||||
"triple-c:component".into(),
|
||||
USER_ID.into()
|
||||
],
|
||||
},
|
||||
ContextEntry {
|
||||
key: "ecs:cluster".into(),
|
||||
values: vec!["arn:eu-west-1:111122223333".into()],
|
||||
},
|
||||
ContextEntry {
|
||||
key: "aws:userid".into(),
|
||||
values: vec![USER_ID.into()],
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(req.context[0].is_list());
|
||||
assert!(!req.context[1].is_list());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() {
|
||||
static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)];
|
||||
let check = Check {
|
||||
action: "sts:GetCallerIdentity",
|
||||
resource: "*",
|
||||
context: CTX,
|
||||
write: false,
|
||||
};
|
||||
let p = Placeholders {
|
||||
region: "r",
|
||||
account: "a",
|
||||
principal: "p",
|
||||
};
|
||||
let req = simulation_request(&check, &p, USER_ID);
|
||||
assert_eq!(
|
||||
req.context,
|
||||
[ContextEntry {
|
||||
key: "aws:userid".into(),
|
||||
values: vec![USER_ID.into()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_denied = true;
|
||||
let report = run(&api);
|
||||
match &report.method {
|
||||
Method::Probed { reason } => {
|
||||
assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}")
|
||||
}
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
// Only the first simulation was tried.
|
||||
assert_eq!(api.simulated().len(), 1);
|
||||
let mut probed = api.probed.lock().unwrap().clone();
|
||||
probed.sort_by_key(|p| format!("{p:?}"));
|
||||
let mut all = ALL_PROBES.to_vec();
|
||||
all.sort_by_key(|p| format!("{p:?}"));
|
||||
assert_eq!(probed, all);
|
||||
|
||||
let ws = Tier::WorkspaceUser;
|
||||
assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified);
|
||||
assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified);
|
||||
// Reads with a probe get its result.
|
||||
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ecs:DescribeClusters"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ssm:GetParametersByPath"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "cloudformation:DescribeStacks"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "secretsmanager:ListSecrets"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
// Reads with no probe are unverified.
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "logs:FilterLogEvents"),
|
||||
Verdict::Unverified
|
||||
);
|
||||
assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "sts:GetCallerIdentity"),
|
||||
Verdict::Unverified
|
||||
);
|
||||
// The fallback itself is evidence: the caller may not simulate itself,
|
||||
// which both tiers need.
|
||||
for t in [ws, Tier::EnvironmentAdmin] {
|
||||
assert_eq!(
|
||||
verdict(&report, t, "iam:SimulatePrincipalPolicy"),
|
||||
Verdict::Denied
|
||||
);
|
||||
}
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denied_get_role_falls_back_to_probes() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
let report = run(&api);
|
||||
match &report.method {
|
||||
Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"),
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
assert!(api.simulated().is_empty());
|
||||
assert_eq!(api.probed.lock().unwrap().len(), 4);
|
||||
// Denied on the caller's own role: direct evidence.
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "iam:GetRole"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsimulatable_principal_falls_back_to_probes() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
match &report.method {
|
||||
Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"),
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
assert!(api.simulated().is_empty());
|
||||
}
|
||||
|
||||
/// R15: the admin tier is nearly all writes, so a probed run can't vouch
|
||||
/// for it — every probe passing still caps at WorkspaceUser.
|
||||
#[test]
|
||||
fn a_probed_run_never_claims_environment_admin() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
assert!(matches!(report.method, Method::Probed { .. }));
|
||||
assert!(tier(&report, Tier::EnvironmentAdmin)
|
||||
.results
|
||||
.iter()
|
||||
.all(|r| r.verdict != Verdict::Denied));
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttled_simulations_block_the_tier_and_carry_notes() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_errors = vec![("cloudformation:CreateStack", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let r = row(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack",
|
||||
);
|
||||
assert_eq!(r.verdict, Verdict::Unverified);
|
||||
assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}");
|
||||
// Allowed rows carry no note.
|
||||
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
|
||||
// In the workspace tier, it blocks everything.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_errors = vec![("ecs:RunTask", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
assert!(row(&report, Tier::WorkspaceUser, "ecs:RunTask")
|
||||
.note
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throttled_first_simulation_is_a_note_not_a_fallback() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let first = checks(Tier::WorkspaceUser)[0].action;
|
||||
api.simulate_errors = vec![(first, throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert!(row(&report, Tier::WorkspaceUser, first).note.is_some());
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_sso_session_mid_check_is_an_error() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let sso = AwsError::SsoExpired {
|
||||
profile: "dev".into(),
|
||||
};
|
||||
api.simulate_errors = vec![("logs:FilterLogEvents", sso.clone())];
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), sso);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_errors_anywhere_are_errors() {
|
||||
let bad = AwsError::InvalidCredentials {
|
||||
message: "ExpiredToken".into(),
|
||||
};
|
||||
// From resolving the principal.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.resolve_error = Some(bad.clone());
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
|
||||
// From the first simulation.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.simulate_errors = vec![(checks(Tier::WorkspaceUser)[0].action, bad.clone())];
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
|
||||
// From a probe.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
api.probes_ok.remove(&Probe::CfnDescribeStacks);
|
||||
api.probe_error = Some(AwsError::KeysMissing);
|
||||
assert_eq!(
|
||||
try_run(&api, &identity()).unwrap_err(),
|
||||
AwsError::KeysMissing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transport_error_resolving_the_principal_is_an_error() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let net = AwsError::Other {
|
||||
message: "dispatch failure: connection refused".into(),
|
||||
};
|
||||
api.resolve_error = Some(net.clone());
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), net);
|
||||
assert!(api.probed.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_probe_marks_its_reads_denied() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.simulate_denied = true;
|
||||
api.probes_ok.remove(&Probe::EcsListClusters);
|
||||
let report = run(&api);
|
||||
let ws = Tier::WorkspaceUser;
|
||||
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ecs:DescribeClusters"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "secretsmanager:ListSecrets"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_failing_for_another_reason_leaves_its_reads_unverified_and_blocks() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
api.probes_ok.remove(&Probe::EcsListClusters);
|
||||
api.probe_error = Some(AwsError::Other {
|
||||
message: "connection reset".into(),
|
||||
});
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
let r = row(&report, Tier::WorkspaceUser, "ecs:ListClusters");
|
||||
assert_eq!(r.verdict, Verdict::Unverified);
|
||||
assert!(r.note.as_deref().unwrap().contains("connection reset"));
|
||||
// Design-level unverified rows carry no note.
|
||||
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_serializes_for_the_ui() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
let v = serde_json::to_value(run(&api)).unwrap();
|
||||
// Probed because iam:GetRole is denied, which the workspace tier needs.
|
||||
assert_eq!(v["summary"], "insufficient");
|
||||
assert!(v["tiers"][0]["results"][0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("note"));
|
||||
assert_eq!(v["tiers"][0]["tier"], "workspace_user");
|
||||
assert!(v["method"]["probed"]["reason"].is_string(), "{v}");
|
||||
assert!(["allowed", "denied", "unverified"]
|
||||
.contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
/// Tauri commands need a `Send` future.
|
||||
#[allow(dead_code)]
|
||||
fn check_permissions_is_send() {
|
||||
fn assert_send<T: Send>(_: T) {}
|
||||
fn with<A: PermissionApi>(api: &A, id: &CallerIdentity) {
|
||||
assert_send(check_permissions(api, id, "r"));
|
||||
}
|
||||
let _ = with::<SdkPermissionApi>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -52,7 +52,8 @@ pub async fn run_sso_login(profile: &str) -> Result<(), String> {
|
||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||
|
||||
let status = tokio::process::Command::new("aws")
|
||||
.args(["sso", "login", "--profile", profile])
|
||||
// One argv element, so a name starting with `-` is never an option.
|
||||
.args(["sso", "login", &format!("--profile={profile}")])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Commands for host connections (AWS): add, update, remove, identity,
|
||||
//! sign-in and the permission check.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi};
|
||||
use crate::aws::policies::{policy_json, Tier};
|
||||
use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity};
|
||||
use crate::models::{
|
||||
validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth,
|
||||
HostConnection, HostKind,
|
||||
};
|
||||
use crate::storage::secure::{self, AwsKeys};
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
use crate::AppState;
|
||||
|
||||
fn other(message: impl Into<String>) -> AwsError {
|
||||
AwsError::Other { message: message.into() }
|
||||
}
|
||||
|
||||
fn check_id(id: &str) -> Result<(), String> {
|
||||
if secure::is_valid_connection_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Invalid connection id".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the identity check is asked to authenticate with.
|
||||
pub(crate) enum Credentials {
|
||||
/// A named host profile (the connection itself).
|
||||
Profile(HostConnection),
|
||||
/// Keys not yet in the keychain, and the region to call in.
|
||||
Keys(AwsKeys, String),
|
||||
}
|
||||
|
||||
/// Validate-free core of both add commands: `GetCallerIdentity` first, and
|
||||
/// only on success keys are stored and the connection saved. An expired SSO
|
||||
/// session on a profile still saves (the UI then offers "Sign in").
|
||||
pub(crate) async fn add_connection_with<I, Fut>(
|
||||
conn: HostConnection,
|
||||
keys: Option<AwsKeys>,
|
||||
identity: I,
|
||||
store_keys: impl Fn(&str, &AwsKeys) -> Result<(), String>,
|
||||
delete_keys: impl Fn(&str) -> Result<(), String>,
|
||||
settings: &SettingsStore,
|
||||
) -> Result<HostConnection, AwsError>
|
||||
where
|
||||
I: FnOnce(Credentials) -> Fut,
|
||||
Fut: Future<Output = Result<CallerIdentity, AwsError>>,
|
||||
{
|
||||
let HostKind::Aws { auth, default_region } = &conn.kind;
|
||||
let credentials = match (&keys, auth) {
|
||||
(Some(k), AwsAuth::Keys { .. }) => Credentials::Keys(k.clone(), default_region.clone()),
|
||||
(None, AwsAuth::Profile { .. }) => Credentials::Profile(conn.clone()),
|
||||
_ => return Err(other("Connection credentials do not match its auth method")),
|
||||
};
|
||||
match identity(credentials).await {
|
||||
Ok(_) => {}
|
||||
Err(AwsError::SsoExpired { .. }) if keys.is_none() => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
if let Some(k) = &keys {
|
||||
store_keys(&conn.id, k).map_err(other)?;
|
||||
}
|
||||
if let Err(e) = settings.modify(|all| {
|
||||
all.hosts.push(conn.clone());
|
||||
Ok(())
|
||||
}) {
|
||||
if keys.is_some() {
|
||||
let _ = delete_keys(&conn.id);
|
||||
}
|
||||
return Err(other(e));
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Removes the stored connection with this id, however malformed its other
|
||||
/// fields are (a broken entry must stay removable). Its keychain entry is
|
||||
/// deleted only for a `Keys` connection whose `keyring_ref` is its own valid id.
|
||||
pub(crate) fn remove_connection_with(
|
||||
id: &str,
|
||||
delete_keys: impl Fn(&str) -> Result<(), String>,
|
||||
settings: &SettingsStore,
|
||||
) -> Result<AppSettings, String> {
|
||||
check_id(id)?;
|
||||
let existing = settings
|
||||
.get()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.find(|h| h.id == id)
|
||||
.ok_or_else(|| "Connection not found".to_string())?;
|
||||
let HostKind::Aws { auth, .. } = &existing.kind;
|
||||
if let AwsAuth::Keys { keyring_ref } = auth {
|
||||
if keyring_ref == id && secure::is_valid_connection_id(keyring_ref) {
|
||||
delete_keys(keyring_ref)?;
|
||||
}
|
||||
}
|
||||
settings
|
||||
.modify(|s| {
|
||||
s.hosts.retain(|h| h.id != id);
|
||||
Ok(())
|
||||
})
|
||||
.map(|(saved, ())| saved)
|
||||
}
|
||||
|
||||
async fn real_identity(c: Credentials) -> Result<CallerIdentity, AwsError> {
|
||||
let cfg = match c {
|
||||
Credentials::Profile(conn) => {
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
sdk_config(&conn, default_region).await?
|
||||
}
|
||||
Credentials::Keys(keys, region) => sdk_config_for_keys(&keys, ®ion),
|
||||
};
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
|
||||
check_id(id)?;
|
||||
let conn = settings
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "Connection not found".to_string())?;
|
||||
// Defense in depth: stored values are re-checked before they reach the
|
||||
// keychain, the SDK or a subprocess.
|
||||
let HostKind::Aws { auth, default_region } = &conn.kind;
|
||||
validate_supported_region(default_region)?;
|
||||
match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
validate_profile_name(name)?;
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } if keyring_ref != id => {
|
||||
return Err("Connection's keychain reference does not match its id".to_string());
|
||||
}
|
||||
AwsAuth::Keys { .. } => {}
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> {
|
||||
Ok((
|
||||
validate_connection_name(name).map_err(other)?,
|
||||
validate_supported_region(region).map_err(other)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_aws_profile_connection(
|
||||
name: String,
|
||||
profile: String,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, default_region) = validated(&name, &default_region)?;
|
||||
let profile = validate_profile_name(&profile).map_err(other)?;
|
||||
let conn = HostConnection {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
kind: HostKind::Aws { auth: AwsAuth::Profile { name: profile }, default_region },
|
||||
};
|
||||
add_connection_with(
|
||||
conn,
|
||||
None,
|
||||
real_identity,
|
||||
secure::store_aws_keys,
|
||||
secure::delete_aws_keys,
|
||||
&state.settings_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_aws_keys_connection(
|
||||
name: String,
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: Option<String>,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, default_region) = validated(&name, &default_region)?;
|
||||
let access_key_id = access_key_id.trim().to_string();
|
||||
let secret_access_key = secret_access_key.trim().to_string();
|
||||
let session_token = session_token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if access_key_id.is_empty() || secret_access_key.is_empty() {
|
||||
return Err(other("Access key ID and secret access key are required"));
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let conn = HostConnection {
|
||||
id: id.clone(),
|
||||
name,
|
||||
kind: HostKind::Aws { auth: AwsAuth::Keys { keyring_ref: id }, default_region },
|
||||
};
|
||||
let keys = AwsKeys { access_key_id, secret_access_key, session_token };
|
||||
add_connection_with(
|
||||
conn,
|
||||
Some(keys),
|
||||
real_identity,
|
||||
secure::store_aws_keys,
|
||||
secure::delete_aws_keys,
|
||||
&state.settings_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_aws_connection(
|
||||
id: String,
|
||||
name: String,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, region) = validated(&name, &default_region)?;
|
||||
let (_, updated) = state
|
||||
.settings_store
|
||||
.modify(|settings| {
|
||||
aws_conn(&id, settings)?;
|
||||
let conn = settings.hosts.iter_mut().find(|h| h.id == id).expect("checked above");
|
||||
conn.name = name;
|
||||
let HostKind::Aws { default_region, .. } = &mut conn.kind;
|
||||
*default_region = region;
|
||||
Ok(conn.clone())
|
||||
})
|
||||
.map_err(other)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_host_connection(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
remove_connection_with(&id, secure::delete_aws_keys, &state.settings_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_connection_identity(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CallerIdentity, AwsError> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
let cfg = sdk_config(&conn, default_region).await?;
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_connection_sso_login(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get())?;
|
||||
let HostKind::Aws { auth, .. } = &conn.kind;
|
||||
let AwsAuth::Profile { name } = auth else {
|
||||
return Err("Only profile connections use SSO".to_string());
|
||||
};
|
||||
let name = validate_profile_name(name)?;
|
||||
super::aws_commands::run_sso_login(&name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_aws_permissions(
|
||||
id: String,
|
||||
region: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PermissionReport, AwsError> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
let region = match region {
|
||||
Some(r) => validate_supported_region(&r).map_err(other)?,
|
||||
None => default_region.clone(),
|
||||
};
|
||||
let cfg = sdk_config(&conn, ®ion).await?;
|
||||
let identity = caller_identity(&cfg).await?;
|
||||
let api = SdkPermissionApi::new(&cfg);
|
||||
check_permissions(&api, &identity, ®ion).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn aws_policy_document(tier: Tier) -> String {
|
||||
policy_json(tier).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn profile_conn() -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c1".into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keys_conn() -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c2".into(),
|
||||
name: "K".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keys() -> AwsKeys {
|
||||
AwsKeys { access_key_id: "AKIA".into(), secret_access_key: "s".into(), session_token: None }
|
||||
}
|
||||
|
||||
fn ident() -> CallerIdentity {
|
||||
CallerIdentity { account: "1".into(), arn: "arn:aws:iam::1:user/u".into(), user_id: "U".into() }
|
||||
}
|
||||
|
||||
fn store_in(dir: &tempfile::TempDir) -> SettingsStore {
|
||||
SettingsStore::at_path(dir.path().join("settings.json"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_saves_nothing_when_identity_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Err(AwsError::InvalidCredentials { message: "bad".into() }) },
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::InvalidCredentials { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
assert!(calls.borrow().is_empty(), "{:?}", calls.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_sso_expired_profile_is_still_saved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let saved = add_connection_with(
|
||||
profile_conn(),
|
||||
None,
|
||||
|_| async { Err(AwsError::SsoExpired { profile: "dev".into() }) },
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(()),
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.get().hosts, vec![saved]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_keys_add_stores_keys_then_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let saved = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|c| async move {
|
||||
assert!(matches!(c, Credentials::Keys(..)));
|
||||
Ok(ident())
|
||||
},
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*calls.borrow(), vec!["store c2".to_string()]);
|
||||
assert_eq!(settings.get().hosts, vec![saved]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keys_are_removed_if_saving_settings_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The parent directory does not exist, so saving fails.
|
||||
let settings = SettingsStore::at_path(dir.path().join("missing/settings.json"));
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Ok(ident()) },
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::Other { .. }));
|
||||
assert_eq!(*calls.borrow(), vec!["store c2".to_string(), "delete c2".to_string()]);
|
||||
assert!(settings.get().hosts.is_empty(), "no unsaved connection in memory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_sso_identity_error_on_a_profile_saves_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let err = add_connection_with(
|
||||
profile_conn(),
|
||||
None,
|
||||
|_| async { Err(AwsError::ProfileNotFound { profile: "dev".into() }) },
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(()),
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::ProfileNotFound { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_store_keys_saves_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let deletes = RefCell::new(0);
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Ok(ident()) },
|
||||
|_, _| Err("keychain locked".to_string()),
|
||||
|_| { *deletes.borrow_mut() += 1; Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::Other { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keys_connection_whose_keyring_ref_differs_from_its_id_is_refused() {
|
||||
let mut s = AppSettings::default();
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "other".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
s.hosts.push(c);
|
||||
assert!(aws_conn("c2", &s).is_err());
|
||||
s.hosts = vec![keys_conn()];
|
||||
assert!(aws_conn("c2", &s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_regions_and_profiles_are_revalidated() {
|
||||
let with = |profile: &str, region: &str| {
|
||||
let mut c = profile_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: profile.into() },
|
||||
default_region: region.into(),
|
||||
};
|
||||
let mut s = AppSettings::default();
|
||||
s.hosts.push(c);
|
||||
s
|
||||
};
|
||||
assert!(aws_conn("c1", &with("dev", "us-west-2")).is_ok());
|
||||
assert!(aws_conn("c1", &with("dev", "not a region")).is_err());
|
||||
assert!(aws_conn("c1", &with("a b", "us-west-2")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_with_an_invalid_stored_region_can_still_be_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
|
||||
default_region: "not a region".into(),
|
||||
};
|
||||
settings.update(AppSettings { hosts: vec![c, profile_conn()], ..AppSettings::default() }).unwrap();
|
||||
let deleted = RefCell::new(Vec::<String>::new());
|
||||
let after = remove_connection_with(
|
||||
"c2",
|
||||
|id| { deleted.borrow_mut().push(id.to_string()); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(after.hosts, vec![profile_conn()]);
|
||||
assert_eq!(settings.get().hosts, vec![profile_conn()]);
|
||||
assert_eq!(*deleted.borrow(), vec!["c2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_leaves_the_keychain_alone_when_keyring_ref_is_not_the_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
settings.update(AppSettings { hosts: vec![c], ..AppSettings::default() }).unwrap();
|
||||
remove_connection_with("c2", |_| panic!("must not touch the keychain"), &settings).unwrap();
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(remove_connection_with("c2", |_| Ok(()), &settings).is_err());
|
||||
assert!(remove_connection_with("bad id!", |_| Ok(()), &settings).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_and_govcloud_regions_are_refused_for_connections() {
|
||||
for r in ["cn-north-1", "us-gov-west-1"] {
|
||||
let e = validated("Prod", r).unwrap_err();
|
||||
assert!(matches!(&e, AwsError::Other { message } if message == "China and GovCloud regions aren't supported yet."), "{e:?}");
|
||||
}
|
||||
assert!(validated("Prod", "eu-west-1").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -173,10 +173,7 @@ pub(crate) mod ops {
|
||||
/// directories, so an id from outside (an import) must be the shape the
|
||||
/// commands mint: a UUID-like `[A-Za-z0-9-]{1,64}`.
|
||||
pub fn validate_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if ok {
|
||||
if crate::storage::secure::is_valid_connection_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("An account or marketplace id is malformed.".to_string())
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod file_commands;
|
||||
pub mod file_viewer_commands;
|
||||
pub mod gateway_commands;
|
||||
pub mod help_commands;
|
||||
pub mod host_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod marketplace_commands;
|
||||
|
||||
@@ -78,6 +78,13 @@ pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &Ap
|
||||
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
|
||||
}
|
||||
|
||||
/// Host connections change only through `host_commands` (which validates
|
||||
/// them and owns their keychain entries) and settings import, never through
|
||||
/// a settings save the frontend posts back.
|
||||
pub(crate) fn restore_host_fields(incoming: &mut AppSettings, stored: &AppSettings) {
|
||||
incoming.hosts = stored.hosts.clone();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
mut settings: AppSettings,
|
||||
@@ -87,6 +94,7 @@ pub async fn update_settings(
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
restore_marketplace_fields(&mut settings, &before);
|
||||
restore_host_fields(&mut settings, &before);
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
@@ -466,4 +474,27 @@ mod tests {
|
||||
"the edit the save was for still applies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_settings_cannot_change_hosts() {
|
||||
use crate::models::{AwsAuth, HostConnection, HostKind};
|
||||
let conn = |id: &str, region: &str| HostConnection {
|
||||
id: id.into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: region.into(),
|
||||
},
|
||||
};
|
||||
let mut stored = AppSettings::default();
|
||||
stored.hosts.push(conn("c1", "us-west-2"));
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.hosts = vec![conn("c1", "not a region"), conn("evil", "us-east-1")];
|
||||
incoming.auto_check_updates = false;
|
||||
|
||||
restore_host_fields(&mut incoming, &stored);
|
||||
|
||||
assert_eq!(incoming.hosts, stored.hosts);
|
||||
assert!(!incoming.auto_check_updates);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
@@ -138,6 +139,7 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
|
||||
&settings.marketplace_accounts,
|
||||
secure::get_marketplace_token,
|
||||
),
|
||||
aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys),
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
@@ -184,6 +186,107 @@ fn restore_marketplace_tokens(
|
||||
warnings
|
||||
}
|
||||
|
||||
/// The stored keys of every `Keys` host connection, by connection id, as the
|
||||
/// `AwsKeys` JSON. A `Profile` connection holds none. A missing or unreadable
|
||||
/// entry is left out, like the other keychain secrets above.
|
||||
fn exported_aws_connection_keys(
|
||||
hosts: &[HostConnection],
|
||||
get: impl Fn(&str) -> Result<Option<secure::AwsKeys>, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
hosts
|
||||
.iter()
|
||||
.filter(|h| matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. }))
|
||||
.filter_map(|h| {
|
||||
let keys = get(&h.id).unwrap_or_default()?;
|
||||
Some((h.id.clone(), serde_json::to_string(&keys).ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write each imported set of AWS keys to the keychain, returning a warning
|
||||
/// (naming the connection id, never the value) for each that could not be.
|
||||
fn restore_aws_connection_keys(
|
||||
keys: &BTreeMap<String, String>,
|
||||
mut store: impl FnMut(&str, &secure::AwsKeys) -> Result<(), String>,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
for (id, json) in keys {
|
||||
if json.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(parsed) = serde_json::from_str::<secure::AwsKeys>(json) else {
|
||||
log::warn!("Settings import: the AWS keys of connection {} are malformed", id);
|
||||
warnings.push(format!(
|
||||
"The AWS access keys of connection {} in the file are malformed; enter them again.",
|
||||
id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = store(id, &parsed) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the AWS keys of connection {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
warnings.push(format!(
|
||||
"Could not restore the AWS access keys of connection {} ({}); enter them again.",
|
||||
id, e
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Checks the imported host connections with the rules the connection
|
||||
/// commands use, before anything is written; refuses the import (like
|
||||
/// `validate_imported_marketplace_state`) rather than applying a bad entry.
|
||||
/// A `Keys` connection's `keyring_ref` must be its own (valid) id, and keys
|
||||
/// may only be carried for a `Keys` connection in the file.
|
||||
fn validate_imported_hosts(
|
||||
settings: &mut AppSettings,
|
||||
keys: &BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::models::host_connection::{
|
||||
validate_connection_name, validate_profile_name, validate_supported_region,
|
||||
};
|
||||
let wrap = |e: String| format!("The file's host connections were refused: {e}");
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for h in &mut settings.hosts {
|
||||
if !secure::is_valid_connection_id(&h.id) {
|
||||
return Err(wrap("a connection has an invalid id.".into()));
|
||||
}
|
||||
if !seen.insert(h.id.clone()) {
|
||||
return Err(wrap("a connection appears twice.".into()));
|
||||
}
|
||||
h.name = validate_connection_name(&h.name).map_err(wrap)?;
|
||||
let HostKind::Aws { auth, default_region } = &h.kind;
|
||||
validate_supported_region(default_region).map_err(wrap)?;
|
||||
match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
validate_profile_name(name).map_err(wrap)?;
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } => {
|
||||
if !secure::is_valid_connection_id(keyring_ref) || *keyring_ref != h.id {
|
||||
return Err(wrap(format!(
|
||||
"the connection \"{}\" has an invalid key reference.",
|
||||
h.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in keys.keys() {
|
||||
let is_keys_connection = settings.hosts.iter().any(|h| {
|
||||
h.id == *id && matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. })
|
||||
});
|
||||
if !is_keys_connection {
|
||||
return Err(wrap("access keys are present for an unknown connection.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
@@ -260,7 +363,8 @@ pub async fn preview_settings_import(
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let local_hosts = state.settings_store.get().hosts;
|
||||
let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
@@ -376,6 +480,9 @@ pub async fn apply_settings_import(
|
||||
&marketplace_tokens,
|
||||
)?;
|
||||
|
||||
let aws_keys = payload.secrets.aws_connection_keys;
|
||||
validate_imported_hosts(&mut settings, &aws_keys)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
@@ -423,24 +530,20 @@ pub async fn apply_settings_import(
|
||||
secure::store_marketplace_token,
|
||||
));
|
||||
|
||||
let imported_marketplace = (
|
||||
settings.marketplace_accounts.clone(),
|
||||
settings.marketplaces.clone(),
|
||||
settings.global_marketplace_installs.clone(),
|
||||
);
|
||||
secret_restore_warnings.extend(restore_aws_connection_keys(
|
||||
&aws_keys,
|
||||
secure::store_aws_keys,
|
||||
));
|
||||
|
||||
let imported_owned = StoreOwned::of(&settings);
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
// `update_settings` keeps marketplace state store-owned. An import is the
|
||||
// one caller entitled to replace it wholesale.
|
||||
let saved = {
|
||||
let mut s = saved;
|
||||
(
|
||||
s.marketplace_accounts,
|
||||
s.marketplaces,
|
||||
s.global_marketplace_installs,
|
||||
) = imported_marketplace;
|
||||
state.settings_store.update(s)?
|
||||
};
|
||||
// `update_settings` keeps marketplace state and host connections
|
||||
// store-owned. An import is the one caller entitled to replace them
|
||||
// wholesale.
|
||||
let saved = state
|
||||
.settings_store
|
||||
.update(imported_owned.applied_to(saved))?;
|
||||
// Caches of marketplaces the import dropped are dead weight now, and
|
||||
// the pins must match the imported installs.
|
||||
use crate::commands::marketplace_commands as mc;
|
||||
@@ -481,6 +584,34 @@ pub async fn apply_settings_import(
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings `update_settings` keeps store-owned (marketplace state and
|
||||
/// host connections), which an import re-applies after that call.
|
||||
struct StoreOwned {
|
||||
marketplace_accounts: Vec<crate::models::marketplace::MarketplaceAccount>,
|
||||
marketplaces: Vec<crate::models::marketplace::Marketplace>,
|
||||
global_marketplace_installs: Vec<crate::models::marketplace::MarketplaceInstall>,
|
||||
hosts: Vec<crate::models::HostConnection>,
|
||||
}
|
||||
|
||||
impl StoreOwned {
|
||||
fn of(s: &AppSettings) -> Self {
|
||||
Self {
|
||||
marketplace_accounts: s.marketplace_accounts.clone(),
|
||||
marketplaces: s.marketplaces.clone(),
|
||||
global_marketplace_installs: s.global_marketplace_installs.clone(),
|
||||
hosts: s.hosts.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_to(self, mut s: AppSettings) -> AppSettings {
|
||||
s.marketplace_accounts = self.marketplace_accounts;
|
||||
s.marketplaces = self.marketplaces;
|
||||
s.global_marketplace_installs = self.global_marketplace_installs;
|
||||
s.hosts = self.hosts;
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
@@ -827,4 +958,141 @@ mod tests {
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(!warnings[0].contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
fn keys_host(id: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: id.into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_host(id: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: "Dev".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_keys() -> secure::AwsKeys {
|
||||
secure::AwsKeys {
|
||||
access_key_id: "AKIATESTNOTREAL".into(),
|
||||
secret_access_key: "test-secret-not-real".into(),
|
||||
session_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_connection_keys_round_trip_through_export() {
|
||||
let hosts = vec![keys_host("k1"), profile_host("p1")];
|
||||
let exported = exported_aws_connection_keys(&hosts, |id| match id {
|
||||
"k1" => Ok(Some(test_keys())),
|
||||
other => panic!("only a Keys connection is read, got {other}"),
|
||||
});
|
||||
assert_eq!(exported.keys().collect::<Vec<_>>(), vec!["k1"]);
|
||||
assert_eq!(serde_json::from_str::<secure::AwsKeys>(&exported["k1"]).unwrap(), test_keys());
|
||||
|
||||
let mut stored = Vec::new();
|
||||
let warnings = restore_aws_connection_keys(&exported, |id, keys| {
|
||||
stored.push((id.to_string(), keys.clone()));
|
||||
Ok(())
|
||||
});
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(stored, vec![("k1".to_string(), test_keys())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_connection_keys_restore_failures_name_the_id_not_the_value() {
|
||||
let keys = BTreeMap::from([
|
||||
("bad".to_string(), "not json test-secret-not-real".to_string()),
|
||||
("locked".to_string(), serde_json::to_string(&test_keys()).unwrap()),
|
||||
]);
|
||||
let warnings = restore_aws_connection_keys(&keys, |_, _| Err("keychain locked".into()));
|
||||
assert_eq!(warnings.len(), 2);
|
||||
assert!(warnings[0].contains("bad"));
|
||||
assert!(warnings[1].contains("locked"));
|
||||
assert!(warnings.iter().all(|w| !w.contains("test-secret-not-real")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_hosts_are_validated() {
|
||||
let check = |hosts: Vec<HostConnection>, keys: BTreeMap<String, String>| {
|
||||
let mut s = AppSettings::default();
|
||||
s.hosts = hosts;
|
||||
validate_imported_hosts(&mut s, &keys)
|
||||
};
|
||||
let none = BTreeMap::new();
|
||||
assert!(check(vec![keys_host("k1"), profile_host("p1")], none.clone()).is_ok());
|
||||
assert!(check(vec![keys_host("k1")], BTreeMap::from([("k1".into(), "{}".into())])).is_ok());
|
||||
|
||||
let mut bad_name = profile_host("p1");
|
||||
bad_name.name = " ".into();
|
||||
assert!(check(vec![bad_name], none.clone()).is_err());
|
||||
|
||||
let mut bad_region = profile_host("p1");
|
||||
bad_region.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "nowhere".into(),
|
||||
};
|
||||
assert!(check(vec![bad_region], none.clone()).is_err());
|
||||
|
||||
let mut cn = profile_host("p1");
|
||||
cn.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "cn-north-1".into(),
|
||||
};
|
||||
let err = check(vec![cn], none.clone()).unwrap_err();
|
||||
assert!(err.contains("China and GovCloud regions aren't supported yet."), "{err}");
|
||||
|
||||
let mut bad_profile = profile_host("p1");
|
||||
bad_profile.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "a b".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
assert!(check(vec![bad_profile], none.clone()).is_err());
|
||||
|
||||
let mut other_ref = keys_host("k1");
|
||||
other_ref.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
assert!(check(vec![other_ref], none.clone()).is_err());
|
||||
|
||||
assert!(check(vec![profile_host("../x")], none.clone()).is_err());
|
||||
assert!(check(vec![profile_host("p1"), profile_host("p1")], none.clone()).is_err());
|
||||
// Keys carried for a connection that is not a Keys connection.
|
||||
assert!(check(vec![profile_host("p1")], BTreeMap::from([("p1".into(), "{}".into())])).is_err());
|
||||
assert!(check(vec![], BTreeMap::from([("ghost".into(), "{}".into())])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_hosts_survive_apply() {
|
||||
use crate::models::{AwsAuth, HostConnection, HostKind};
|
||||
let host = HostConnection {
|
||||
id: "h1".into(),
|
||||
name: "Imported".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
};
|
||||
let stored = AppSettings::default();
|
||||
let mut imported = AppSettings::default();
|
||||
imported.hosts.push(host.clone());
|
||||
|
||||
let owned = StoreOwned::of(&imported);
|
||||
// What `update_settings` does to the incoming settings.
|
||||
let mut after_update = imported.clone();
|
||||
crate::commands::settings_commands::restore_host_fields(&mut after_update, &stored);
|
||||
assert!(after_update.hosts.is_empty(), "update_settings drops hosts");
|
||||
|
||||
assert_eq!(owned.applied_to(after_update).hosts, vec![host]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod auth_bridge;
|
||||
mod aws;
|
||||
mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
@@ -593,6 +594,15 @@ pub fn run() {
|
||||
commands::settings_commands::detect_aws_config,
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
// AWS host connections
|
||||
commands::host_commands::add_aws_profile_connection,
|
||||
commands::host_commands::add_aws_keys_connection,
|
||||
commands::host_commands::update_aws_connection,
|
||||
commands::host_commands::remove_host_connection,
|
||||
commands::host_commands::aws_connection_identity,
|
||||
commands::host_commands::aws_connection_sso_login,
|
||||
commands::host_commands::check_aws_permissions,
|
||||
commands::host_commands::aws_policy_document,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::gateway_settings::GatewaySettings;
|
||||
use super::host_connection::HostConnection;
|
||||
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
|
||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||
|
||||
@@ -143,6 +144,9 @@ pub struct AppSettings {
|
||||
/// Marketplace git repos the user added.
|
||||
#[serde(default)]
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
/// Remote host connections (AWS). Secrets live in the OS keychain, never here.
|
||||
#[serde(default)]
|
||||
pub hosts: Vec<HostConnection>,
|
||||
/// Items installed for every project (projects may opt out per item).
|
||||
#[serde(default)]
|
||||
pub global_marketplace_installs: Vec<MarketplaceInstall>,
|
||||
@@ -259,6 +263,7 @@ impl Default for AppSettings {
|
||||
global_claude_code_settings: None,
|
||||
marketplace_accounts: Vec::new(),
|
||||
marketplaces: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
global_marketplace_installs: Vec::new(),
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Remote host connections (currently AWS). Only non-secret data lives here;
|
||||
//! access keys are held in the OS keychain and referenced by `keyring_ref`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: HostKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HostKind {
|
||||
Aws { auth: AwsAuth, default_region: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum AwsAuth {
|
||||
/// A named profile from the host's AWS config.
|
||||
Profile { name: String },
|
||||
/// Static keys stored in the OS keychain under this reference.
|
||||
Keys { keyring_ref: String },
|
||||
}
|
||||
|
||||
/// Unicode format characters (category Cf): invisible, but able to reorder or
|
||||
/// hide text (bidi overrides, zero-width spaces). `char::is_control` is Cc only.
|
||||
fn is_format_char(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x180E
|
||||
| 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F
|
||||
| 0xFEFF | 0xFFF9..=0xFFFB | 0xE0001 | 0xE0020..=0xE007F)
|
||||
}
|
||||
|
||||
/// Trimmed, 1..=64 characters, no control or invisible format characters.
|
||||
pub fn validate_connection_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
let len = name.chars().count();
|
||||
if len == 0 || len > 64 {
|
||||
return Err("Connection name must be 1 to 64 characters".into());
|
||||
}
|
||||
if name.chars().any(|c| c.is_control() || is_format_char(c)) {
|
||||
return Err("Connection name must not contain control or invisible formatting characters".into());
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// An AWS region id such as `us-west-2` or `us-gov-west-1`.
|
||||
pub fn validate_region(region: &str) -> Result<String, String> {
|
||||
let b = region.as_bytes();
|
||||
let ok = (|| {
|
||||
// ^[a-z]{2}(-[a-z]+)+-\d+$
|
||||
if b.len() < 2 || !b[..2].iter().all(u8::is_ascii_lowercase) {
|
||||
return false;
|
||||
}
|
||||
let mut parts = region[2..].split('-');
|
||||
if parts.next() != Some("") {
|
||||
return false;
|
||||
}
|
||||
let rest: Vec<&str> = parts.collect();
|
||||
let Some((last, words)) = rest.split_last() else {
|
||||
return false;
|
||||
};
|
||||
!words.is_empty()
|
||||
&& words
|
||||
.iter()
|
||||
.all(|w| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase()))
|
||||
&& !last.is_empty()
|
||||
&& last.bytes().all(|c| c.is_ascii_digit())
|
||||
})();
|
||||
if ok {
|
||||
Ok(region.to_string())
|
||||
} else {
|
||||
Err(format!("\"{}\" is not a valid AWS region", region.escape_debug()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A region Triple-C's policies and checks can work with. Every policy and check
|
||||
/// template hard-codes the `aws` partition (`arn:aws:`), so China, GovCloud and
|
||||
/// the isolated partitions would always check as Insufficient. `validate_region`
|
||||
/// stays general (it only checks the shape); connection add/update/import use this.
|
||||
pub fn validate_supported_region(region: &str) -> Result<String, String> {
|
||||
let region = validate_region(region)?;
|
||||
if ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"]
|
||||
.iter()
|
||||
.any(|p| region.starts_with(p))
|
||||
{
|
||||
return Err("China and GovCloud regions aren't supported yet.".into());
|
||||
}
|
||||
Ok(region)
|
||||
}
|
||||
|
||||
/// Non-empty, at most 128 characters of `[A-Za-z0-9_.@+=,-]`.
|
||||
pub fn validate_profile_name(name: &str) -> Result<String, String> {
|
||||
if name.is_empty() || name.len() > 128 {
|
||||
return Err("Profile name must be 1 to 128 characters".into());
|
||||
}
|
||||
if !name
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"_.@+=,-".contains(&c))
|
||||
{
|
||||
return Err("Profile name contains characters AWS profiles do not allow".into());
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::AppSettings;
|
||||
|
||||
#[test]
|
||||
fn settings_without_hosts_deserialize_to_empty() {
|
||||
let mut json = serde_json::to_value(AppSettings::default()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("hosts");
|
||||
let s: AppSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.hosts, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_connection_round_trips() {
|
||||
let c = HostConnection {
|
||||
id: "h1".into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
};
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
assert!(s.contains("\"type\":\"aws\""), "{s}");
|
||||
assert!(s.contains("\"method\":\"profile\""), "{s}");
|
||||
assert_eq!(serde_json::from_str::<HostConnection>(&s).unwrap(), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regions_are_validated() {
|
||||
for ok in ["us-west-2", "eu-central-1", "cn-north-1", "us-gov-west-1"] {
|
||||
assert_eq!(validate_region(ok).unwrap(), ok);
|
||||
}
|
||||
for ok in ["us-west-2", "eu-central-1", "ap-southeast-4"] {
|
||||
assert_eq!(validate_supported_region(ok).unwrap(), ok);
|
||||
}
|
||||
for other in ["cn-north-1", "cn-northwest-1", "us-gov-west-1", "us-gov-east-1"] {
|
||||
assert_eq!(
|
||||
validate_supported_region(other).unwrap_err(),
|
||||
"China and GovCloud regions aren't supported yet."
|
||||
);
|
||||
}
|
||||
assert!(validate_supported_region("nope").unwrap_err().contains("not a valid"));
|
||||
for bad in ["", "US-WEST-2", "us-west", "us-west-2; rm"] {
|
||||
assert!(validate_region(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_names_are_validated() {
|
||||
for ok in ["default", "340315884407_StaffAdministratorAccess"] {
|
||||
assert_eq!(validate_profile_name(ok).unwrap(), ok);
|
||||
}
|
||||
for bad in ["", "a b", "x\n"] {
|
||||
assert!(validate_profile_name(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(validate_profile_name(&"a".repeat(129)).is_err());
|
||||
assert!(validate_profile_name(&"a".repeat(128)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_names_are_validated() {
|
||||
assert_eq!(validate_connection_name(" Prod ").unwrap(), "Prod");
|
||||
assert!(validate_connection_name(" ").is_err());
|
||||
assert!(validate_connection_name("a\tb").is_err());
|
||||
for c in ['\u{202E}', '\u{200B}', '\u{2066}', '\u{2069}', '\u{FEFF}'] {
|
||||
assert!(validate_connection_name(&format!("ab{c}cd")).is_err(), "{c:?}");
|
||||
}
|
||||
assert!(validate_connection_name("Prod é 日本").is_ok());
|
||||
assert!(validate_connection_name(&"a".repeat(65)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod host_connection;
|
||||
pub mod marketplace;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
@@ -11,6 +12,7 @@ pub mod update_info;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use host_connection::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
|
||||
@@ -64,6 +64,11 @@ pub struct ExportedSecrets {
|
||||
/// here or an imported account could never fetch.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_tokens: BTreeMap<String, String>,
|
||||
/// Access keys of `AwsAuth::Keys` host connections, keyed by connection
|
||||
/// id; each value is the `AwsKeys` JSON. Like the tokens above they live
|
||||
/// in the keychain, not in `AppSettings::hosts`.
|
||||
#[serde(default)]
|
||||
pub aws_connection_keys: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExportedSecrets {
|
||||
@@ -74,6 +79,7 @@ impl ExportedSecrets {
|
||||
&& blank(&self.gateway_master_key)
|
||||
&& blank(&self.web_terminal_access_token)
|
||||
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
|
||||
&& self.aws_connection_keys.values().all(|v| v.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,69 @@ pub struct SettingsImportPreview {
|
||||
/// Non-blank marketplace account tokens the import restores.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_token_count: usize,
|
||||
/// The AWS connections the import brings. Applied wholesale, so a crafted
|
||||
/// export could otherwise add or replace one unannounced. Names and
|
||||
/// profile names are cleaned; no key values, only the count below.
|
||||
#[serde(default)]
|
||||
pub aws_connections: Vec<AwsConnectionPreview>,
|
||||
/// Non-blank AWS access-key entries the import restores to the keychain.
|
||||
#[serde(default)]
|
||||
pub aws_connection_key_count: usize,
|
||||
/// Local connections the import drops, because it replaces the whole list.
|
||||
/// Their keychain entries, if any, are left behind.
|
||||
#[serde(default)]
|
||||
pub removed_aws_connections: Vec<AwsConnectionSummary>,
|
||||
}
|
||||
|
||||
/// Cleaned, non-secret description of one connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AwsConnectionSummary {
|
||||
pub name: String,
|
||||
/// `"profile"` or `"keys"`.
|
||||
pub auth: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub default_region: String,
|
||||
}
|
||||
|
||||
impl AwsConnectionSummary {
|
||||
fn of(h: &crate::models::host_connection::HostConnection) -> Self {
|
||||
use crate::models::host_connection::{AwsAuth, HostKind};
|
||||
let HostKind::Aws { auth, default_region } = &h.kind;
|
||||
let (auth_kind, profile_name) = match auth {
|
||||
AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))),
|
||||
AwsAuth::Keys { .. } => ("keys", None),
|
||||
};
|
||||
Self {
|
||||
name: sanitize_for_preview(&h.name),
|
||||
auth: auth_kind.to_string(),
|
||||
profile_name,
|
||||
default_region: sanitize_for_preview(default_region),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One imported AWS connection as the confirmation dialog shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AwsConnectionPreview {
|
||||
pub name: String,
|
||||
/// `"profile"` or `"keys"`.
|
||||
pub auth: String,
|
||||
/// The profile name, for a `profile` connection.
|
||||
pub profile_name: Option<String>,
|
||||
pub default_region: String,
|
||||
/// A connection with this id exists here with different content.
|
||||
pub overwrites_existing: bool,
|
||||
/// The import carries keys for this id and a keychain entry for it
|
||||
/// already exists here (its value is never read into the preview).
|
||||
pub overwrites_keys: bool,
|
||||
/// The import carries keys for this id and the keychain could not be
|
||||
/// asked whether some are stored already.
|
||||
#[serde(default)]
|
||||
pub keys_check_failed: bool,
|
||||
/// The local connection with this id, when the import replaces it, so an
|
||||
/// imported name cannot hide which connection is being replaced.
|
||||
#[serde(default)]
|
||||
pub local: Option<AwsConnectionSummary>,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
@@ -195,7 +264,55 @@ fn sanitize_for_preview(value: &str) -> String {
|
||||
}
|
||||
|
||||
impl SettingsImportPreview {
|
||||
#[cfg(test)]
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
Self::from_payload_against(payload, &[], |_| Some(false))
|
||||
}
|
||||
|
||||
/// Builds the preview, also flagging what the import would overwrite or
|
||||
/// drop: `local_hosts` are the connections held now and `local_has_keys`
|
||||
/// says whether a keychain entry exists for an id (`None`: could not be
|
||||
/// determined).
|
||||
pub fn from_payload_against(
|
||||
payload: &SettingsExportPayload,
|
||||
local_hosts: &[crate::models::host_connection::HostConnection],
|
||||
local_has_keys: impl Fn(&str) -> Option<bool>,
|
||||
) -> Self {
|
||||
let carried_keys = |id: &str| {
|
||||
payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.get(id)
|
||||
.is_some_and(|v| !v.trim().is_empty())
|
||||
};
|
||||
let aws_connections = payload
|
||||
.settings
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let summary = AwsConnectionSummary::of(h);
|
||||
let local = local_hosts.iter().find(|l| l.id == h.id);
|
||||
let overwrites_existing = local.is_some_and(|l| l != h);
|
||||
let key_state = if carried_keys(&h.id) { local_has_keys(&h.id) } else { Some(false) };
|
||||
AwsConnectionPreview {
|
||||
name: summary.name,
|
||||
auth: summary.auth,
|
||||
profile_name: summary.profile_name,
|
||||
default_region: summary.default_region,
|
||||
overwrites_existing,
|
||||
overwrites_keys: key_state == Some(true),
|
||||
keys_check_failed: key_state.is_none(),
|
||||
local: local
|
||||
.filter(|_| overwrites_existing || key_state != Some(false))
|
||||
.map(AwsConnectionSummary::of),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let removed_aws_connections = local_hosts
|
||||
.iter()
|
||||
.filter(|l| !payload.settings.hosts.iter().any(|h| h.id == l.id))
|
||||
.map(AwsConnectionSummary::of)
|
||||
.collect();
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
@@ -240,6 +357,14 @@ impl SettingsImportPreview {
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
aws_connections,
|
||||
aws_connection_key_count: payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
removed_aws_connections,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,4 +602,98 @@ mod tests {
|
||||
assert_eq!(preview.global_plugin_install_count, 2);
|
||||
assert_eq!(preview.global_hook_install_count, 1);
|
||||
}
|
||||
|
||||
fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection {
|
||||
use crate::models::host_connection::*;
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: if keys {
|
||||
AwsAuth::Keys { keyring_ref: id.into() }
|
||||
} else {
|
||||
AwsAuth::Profile { name: "prod-profile".into() }
|
||||
},
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_discloses_aws_connections_and_key_count() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([
|
||||
("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()),
|
||||
("k2".to_string(), " ".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)];
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.aws_connection_key_count, 1);
|
||||
assert_eq!(preview.aws_connections.len(), 2);
|
||||
assert_eq!(preview.aws_connections[0].name, "Keys");
|
||||
assert_eq!(preview.aws_connections[0].auth, "keys");
|
||||
assert_eq!(preview.aws_connections[1].auth, "profile");
|
||||
assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile"));
|
||||
assert_eq!(preview.aws_connections[1].default_region, "us-west-2");
|
||||
assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_flags_aws_overwrites() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![
|
||||
aws_host("k1", "Keys", true),
|
||||
aws_host("same", "Same", false),
|
||||
aws_host("changed", "New name", false),
|
||||
aws_host("fresh", "Fresh", false),
|
||||
];
|
||||
let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)];
|
||||
let preview =
|
||||
SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1"));
|
||||
let flags: Vec<_> = preview
|
||||
.aws_connections
|
||||
.iter()
|
||||
.map(|c| (c.overwrites_existing, c.overwrites_keys))
|
||||
.collect();
|
||||
assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_preview_names_the_local_connection_and_discloses_removals_and_failed_checks() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([
|
||||
("k1".to_string(), "{}".to_string()),
|
||||
("k2".to_string(), "{}".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![
|
||||
aws_host("k1", "Innocent", true),
|
||||
aws_host("k2", "Other", true),
|
||||
aws_host("p1", "Fresh Name", false),
|
||||
];
|
||||
let local = vec![
|
||||
aws_host("k1", "Real Prod", true),
|
||||
aws_host("p1", "Real Dev", false),
|
||||
aws_host("gone", "Dropped\n", false),
|
||||
];
|
||||
let preview = SettingsImportPreview::from_payload_against(&payload, &local, |id| match id {
|
||||
"k1" => Some(true),
|
||||
_ => None,
|
||||
});
|
||||
let c = &preview.aws_connections;
|
||||
assert!(c[0].overwrites_keys && !c[0].keys_check_failed);
|
||||
assert_eq!(c[0].local.as_ref().unwrap().name, "Real Prod");
|
||||
assert!(c[1].keys_check_failed && !c[1].overwrites_keys && c[1].local.is_none());
|
||||
assert!(c[2].overwrites_existing);
|
||||
assert_eq!(c[2].local.as_ref().unwrap().name, "Real Dev");
|
||||
assert_eq!(c[2].local.as_ref().unwrap().profile_name.as_deref(), Some("prod-profile"));
|
||||
assert_eq!(preview.removed_aws_connections.len(), 1);
|
||||
assert_eq!(preview.removed_aws_connections[0].name, "Dropped");
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,12 @@ fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
|
||||
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.delete_credential() {
|
||||
delete_outcome(entry.delete_credential(), label)
|
||||
}
|
||||
|
||||
/// "Wasn't there" is success; any other failure names the entry, not its value.
|
||||
fn delete_outcome(result: Result<(), keyring::Error>, label: &str) -> Result<(), String> {
|
||||
match result {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
|
||||
}
|
||||
@@ -379,10 +384,7 @@ const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
|
||||
/// The service name for one account. Ids are uuids; anything else is refused
|
||||
/// before a keychain entry is constructed.
|
||||
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
|
||||
let ok = !account_id.is_empty()
|
||||
&& account_id.len() <= 64
|
||||
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if !ok {
|
||||
if !is_valid_connection_id(account_id) {
|
||||
return Err(format!("Invalid marketplace account id {:?}", account_id));
|
||||
}
|
||||
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
|
||||
@@ -408,6 +410,90 @@ pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
|
||||
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AWS connection access keys (global, one entry per connection)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Keychain service prefix; the connection id completes it.
|
||||
const AWS_CONNECTION_SERVICE_PREFIX: &str = "triple-c-aws-connection-";
|
||||
|
||||
/// Static AWS credentials for a `HostConnection` using `AwsAuth::Keys`. Held in
|
||||
/// the keychain as one JSON value. `Debug` redacts the secret and the token.
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct AwsKeys {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub session_token: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AwsKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AwsKeys")
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &"<redacted>")
|
||||
.field("session_token", &self.session_token.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` may be used as a connection id / keyring reference: 1..=64 of
|
||||
/// `[A-Za-z0-9-]`. The single definition of the keychain-id rule: marketplace
|
||||
/// account ids, AWS connection ids and `commands::marketplace_commands`'s
|
||||
/// `validate_id` all go through it.
|
||||
pub fn is_valid_connection_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
}
|
||||
|
||||
fn aws_connection_service(connection_id: &str) -> Result<String, String> {
|
||||
if !is_valid_connection_id(connection_id) {
|
||||
return Err(format!("Invalid AWS connection id {:?}", connection_id));
|
||||
}
|
||||
Ok(format!("{}{}", AWS_CONNECTION_SERVICE_PREFIX, connection_id))
|
||||
}
|
||||
|
||||
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> {
|
||||
let service = aws_connection_service(connection_id)?;
|
||||
let json = serde_json::to_string(keys)
|
||||
.map_err(|_| "Failed to encode the AWS access keys".to_string())?;
|
||||
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|e| format!("Failed to store the AWS access keys: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String> {
|
||||
let Some(json) = read_entry(&aws_connection_service(connection_id)?, "the AWS access keys")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_str(&json)
|
||||
.map(Some)
|
||||
.map_err(|_| "The stored AWS access keys are unreadable".to_string())
|
||||
}
|
||||
|
||||
/// Whether an entry exists for the connection, readable or not. `None` means
|
||||
/// the keychain could not be asked, so callers must not read it as "absent".
|
||||
/// Never returns or logs a value.
|
||||
pub fn aws_keys_entry_state(connection_id: &str) -> Option<bool> {
|
||||
let svc = aws_connection_service(connection_id).ok()?;
|
||||
let entry = keyring::Entry::new(&svc, KEYCHAIN_ACCOUNT).ok()?;
|
||||
match entry.get_password() {
|
||||
Ok(_) => Some(true),
|
||||
Err(keyring::Error::NoEntry) => Some(false),
|
||||
// Present but not a plain string, etc.: something is there.
|
||||
Err(keyring::Error::BadEncoding(_)) => Some(true),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing entry is success.
|
||||
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> {
|
||||
delete_entry(&aws_connection_service(connection_id)?, "the AWS access keys")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -496,4 +582,39 @@ mod tests {
|
||||
// Pasted credentials routinely carry a trailing newline.
|
||||
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_keys_debug_redacts_secrets() {
|
||||
let keys = AwsKeys {
|
||||
access_key_id: "AKIATESTNOTREAL".into(),
|
||||
secret_access_key: "test-secret-not-real".into(),
|
||||
session_token: Some("test-session-token-not-real".into()),
|
||||
};
|
||||
let shown = format!("{:?}", keys);
|
||||
assert!(!shown.contains("test-secret-not-real"), "{shown}");
|
||||
assert!(!shown.contains("test-session-token-not-real"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_key_service_names_reject_bad_ids() {
|
||||
for bad in ["../x", "", &"a".repeat(65)] {
|
||||
assert!(aws_connection_service(bad).is_err(), "{bad:?}");
|
||||
assert!(get_aws_keys(bad).is_err());
|
||||
assert!(delete_aws_keys(bad).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
aws_connection_service("0b9e6a2c-1111").unwrap(),
|
||||
"triple-c-aws-connection-0b9e6a2c-1111"
|
||||
);
|
||||
}
|
||||
|
||||
/// There is no mock keyring here, so the "no entry is fine" rule is tested
|
||||
/// as the pure mapping `delete_entry` applies.
|
||||
#[test]
|
||||
fn removing_a_keys_connection_with_no_keyring_entry_succeeds() {
|
||||
assert_eq!(delete_outcome(Err(keyring::Error::NoEntry), "x"), Ok(()));
|
||||
assert_eq!(delete_outcome(Ok(()), "x"), Ok(()));
|
||||
let err = delete_outcome(Err(keyring::Error::NoStorageAccess("denied".into())), "x");
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ impl SettingsStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// A store over an explicit file, for tests (no data-dir lookup).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn at_path(file_path: PathBuf) -> Self {
|
||||
Self { settings: Mutex::new(AppSettings::default()), file_path }
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
@@ -69,8 +75,77 @@ impl SettingsStore {
|
||||
|
||||
pub fn update(&self, new_settings: AppSettings) -> Result<AppSettings, String> {
|
||||
let mut settings = self.lock();
|
||||
// Save first: a failed save must not leave the unsaved copy in memory.
|
||||
self.save(&new_settings)?;
|
||||
*settings = new_settings.clone();
|
||||
self.save(&settings)?;
|
||||
Ok(new_settings)
|
||||
}
|
||||
|
||||
/// Read-modify-save under the store's lock, so concurrent callers cannot
|
||||
/// lose each other's updates. `f` edits a copy; if it fails, or the save
|
||||
/// fails, memory and disk are unchanged. Returns the saved settings and
|
||||
/// whatever `f` returned.
|
||||
pub fn modify<R, F>(&self, f: F) -> Result<(AppSettings, R), String>
|
||||
where
|
||||
F: FnOnce(&mut AppSettings) -> Result<R, String>,
|
||||
{
|
||||
let mut settings = self.lock();
|
||||
let mut copy = settings.clone();
|
||||
let out = f(&mut copy)?;
|
||||
self.save(©)?;
|
||||
*settings = copy.clone();
|
||||
Ok((copy, out))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modify_saves_and_returns_the_closure_result() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("settings.json"));
|
||||
let (saved, n) = store
|
||||
.modify(|s| {
|
||||
s.hosts.clear();
|
||||
Ok(7)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&saved).unwrap(),
|
||||
serde_json::to_value(store.get()).unwrap()
|
||||
);
|
||||
assert!(dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_does_not_change_memory_when_save_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("missing/settings.json"));
|
||||
let before = serde_json::to_value(store.get()).unwrap();
|
||||
let err = store.modify(|s| {
|
||||
s.hosts.push(crate::models::HostConnection {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
kind: crate::models::HostKind::Aws {
|
||||
auth: crate::models::AwsAuth::Profile { name: "p".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
assert!(err.is_err());
|
||||
assert_eq!(serde_json::to_value(store.get()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_does_not_save_when_the_closure_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("settings.json"));
|
||||
let err = store.modify(|_| -> Result<(), String> { Err("no".into()) }).unwrap_err();
|
||||
assert_eq!(err, "no");
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import HostsSettings from "./HostsSettings";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
const conn: HostConnection = {
|
||||
id: "h1",
|
||||
name: "Prod",
|
||||
kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" },
|
||||
};
|
||||
const keysConn: HostConnection = {
|
||||
id: "h2",
|
||||
name: "Keys box",
|
||||
kind: { type: "aws", auth: { method: "keys", keyring_ref: "r" }, default_region: "us-east-1" },
|
||||
};
|
||||
const identity = { account: "123456789012", arn: "arn:aws:iam::123456789012:user/me", user_id: "U" };
|
||||
|
||||
function report(over: Partial<PermissionReport>): PermissionReport {
|
||||
return {
|
||||
identity,
|
||||
region: "eu-west-1",
|
||||
method: "simulated",
|
||||
summary: "insufficient",
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "denied", note: null }] },
|
||||
{ tier: "environment_admin", results: [{ action: "iam:CreateRole", verdict: "denied", note: null }] },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setHosts(hosts: HostConnection[]) {
|
||||
useAppState.setState({ appSettings: { hosts } as unknown as AppSettings });
|
||||
}
|
||||
|
||||
describe("HostsSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setHosts([conn, keysConn]);
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
|
||||
if (cmd === "get_settings") return { hosts: [conn, keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Local Docker and existing AWS connections", async () => {
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
expect(screen.getByText("Local Docker")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("Profile: prod-sso")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("host-h2")).getByText("Access keys")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("host-h1")).getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Check/ })).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "Remove Local Docker" })).toBeNull();
|
||||
});
|
||||
|
||||
it("adds a profile connection and shows the signed-in identity", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
if (cmd === "get_settings") return { hosts: [conn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Default region"), { target: { value: "eu-west-1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("add_aws_profile_connection", {
|
||||
name: "Dev",
|
||||
profile: "dev",
|
||||
defaultRegion: "eu-west-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers Sign in when the profile's SSO session expired", async () => {
|
||||
let identityCalls = 0;
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "aws_connection_identity") {
|
||||
if (identityCalls++ === 0) throw { kind: "sso_expired", profile: "dev" };
|
||||
return identity;
|
||||
}
|
||||
if (cmd === "aws_connection_sso_login") return undefined;
|
||||
if (cmd === "get_settings") return { hosts: [conn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("aws_connection_sso_login", { id: "h1" });
|
||||
// The connection was added once; only the failed step was retried.
|
||||
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("access key inputs are password fields and the session token is optional", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "add_aws_keys_connection") return keysConn;
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
if (cmd === "get_settings") return { hosts: [keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
|
||||
const id = screen.getByLabelText("Access key ID");
|
||||
const secret = screen.getByLabelText("Secret access key");
|
||||
const token = screen.getByLabelText(/Session token/);
|
||||
for (const el of [id, secret, token]) {
|
||||
expect(el).toHaveAttribute("type", "password");
|
||||
expect(el).toHaveAttribute("autocomplete", "off");
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "K" } });
|
||||
fireEvent.change(id, { target: { value: "AKIA" } });
|
||||
fireEvent.change(secret, { target: { value: "shh" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
await screen.findByText(`Signed in as ${identity.arn}`);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"add_aws_keys_connection",
|
||||
expect.objectContaining({ accessKeyId: "AKIA", secretAccessKey: "shh", sessionToken: null }),
|
||||
);
|
||||
expect(screen.getByLabelText("Access key ID")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Secret access key")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("check shows Insufficient with the denied actions and the copy-policy buttons", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document") return '{"Version":"2012-10-17"}';
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText("Insufficient")).toBeInTheDocument();
|
||||
expect(screen.getByText("ecs:RunTask")).toBeInTheDocument();
|
||||
expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy workspace-user policy" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}'));
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" });
|
||||
expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "throttled" }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
const badge = await screen.findByText("Incomplete — check again");
|
||||
expect(badge.className).not.toContain("--error");
|
||||
expect(screen.queryByText("Insufficient")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Some checks couldn't complete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains denied own-principal checks as the first thing to ask an admin for", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "iam:SimulatePrincipalPolicy", verdict: "denied", note: null }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(/couldn't inspect the caller's own permissions/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows throttling notes with a check-again hint", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "Throttling" }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(/Throttling/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Check again/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a probed check says writes are unverified", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
method: { probed: { reason: "iam:SimulatePrincipalPolicy denied" } },
|
||||
summary: "workspace_user",
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText("Workspace user (unverified)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Simulation not permitted (iam:SimulatePrincipalPolicy denied). Read access was probed; write actions are unverified and will be confirmed on first use.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for confirmation before removing", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "remove_host_connection") return { hosts: [keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
|
||||
expect(invoke).not.toHaveBeenCalledWith("remove_host_connection", expect.anything());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm remove Prod" }));
|
||||
await waitFor(() => expect(screen.queryByText("Prod")).toBeNull());
|
||||
expect(invoke).toHaveBeenCalledWith("remove_host_connection", { id: "h1" });
|
||||
});
|
||||
|
||||
it("never renders a no-op Sign in when the add itself rejects with sso_expired", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") throw { kind: "sso_expired", profile: "dev" };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull();
|
||||
expect(screen.getByText("aws sso login --profile dev")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never re-runs the add when the settings refresh fails after a successful add", async () => {
|
||||
let settingsCalls = 0;
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "get_settings") {
|
||||
if (settingsCalls++ === 0) throw "boom";
|
||||
return { hosts: [conn] };
|
||||
}
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText(/could not be refreshed/)).toBeInTheDocument();
|
||||
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a message when the profile list cannot be read", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") throw "no config";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
expect(await screen.findByText(/Couldn't read your AWS profiles: no config/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
|
||||
expect(screen.getByLabelText("Access key ID")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a message when copying the policy fails", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document") return "{}";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } });
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy admin policy" }));
|
||||
expect(await screen.findByText(/Could not copy the policy: denied/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ kind: "profile_not_found", profile: "ghost" }, "Profile ghost was not found in your AWS config, or has no credentials."],
|
||||
[
|
||||
{ kind: "keys_missing" },
|
||||
"The access keys for this connection are missing from the keychain. Remove and re-add the connection.",
|
||||
],
|
||||
])("a failed Check shows its error: %j", async (err, text) => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") throw err;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(text)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("permission-report")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves focus to Confirm when removing, and back to Remove on cancel", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
|
||||
expect(screen.getByRole("button", { name: "Confirm remove Prod" })).toHaveFocus();
|
||||
fireEvent.click(within(screen.getByTestId("host-h1")).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Remove Prod" })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import {
|
||||
addAwsKeysConnection,
|
||||
addAwsProfileConnection,
|
||||
awsConnectionIdentity,
|
||||
awsConnectionSsoLogin,
|
||||
awsPolicyDocument,
|
||||
checkAwsPermissions,
|
||||
listAwsProfiles,
|
||||
removeHostConnection,
|
||||
updateAwsConnection,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { toAwsError } from "../../lib/aws-error";
|
||||
import type {
|
||||
AwsError,
|
||||
HostConnection,
|
||||
PermissionReport,
|
||||
PermissionSummary,
|
||||
Tier,
|
||||
} from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
|
||||
const COMMON_REGIONS = [
|
||||
"us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1",
|
||||
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
|
||||
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
|
||||
|
||||
/** Human-readable copy for a failed AWS command; never raw JSON. */
|
||||
export function awsErrorMessage(e: AwsError): string {
|
||||
switch (e.kind) {
|
||||
case "sso_expired":
|
||||
return `Your SSO session for profile ${e.profile} has expired.`;
|
||||
case "profile_not_found":
|
||||
return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`;
|
||||
case "keys_missing":
|
||||
return "The access keys for this connection are missing from the keychain. Remove and re-add the connection.";
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const SUMMARY_LABEL: Record<PermissionSummary, string> = {
|
||||
environment_admin: "Environment admin",
|
||||
workspace_user: "Workspace user",
|
||||
insufficient: "Insufficient",
|
||||
};
|
||||
|
||||
const SUMMARY_STYLE: Record<PermissionSummary, string> = {
|
||||
environment_admin: "text-[var(--success)] border-[var(--success)]/40",
|
||||
workspace_user: "text-[var(--accent)] border-[var(--accent)]/40",
|
||||
insufficient: "text-[var(--error)] border-[var(--error)]/40",
|
||||
};
|
||||
|
||||
type AuthChoice = "profile" | "keys";
|
||||
|
||||
/** An error with the step that failed, so Sign in can retry just that step. */
|
||||
interface Failure {
|
||||
error: AwsError;
|
||||
retry: () => Promise<void>;
|
||||
}
|
||||
|
||||
function ErrorLine({
|
||||
failure,
|
||||
onSignIn,
|
||||
signingIn,
|
||||
}: {
|
||||
failure: Failure;
|
||||
/** Absent when there is no saved connection to sign in with. */
|
||||
onSignIn?: () => void;
|
||||
signingIn: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className="text-xs text-[var(--error)] leading-snug flex items-center gap-2 flex-wrap">
|
||||
<span>{awsErrorMessage(failure.error)}</span>
|
||||
{failure.error.kind === "sso_expired" && !onSignIn && (
|
||||
<span>
|
||||
Sign in with <code>aws sso login --profile {failure.error.profile}</code> and try again.
|
||||
</span>
|
||||
)}
|
||||
{failure.error.kind === "sso_expired" && onSignIn && (
|
||||
<Button size="sm" variant="secondary" onClick={onSignIn} disabled={signingIn}>
|
||||
{signingIn ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionReportView({ report }: { report: PermissionReport }) {
|
||||
const [copyNote, setCopyNote] = useState<string | null>(null);
|
||||
const probed = typeof report.method === "object";
|
||||
// Insufficient only because checks couldn't complete (no denied row in the
|
||||
// workspace tier, only rows with a note): don't claim the permissions are missing.
|
||||
const workspaceTier = report.tiers.find((t) => t.tier === "workspace_user");
|
||||
const incomplete =
|
||||
report.summary === "insufficient" &&
|
||||
!!workspaceTier &&
|
||||
!workspaceTier.results.some((r) => r.verdict === "denied") &&
|
||||
workspaceTier.results.some((r) => r.note);
|
||||
const badge = incomplete
|
||||
? "Incomplete — check again"
|
||||
: report.summary === "workspace_user" && probed
|
||||
? "Workspace user (unverified)"
|
||||
: SUMMARY_LABEL[report.summary];
|
||||
const badgeStyle = incomplete
|
||||
? "text-[var(--text-secondary)] border-[var(--border-color)]"
|
||||
: SUMMARY_STYLE[report.summary];
|
||||
const flagged = report.tiers
|
||||
.map((t) => ({ ...t, results: t.results.filter((r) => r.verdict !== "allowed") }))
|
||||
.filter((t) => t.results.length > 0);
|
||||
const ownPrincipalDenied =
|
||||
report.summary === "insufficient" &&
|
||||
report.tiers.some((t) =>
|
||||
t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)),
|
||||
);
|
||||
|
||||
const copy = async (tier: Tier) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(await awsPolicyDocument(tier));
|
||||
setCopyNote("Policy copied to clipboard.");
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy the policy: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2" data-testid="permission-report">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full border ${badgeStyle}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{report.identity.arn} · {report.region}
|
||||
</span>
|
||||
</div>
|
||||
{incomplete && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Some checks couldn't complete (for example because AWS throttled the requests), so the
|
||||
permissions couldn't be fully assessed. Check again in a moment.
|
||||
</p>
|
||||
)}
|
||||
{typeof report.method === "object" && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{`Simulation not permitted (${report.method.probed.reason}). Read access was probed; write actions are unverified and will be confirmed on first use.`}
|
||||
</p>
|
||||
)}
|
||||
{ownPrincipalDenied && (
|
||||
<p className="text-xs text-[var(--warning)] leading-snug">
|
||||
Triple-C couldn't inspect the caller's own permissions. Both policies grant iam:GetRole and
|
||||
iam:SimulatePrincipalPolicy on the caller's own principal, so this is the first thing to ask
|
||||
an admin for.
|
||||
</p>
|
||||
)}
|
||||
{flagged.map((t) => (
|
||||
<details key={t.tier} className="text-xs">
|
||||
<summary className="cursor-pointer text-[var(--text-primary)]">
|
||||
{t.tier === "workspace_user" ? "Workspace user" : "Environment admin"} ({t.results.length} not
|
||||
allowed)
|
||||
</summary>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
{t.results.map((r) => (
|
||||
<li key={r.action} className="text-[var(--text-secondary)]">
|
||||
<span className="font-mono">{r.action}</span> — {r.verdict}
|
||||
{r.note && (
|
||||
<>
|
||||
{" "}
|
||||
({r.note}). Check again in a moment.
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={() => copy("workspace_user")}>
|
||||
Copy workspace-user policy
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => copy("environment_admin")}>
|
||||
Copy admin policy
|
||||
</Button>
|
||||
</div>
|
||||
{copyNote && (
|
||||
<p role="status" className="text-xs text-[var(--text-secondary)]">
|
||||
{copyNote}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
The simulation doesn't account for SCPs or session policies.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionRow({ host }: { host: HostConnection }) {
|
||||
const { loadSettings, setAppSettings } = useSettings();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(host.name);
|
||||
const [region, setRegion] = useState(host.kind.default_region);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [report, setReport] = useState<PermissionReport | null>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const auth = host.kind.auth;
|
||||
const rowRef = useRef<HTMLLIElement>(null);
|
||||
const focusIn = (key: string) =>
|
||||
rowRef.current?.querySelector<HTMLElement>(`[data-focus="${key}"]`)?.focus();
|
||||
useEffect(() => {
|
||||
if (confirmRemove) focusIn("confirm-remove");
|
||||
}, [confirmRemove]);
|
||||
const cancelRemove = () => {
|
||||
setConfirmRemove(false);
|
||||
requestAnimationFrame(() => focusIn("remove"));
|
||||
};
|
||||
|
||||
const runCheck = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
setReport(await checkAwsPermissions(host.id));
|
||||
} catch (e) {
|
||||
setReport(null);
|
||||
setFailure({ error: toAwsError(e), retry: runCheck });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
if (!failure) return;
|
||||
const retry = failure.retry;
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await awsConnectionSsoLogin(host.id);
|
||||
setFailure(null);
|
||||
await retry();
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry });
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
await updateAwsConnection(host.id, name.trim(), region.trim());
|
||||
await loadSettings();
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: save });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setAppSettings(await removeHostConnection(host.id));
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: remove });
|
||||
setConfirmRemove(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
data-testid={`host-${host.id}`}
|
||||
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5 space-y-1.5"
|
||||
>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Field label="Name">{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label="Default region">
|
||||
{(id) => (
|
||||
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="primary" onClick={save} disabled={busy || !name.trim() || !region.trim()}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditing(false); setName(host.name); setRegion(host.kind.default_region); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">{host.name}</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
<span>{auth.method === "profile" ? `Profile: ${auth.name}` : "Access keys"}</span>
|
||||
{" · "}
|
||||
<span>{host.kind.default_region}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={runCheck} disabled={busy} aria-label={`Check ${host.name}`}>
|
||||
Check
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(true)} aria-label={`Edit ${host.name}`}>
|
||||
Edit
|
||||
</Button>
|
||||
{!confirmRemove && (
|
||||
<Button data-focus="remove" size="sm" variant="danger" onClick={() => setConfirmRemove(true)} aria-label={`Remove ${host.name}`}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmRemove && (
|
||||
<div role="alertdialog" aria-label={`Remove ${host.name}?`} className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span>Remove {host.name}? Its stored credentials are deleted.</span>
|
||||
<Button data-focus="confirm-remove" size="sm" variant="danger" onClick={remove} disabled={busy} aria-label={`Confirm remove ${host.name}`}>
|
||||
Confirm remove
|
||||
</Button>
|
||||
<Button size="sm" onClick={cancelRemove}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{busy && !editing && !confirmRemove && <p role="status" className="text-xs text-[var(--text-secondary)]">Working…</p>}
|
||||
{failure && <ErrorLine failure={failure} onSignIn={signIn} signingIn={signingIn} />}
|
||||
{report && <PermissionReportView report={report} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function AddConnectionForm() {
|
||||
const { loadSettings } = useSettings();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [auth, setAuth] = useState<AuthChoice>("profile");
|
||||
const [name, setName] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [accessKeyId, setAccessKeyId] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [region, setRegion] = useState("us-east-1");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [signedInAs, setSignedInAs] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const [profilesError, setProfilesError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listAwsProfiles()
|
||||
.then((p) => {
|
||||
if (!cancelled) setProfiles(p);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setProfiles([]);
|
||||
setProfilesError(awsErrorMessage(toAwsError(e)));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ready =
|
||||
name.trim() !== "" &&
|
||||
region.trim() !== "" &&
|
||||
(auth === "profile" ? profile !== "" : accessKeyId.trim() !== "" && secret !== "");
|
||||
|
||||
/** Second step: confirm the new connection can sign in. Retried alone after an SSO login. */
|
||||
const verify = async (id: string) => {
|
||||
try {
|
||||
const who = await awsConnectionIdentity(id);
|
||||
setSignedInAs(who.arn);
|
||||
setFailure(null);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: () => verify(id) });
|
||||
}
|
||||
};
|
||||
|
||||
/** Everything after a successful add. Retrying never re-runs the add. */
|
||||
const afterAdd = async (id: string) => {
|
||||
const refreshed = await loadSettings();
|
||||
if (!refreshed) {
|
||||
setFailure({
|
||||
error: { kind: "other", message: "The connection was added, but the settings list could not be refreshed." },
|
||||
retry: () => afterAdd(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await verify(id);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
setSignedInAs(null);
|
||||
try {
|
||||
setPendingId(null);
|
||||
const added =
|
||||
auth === "profile"
|
||||
? await addAwsProfileConnection(name.trim(), profile, region.trim())
|
||||
: await addAwsKeysConnection(name.trim(), accessKeyId.trim(), secret, token.trim() || null, region.trim());
|
||||
if (auth === "keys") {
|
||||
setAccessKeyId("");
|
||||
setSecret("");
|
||||
setToken("");
|
||||
}
|
||||
setName("");
|
||||
setPendingId(added.id);
|
||||
await afterAdd(added.id);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: submit });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
if (!failure || !pendingId) return;
|
||||
const retry = failure.retry;
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await awsConnectionSsoLogin(pendingId);
|
||||
setFailure(null);
|
||||
await retry();
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry });
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ready && !busy) void submit();
|
||||
}}
|
||||
>
|
||||
<h4 className="text-[13px] font-semibold text-[var(--text-primary)]">Add AWS connection</h4>
|
||||
<Field label="Name">
|
||||
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
|
||||
</Field>
|
||||
<SegmentedControl<AuthChoice>
|
||||
label="Authentication"
|
||||
value={auth}
|
||||
onChange={setAuth}
|
||||
segments={[
|
||||
{ value: "profile", label: "Profile" },
|
||||
{ value: "keys", label: "Access keys" },
|
||||
]}
|
||||
/>
|
||||
{auth === "profile" ? (
|
||||
<Field
|
||||
label="Profile"
|
||||
hint={profilesError ? `Couldn't read your AWS profiles: ${profilesError}` : undefined}
|
||||
>
|
||||
{(id) => (
|
||||
<select id={id} className={`${selectClass} w-full`} value={profile} onChange={(e) => setProfile(e.target.value)}>
|
||||
<option value="">Select a profile…</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Access key ID">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={accessKeyId} onChange={(e) => setAccessKeyId(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Secret access key">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={secret} onChange={(e) => setSecret(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Session token (optional)" hint="Only for temporary credentials.">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={token} onChange={(e) => setToken(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Stored in the OS keychain and never shown again.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Field label="Default region">
|
||||
{(id) => (
|
||||
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" size="md" variant="primary" disabled={!ready || busy}>
|
||||
{busy ? "Adding…" : "Add connection"}
|
||||
</Button>
|
||||
{signedInAs && (
|
||||
<p role="status" className="text-xs text-[var(--success)]">
|
||||
Signed in as {signedInAs}
|
||||
</p>
|
||||
)}
|
||||
{failure && (
|
||||
<ErrorLine failure={failure} onSignIn={pendingId ? signIn : undefined} signingIn={signingIn} />
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HostsSettings() {
|
||||
const { appSettings } = useSettings();
|
||||
const hosts = appSettings?.hosts ?? [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Where project environments run. AWS connections are used to check what your credentials can do.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">Local Docker</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Always available</div>
|
||||
</li>
|
||||
{hosts.map((h) => (
|
||||
<ConnectionRow key={h.id} host={h} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<AddConnectionForm />
|
||||
</div>
|
||||
<datalist id="aws-regions">
|
||||
{COMMON_REGIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,9 @@ const samplePreview: SettingsImportPreview = {
|
||||
global_hook_install_count: 0,
|
||||
global_plugin_install_count: 0,
|
||||
marketplace_account_token_count: 0,
|
||||
aws_connections: [],
|
||||
aws_connection_key_count: 0,
|
||||
removed_aws_connections: [],
|
||||
};
|
||||
|
||||
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||
@@ -80,6 +83,50 @@ describe("ImportSettingsModal", () => {
|
||||
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the AWS connections and key count, and flags overwrites", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
aws_connections: [
|
||||
{
|
||||
name: "Prod",
|
||||
auth: "keys",
|
||||
profile_name: null,
|
||||
default_region: "us-west-2",
|
||||
overwrites_existing: true,
|
||||
overwrites_keys: true,
|
||||
keys_check_failed: false,
|
||||
local: { name: "Real Prod", auth: "keys", profile_name: null, default_region: "us-east-1" },
|
||||
},
|
||||
{
|
||||
name: "Dev",
|
||||
auth: "profile",
|
||||
profile_name: "dev-profile",
|
||||
default_region: "eu-west-1",
|
||||
overwrites_existing: false,
|
||||
overwrites_keys: false,
|
||||
keys_check_failed: true,
|
||||
local: null,
|
||||
},
|
||||
],
|
||||
aws_connection_key_count: 1,
|
||||
removed_aws_connections: [
|
||||
{ name: "Old", auth: "profile", profile_name: "old-p", default_region: "ap-south-1" },
|
||||
],
|
||||
});
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Overwrites the AWS access keys stored for your connection "Real Prod"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Replaces your connection "Real Prod" \(access keys, us-east-1\) with "Prod"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Could not check whether keys are stored for "Dev".*may overwrite them/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Removes your AWS connection "Old" \(profile old-p, ap-south-1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
|
||||
@@ -21,6 +21,7 @@ import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import MarketplaceSettings from "./MarketplaceSettings";
|
||||
import HostsSettings from "./HostsSettings";
|
||||
import CertificateSettings from "./CertificateSettings";
|
||||
import ExportSettingsModal from "./ExportSettingsModal";
|
||||
import ImportSettingsModal from "./ImportSettingsModal";
|
||||
@@ -188,6 +189,10 @@ export default function SettingsPanel() {
|
||||
<GatewaySettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>
|
||||
<HostsSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
||||
<DockerSettings />
|
||||
</AccordionSection>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toAwsError } from "./aws-error";
|
||||
|
||||
describe("toAwsError", () => {
|
||||
it("passes a tagged AwsError through", () => {
|
||||
const e = { kind: "sso_expired", profile: "dev" };
|
||||
expect(toAwsError(e)).toBe(e);
|
||||
});
|
||||
it("wraps strings and Errors as other", () => {
|
||||
expect(toAwsError("boom")).toEqual({ kind: "other", message: "boom" });
|
||||
expect(toAwsError(new Error("bad"))).toEqual({ kind: "other", message: "bad" });
|
||||
});
|
||||
it("rejects unknown kinds, inherited names and null", () => {
|
||||
expect(toAwsError({ kind: "nope" }).kind).toBe("other");
|
||||
expect(toAwsError({ kind: "toString" }).kind).toBe("other");
|
||||
expect(toAwsError(null).kind).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AwsError } from "./types";
|
||||
|
||||
// A `Record` over every kind: adding or removing a kind in `AwsError` fails to compile here.
|
||||
const KINDS: Record<AwsError["kind"], true> = {
|
||||
sso_expired: true,
|
||||
profile_not_found: true,
|
||||
keys_missing: true,
|
||||
invalid_credentials: true,
|
||||
access_denied: true,
|
||||
other: true,
|
||||
};
|
||||
|
||||
/** Narrow what an AWS command rejected with. Anything that is not a tagged `AwsError` becomes `other`. */
|
||||
export function toAwsError(e: unknown): AwsError {
|
||||
if (e && typeof e === "object" && "kind" in e) {
|
||||
const kind = (e as { kind: unknown }).kind;
|
||||
if (typeof kind === "string" && Object.prototype.hasOwnProperty.call(KINDS, kind)) {
|
||||
return e as AwsError;
|
||||
}
|
||||
}
|
||||
return { kind: "other", message: typeof e === "string" ? e : e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
@@ -24,6 +24,9 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
|
||||
global_hook_install_count: 0,
|
||||
global_plugin_install_count: 0,
|
||||
marketplace_account_token_count: 0,
|
||||
aws_connections: [],
|
||||
aws_connection_key_count: 0,
|
||||
removed_aws_connections: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SettingsImportPreview } from "./types";
|
||||
import type { AwsConnectionSummary, SettingsImportPreview } from "./types";
|
||||
|
||||
/** Named things a `SettingsImportPreview` says an import will change, for
|
||||
* `ImportSettingsModal`'s confirmation list. Does not include anything
|
||||
@@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] {
|
||||
const n = preview.marketplace_account_token_count;
|
||||
items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`);
|
||||
}
|
||||
for (const c of preview.aws_connections) {
|
||||
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
|
||||
items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`);
|
||||
}
|
||||
if (preview.aws_connection_key_count > 0) {
|
||||
const n = preview.aws_connection_key_count;
|
||||
items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -82,6 +90,29 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
|
||||
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
|
||||
);
|
||||
}
|
||||
for (const c of preview.aws_connections) {
|
||||
const localDesc = c.local ? describeAws(c.local) : null;
|
||||
if (c.overwrites_existing && c.local) {
|
||||
warnings.push(`Replaces your connection ${localDesc} with "${c.name}" (${c.auth === "keys" ? "access keys" : "profile " + (c.profile_name ?? "")}, ${c.default_region}).`);
|
||||
}
|
||||
if (c.overwrites_keys) {
|
||||
warnings.push(
|
||||
c.local
|
||||
? `Overwrites the AWS access keys stored for your connection ${localDesc} (imported as "${c.name}").`
|
||||
: `Overwrites AWS access keys already stored for the imported connection "${c.name}".`,
|
||||
);
|
||||
}
|
||||
if (c.keys_check_failed) {
|
||||
warnings.push(
|
||||
`Could not check whether keys are stored for "${c.name}"${c.local ? ` (your connection ${localDesc})` : ""}; importing may overwrite them.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const r of preview.removed_aws_connections) {
|
||||
warnings.push(
|
||||
`Removes your AWS connection ${describeAws(r)}, which is not in the file. Any access keys stored for it stay in your keychain.`,
|
||||
);
|
||||
}
|
||||
if (preview.image_source === "custom") {
|
||||
warnings.push(
|
||||
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
|
||||
@@ -89,3 +120,8 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function describeAws(c: AwsConnectionSummary): string {
|
||||
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
|
||||
return `"${c.name}" (${auth}, ${c.default_region})`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport } from "./types";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, Tier } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -44,6 +44,40 @@ export const detectAwsConfig = () =>
|
||||
invoke<string | null>("detect_aws_config");
|
||||
export const listAwsProfiles = () =>
|
||||
invoke<string[]>("list_aws_profiles");
|
||||
|
||||
// AWS host connections. The commands returning `Promise<…>` below reject with an `AwsError`
|
||||
// object (not a string); narrow a caught value with `toAwsError` from `./aws-error`.
|
||||
export const addAwsProfileConnection = (name: string, profile: string, defaultRegion: string) =>
|
||||
invoke<HostConnection>("add_aws_profile_connection", { name, profile, defaultRegion });
|
||||
export const addAwsKeysConnection = (
|
||||
name: string,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string,
|
||||
sessionToken: string | null,
|
||||
defaultRegion: string,
|
||||
) =>
|
||||
invoke<HostConnection>("add_aws_keys_connection", {
|
||||
name,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
defaultRegion,
|
||||
});
|
||||
export const updateAwsConnection = (id: string, name: string, defaultRegion: string) =>
|
||||
invoke<HostConnection>("update_aws_connection", { id, name, defaultRegion });
|
||||
/** Rejects with a string. Returns the settings after removal. */
|
||||
export const removeHostConnection = (id: string) =>
|
||||
invoke<AppSettings>("remove_host_connection", { id });
|
||||
export const awsConnectionIdentity = (id: string) =>
|
||||
invoke<CallerIdentity>("aws_connection_identity", { id });
|
||||
/** Profile connections only; rejects with a string. */
|
||||
export const awsConnectionSsoLogin = (id: string) =>
|
||||
invoke<void>("aws_connection_sso_login", { id });
|
||||
/** `region` omitted: the connection's default region. */
|
||||
export const checkAwsPermissions = (id: string, region?: string) =>
|
||||
invoke<PermissionReport>("check_aws_permissions", { id, region: region ?? null });
|
||||
export const awsPolicyDocument = (tier: Tier) =>
|
||||
invoke<string>("aws_policy_document", { tier });
|
||||
/** Check a corporate CA path and report what would be installed. Never
|
||||
* rejects for a bad path — the reason comes back in `error`. */
|
||||
export const inspectCaCertPath = (path: string) =>
|
||||
|
||||
@@ -300,6 +300,7 @@ export interface AppSettings {
|
||||
terminal_gpu_rendering: boolean | null;
|
||||
marketplace_accounts: MarketplaceAccount[];
|
||||
marketplaces: Marketplace[];
|
||||
hosts: HostConnection[];
|
||||
global_marketplace_installs: MarketplaceInstall[];
|
||||
}
|
||||
|
||||
@@ -424,6 +425,34 @@ export interface SettingsImportPreview {
|
||||
global_plugin_install_count: number;
|
||||
/** Marketplace account tokens the import restores to the keychain. */
|
||||
marketplace_account_token_count: number;
|
||||
/** AWS connections the import brings (names cleaned; no key values). */
|
||||
aws_connections: AwsConnectionPreview[];
|
||||
/** AWS access-key entries the import restores to the keychain. */
|
||||
aws_connection_key_count: number;
|
||||
/** Local AWS connections the import drops (it replaces the whole list). */
|
||||
removed_aws_connections: AwsConnectionSummary[];
|
||||
}
|
||||
|
||||
export interface AwsConnectionSummary {
|
||||
name: string;
|
||||
auth: "profile" | "keys";
|
||||
profile_name: string | null;
|
||||
default_region: string;
|
||||
}
|
||||
|
||||
export interface AwsConnectionPreview {
|
||||
name: string;
|
||||
auth: "profile" | "keys";
|
||||
profile_name: string | null;
|
||||
default_region: string;
|
||||
/** A connection with this id exists here with different content. */
|
||||
overwrites_existing: boolean;
|
||||
/** The import carries keys for an id that already has keys stored here. */
|
||||
overwrites_keys: boolean;
|
||||
/** The keychain could not be asked whether keys are already stored. */
|
||||
keys_check_failed: boolean;
|
||||
/** The local connection with this id, when the import replaces it. */
|
||||
local: AwsConnectionSummary | null;
|
||||
}
|
||||
|
||||
/** What `apply_settings_import` returns: the settings that were actually
|
||||
@@ -1099,3 +1128,59 @@ export interface ViewerPoll {
|
||||
hash: string | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export type AwsAuth =
|
||||
| { method: "profile"; name: string }
|
||||
| { method: "keys"; keyring_ref: string };
|
||||
|
||||
export interface HostConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: { type: "aws"; auth: AwsAuth; default_region: string };
|
||||
}
|
||||
|
||||
export type AwsErrorKind = AwsError["kind"];
|
||||
|
||||
/** What a failed AWS command rejects with (`aws::AwsError`, tagged on `kind`). */
|
||||
export type AwsError =
|
||||
| { kind: "sso_expired"; profile: string }
|
||||
| { kind: "profile_not_found"; profile: string }
|
||||
| { kind: "keys_missing" }
|
||||
| { kind: "invalid_credentials"; message: string }
|
||||
| { kind: "access_denied"; action: string; message: string }
|
||||
| { kind: "other"; message: string };
|
||||
|
||||
export interface CallerIdentity {
|
||||
account: string;
|
||||
arn: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export type Tier = "environment_admin" | "workspace_user";
|
||||
|
||||
export type Verdict = "allowed" | "denied" | "unverified";
|
||||
|
||||
export interface ActionResult {
|
||||
action: string;
|
||||
verdict: Verdict;
|
||||
/** Set when the row is unverified because a call failed; blocks the tier's claim. */
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface TierResult {
|
||||
tier: Tier;
|
||||
results: ActionResult[];
|
||||
}
|
||||
|
||||
export type PermissionMethod = "simulated" | { probed: { reason: string } };
|
||||
|
||||
export type PermissionSummary = "environment_admin" | "workspace_user" | "insufficient";
|
||||
|
||||
export interface PermissionReport {
|
||||
identity: CallerIdentity;
|
||||
region: string;
|
||||
method: PermissionMethod;
|
||||
summary: PermissionSummary;
|
||||
/** `[workspace_user, environment_admin]`. */
|
||||
tiers: TierResult[];
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# Triple-C AWS policies
|
||||
|
||||
These are the IAM policies for running Triple-C workspaces on AWS (ECS). Attach them to the IAM
|
||||
users, groups, roles or SSO permission sets of the people who use Triple-C. The app reports which
|
||||
tier the signed-in identity has under **Settings → Hosts → Check**.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment |
|
||||
| `policies/environment-admin.json` | The **environment admin** tier: deploy, update and delete the environment stack, and onboard users |
|
||||
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
|
||||
|
||||
An environment admin needs **both** tier files. The admin policy adds environment management on
|
||||
top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone
|
||||
who has it attached.
|
||||
|
||||
## Workspace user
|
||||
|
||||
Every per-user resource is tagged `triple-c:owner = ${aws:userid}`. That value is the `UserId`
|
||||
printed by `aws sts get-caller-identity`. The policy uses this tag so that a user can only do the
|
||||
following:
|
||||
|
||||
- **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner
|
||||
ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID.
|
||||
The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks.
|
||||
- **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an
|
||||
SSM session to them. They can end or resume only sessions they started, and open data channels
|
||||
(`ssmmessages:OpenDataChannel`) only on those.
|
||||
- `ecs:ExecuteCommand` is authorized against the **cluster** as well as the task, so the policy
|
||||
also allows it on `triple-c-*` clusters with no condition. The owner rule is on the task, and
|
||||
both must pass.
|
||||
- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with
|
||||
their owner ID.
|
||||
- A secret must be encrypted with an **environment KMS key**, meaning one tagged
|
||||
`triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId` (a key
|
||||
ID, an alias or no key at all is refused), and the user can use KMS
|
||||
(`Encrypt`/`GenerateDataKey`/`Decrypt`) only through Secrets Manager and only with environment
|
||||
keys. Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` to create or update a
|
||||
secret on a customer key.
|
||||
- A secret on any other key is refused by an **explicit Deny**: `kms:Encrypt` and
|
||||
`kms:GenerateDataKey*` through Secrets Manager on a key without `triple-c:component`. The Deny
|
||||
is needed because the AWS-managed `aws/secretsmanager` key, or any customer key whose key
|
||||
policy grants account principals directly, needs no identity-policy Allow; without the Deny
|
||||
such a `CreateSecret` succeeds. Keeping every user secret on the environment key keeps it
|
||||
under the key policy the environment stack controls. (The permissions boundary does *not*
|
||||
stop a task role decrypting an `aws/secretsmanager` secret: that key's policy grants the
|
||||
caller directly.)
|
||||
- Every `TagResource` on a secret must carry `triple-c:owner = ${aws:userid}` in the request,
|
||||
even when it only adds `triple-c:project`.
|
||||
- A secret tagged `triple-c:component` belongs to the environment. A workspace user can never
|
||||
tag, untag, overwrite or delete one; an explicit Deny enforces this.
|
||||
- **Manage their own access points.** They can create and delete EFS access points tagged with
|
||||
their owner ID, and only on the environment's file system, which is the one tagged
|
||||
`triple-c:component`.
|
||||
- **Set only the ordinary tag keys.** Every request that tags something may set only
|
||||
`triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to
|
||||
`RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A
|
||||
workspace user can never set `triple-c:component`, which marks environment resources and which
|
||||
the Denies and phase-4 detection trust.
|
||||
- **Inspect their own principal, as far as IAM can say so.** `iam:GetRole` and
|
||||
`iam:SimulatePrincipalPolicy`, which the app's permission check uses on the caller's own
|
||||
principal. An IAM user may inspect only itself (`user/${aws:username}`, with or without a path).
|
||||
"My own role" cannot be written: IAM refuses `${aws:PrincipalArn}` as a Resource (verified live,
|
||||
"must be in ARN format or \"*\"") and has no role-name variable. So the `InspectOwnPrincipal`
|
||||
statement names the roles people actually sign in with:
|
||||
- `role/aws-reserved/sso.amazonaws.com/*`, the IAM Identity Center permission-set roles;
|
||||
- `role/triple-c-*`.
|
||||
|
||||
A caller on any other role cannot read or simulate its role, so the permission check falls back
|
||||
to probes and reports **Insufficient**, naming `iam:GetRole`. If your users sign in through other
|
||||
roles, add those roles' ARNs or path prefixes (for example `arn:aws:iam::*:role/engineers/*`)
|
||||
to `InspectOwnPrincipal` in both tier policies. Everything matched there can be read and
|
||||
simulated by every holder of the policy, so keep it to the sign-in roles.
|
||||
- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM
|
||||
parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the
|
||||
app filters by owner for display.
|
||||
|
||||
## Environment admin
|
||||
|
||||
The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create:
|
||||
|
||||
- `triple-c-*` IAM roles;
|
||||
- ECS clusters, capacity providers and task definitions;
|
||||
- security groups, EFS file systems and KMS keys;
|
||||
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters;
|
||||
- ECR repositories and pull-through cache rules.
|
||||
|
||||
It is fenced in the following ways.
|
||||
|
||||
**Roles are bounded.** The admin can create a `triple-c-*` role, or change its policies, only when
|
||||
the role carries the `triple-c-permissions-boundary` managed policy as its permissions boundary.
|
||||
|
||||
- The boundary is the most a Triple-C role can ever hold:
|
||||
- ECR image pulls;
|
||||
- writing `/triple-c/*` logs;
|
||||
- ECS Exec channels;
|
||||
- reading `triple-c/*` secrets;
|
||||
- decrypting only through Secrets Manager, and only with environment (component-tagged) keys;
|
||||
- mounting and writing only environment (component-tagged) file systems; whether that excludes
|
||||
root access depends on access points fixing the POSIX user and root directory, which is only
|
||||
true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps);
|
||||
- `sts:GetCallerIdentity`;
|
||||
- invoking Bedrock models.
|
||||
- The boundary grants nothing in IAM.
|
||||
- Explicit Deny statements stop the admin from:
|
||||
- removing the boundary from a `triple-c-*` role;
|
||||
- setting any other boundary on one;
|
||||
- editing or deleting the boundary policy itself.
|
||||
|
||||
Without these fences an admin could create a role with any permissions and become an account
|
||||
administrator.
|
||||
|
||||
**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or
|
||||
create a change set for one, with a service role (`RoleARN`).
|
||||
|
||||
**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only
|
||||
with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag.
|
||||
Tagging is allowed only as part of the creating call, or on a resource that is already
|
||||
Triple-C's.
|
||||
|
||||
**What is not scoped.** The scoping above does not cover every action. These actions apply
|
||||
account-wide:
|
||||
|
||||
- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister
|
||||
*any* task definition revision in the account. `DeregisterTaskDefinition` has no resource type
|
||||
(IAM authorizes it against `*`), so it cannot be narrowed.
|
||||
- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add
|
||||
pull-through cache rules for any prefix. This cannot delete or overwrite anything.
|
||||
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
|
||||
`triple-c:component` request tag.
|
||||
- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`.
|
||||
- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions
|
||||
above, because CloudFormation runs with the caller's own permissions (see the invariants below).
|
||||
|
||||
## Invariants the templates must keep (phase 4)
|
||||
|
||||
The policies rely on the following. Phase 4's templates must establish them, and phase 4's
|
||||
detection should flag any environment that breaks them.
|
||||
|
||||
1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot
|
||||
create one without it. A role created some other way, such as by an account administrator or by
|
||||
hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no
|
||||
boundary.
|
||||
2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack`
|
||||
and change sets on a stack that already has a service role reuse it without `iam:PassRole`,
|
||||
and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection
|
||||
should flag a `triple-c-*` stack whose `RoleARN` is set.
|
||||
3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with
|
||||
neither an owner tag nor a component tag could be claimed by any workspace user.
|
||||
4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary
|
||||
and the admin tier both rely on this tag.
|
||||
5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
|
||||
`KmsKeyId` when it creates a secret. The workspace policy explicitly denies Secrets Manager
|
||||
encryption with any key that lacks `triple-c:component`, including `aws/secretsmanager`.
|
||||
|
||||
## One-time setup: the boundary policy
|
||||
|
||||
An **account administrator** creates the boundary policy once, before anyone deploys an
|
||||
environment. The environment-admin tier deliberately cannot do this.
|
||||
|
||||
```sh
|
||||
aws iam create-policy --policy-name triple-c-permissions-boundary \
|
||||
--policy-document file://policies/permissions-boundary.json
|
||||
```
|
||||
|
||||
Create the policy **without a path**. The admin policy expects exactly
|
||||
`arn:aws:iam::<account>:policy/triple-c-permissions-boundary`. Phase 4 decides how the app offers
|
||||
this step; one option is to list it with the environment stack's prerequisites.
|
||||
|
||||
## Attaching the tier policies
|
||||
|
||||
Create each file as a **customer managed policy** and attach it. In the console, go to
|
||||
**IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group
|
||||
or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an
|
||||
environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may
|
||||
hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30
|
||||
characters of headroom, so phase 4 must split `workspace-user.json` into two policies before
|
||||
adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it
|
||||
outgrows that. For IAM Identity Center, add the files as inline policies on the permission set
|
||||
(the "up to 32,768 characters" limit is unverified) or reference the managed policies.
|
||||
|
||||
With the CLI:
|
||||
|
||||
```sh
|
||||
aws iam create-policy --policy-name triple-c-workspace-user \
|
||||
--policy-document file://policies/workspace-user.json
|
||||
aws iam attach-user-policy --user-name <user> \
|
||||
--policy-arn arn:aws:iam::<account>:policy/triple-c-workspace-user
|
||||
```
|
||||
|
||||
## Known gaps
|
||||
|
||||
**Phase 4**
|
||||
|
||||
- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner
|
||||
ID. Each user's onboarding stack must therefore create their execution role as well as their
|
||||
task role. The alternative is a separate statement that allows the environment's shared
|
||||
execution role.
|
||||
- **The admin's full deploy permissions.** Deploying needs the following, added with the same
|
||||
boundary and tag scoping once the templates exist:
|
||||
- tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR;
|
||||
- autoscaling and instance-profile actions for EC2 capacity;
|
||||
- `cloudformation:ValidateTemplate`;
|
||||
- whatever else the templates create.
|
||||
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
|
||||
boundary also allows every `triple-c/*` secret and every environment file system. As a result,
|
||||
isolating users *from the admin* depends on the onboarding template's per-user policies, not on
|
||||
IAM fences.
|
||||
- **The workspace tier's secret Deny also applies to admins.** If the environment stack ever
|
||||
manages component-tagged secrets through the admin's credentials, that Deny will block
|
||||
`PutSecretValue`/`DeleteSecret` for them.
|
||||
|
||||
**Phase 5**
|
||||
|
||||
- **No secrets in task definitions or `RunTask` overrides.** Nothing sensitive (the agent token,
|
||||
git credentials) may travel in task-definition environment variables or in `RunTask`
|
||||
`containerOverrides`: every workspace user can read them through `ecs:Describe*` on `*`. Use
|
||||
Secrets Manager (`triple-c/*`) references instead.
|
||||
|
||||
**Phase 6**
|
||||
|
||||
- **Enforced access-point roots.** A workspace user creates their own access points. The
|
||||
`RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot
|
||||
read other users' data. Two ways to do this: an EFS file-system policy that pins each access
|
||||
point to its owner's task role, or access points created by the onboarding stack rather than by
|
||||
the user.
|
||||
|
||||
## Live verification (2026-11-14)
|
||||
|
||||
Every point the unit tests' small evaluator could not settle was checked against real IAM in a
|
||||
disposable account (us-west-2): with the policy simulator, with calls made as throwaway roles that
|
||||
hold the policies, and with deny probes (a call made as a role with no permissions names the
|
||||
resource it was authorized against, and creates nothing). The repeatable part is
|
||||
`app/src-tauri/src/aws/live_tests.rs`:
|
||||
|
||||
```sh
|
||||
cd app/src-tauri
|
||||
TRIPLEC_AWS_E2E_PROFILE=<admin profile of a disposable account> TRIPLEC_AWS_E2E_REGION=us-west-2 \
|
||||
cargo test aws::live_tests -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
It creates `triple-c-e2e-<id>-*` roles and policies, `triple-c/triple-c-e2e-<id>/*` secrets and
|
||||
`triple-c-e2e-<id>` KMS keys, all tagged `triple-c-test=phase3`, and deletes them when each test
|
||||
ends (keys are scheduled for deletion, 7 days). One test creates the real
|
||||
`triple-c-permissions-boundary` policy if the account has none, and deletes it again.
|
||||
|
||||
✓ verified as written · ✗→fixed: the policy changed · — could not verify, and why.
|
||||
|
||||
| Point | Result |
|
||||
|---|---|
|
||||
| `${aws:PrincipalArn}` as a Resource | ✗→fixed. IAM refuses it ("must be in ARN format or \"*\""), so neither tier policy could be attached at all. Now SSO permission-set roles, `triple-c-*` roles and `user/${aws:username}`, `user/*/${aws:username}` (see the workspace user's own principal) |
|
||||
| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com/<region>/AWSReservedSSO_…`, and the check simulates that ARN. The SSO admin session checks as Environment admin, Simulated |
|
||||
| `${aws:PrincipalAccount}` in `iam:PermissionsBoundary` | ✓ `CreateRole` with `triple-c-permissions-boundary` succeeds as the admin tier; without a boundary, or with another one, it is denied |
|
||||
| Boundary fences | ✓ removing, replacing or editing the boundary is an explicit deny |
|
||||
| Simulator and context | ✓ the simulator substitutes `${aws:userid}` and `${aws:username}` from context entries. It **does** apply the simulated principal's own permissions boundary (verified); it does not apply session policies. SCPs: not verifiable here |
|
||||
| Simulator rate limit | ✗→fixed (app). `SimulatePrincipalPolicy` throttles ("Rate exceeded") within a full check; the check now uses adaptive retries and two requests at a time |
|
||||
| KMS `CreateKey` with tags | ✓ allowed: tag-on-create passes the tag rule on `kms:TagResource`. Untagged `CreateKey`, and `CreateAlias`/`TagResource`/`ScheduleKeyDeletion` on an untagged key, are denied |
|
||||
| ECS Exec on the cluster | ✗→fixed. `ecs:ExecuteCommand` is authorized against the cluster, then the task. Added `ExecOnTripleCClusters`; with only the cluster allowed, the call is denied on the task, so the owner rule still decides |
|
||||
| ECS tag keys | ✓ (service reference) `aws:ResourceTag` is supported on tasks, clusters and task definitions. Live: — needs phase 4/5 ECS resources |
|
||||
| SSM `StartSession` to a task | ✓ authorized against `arn:aws:ecs:<region>:<account>:task/<cluster>/<task-id>`, which `task/triple-c-*/*` matches. The tag condition on a real task: — needs phase 5 |
|
||||
| SSO session ownership | ✓ (AWS docs) the `aws:ssmmessages:session-id` tag is the starter's `aws:userid` for users and role sessions alike. Live: — needs a session. Added `ssmmessages:OpenDataChannel` on own sessions, which AWS's current examples require |
|
||||
| EC2 tag-on-create | ✓ `CreateSecurityGroup` (DryRun) is allowed with a `triple-c:component` tag and denied without one or with only other tags. `security-group-rule` resources on `AuthorizeSecurityGroup*`: — needs a security group |
|
||||
| EFS | ✗→fixed: `CreateFileSystem` has no resource type, so `Resource` is now `*`. ✓ a tagged `CreateFileSystem`/`CreateAccessPoint` authorizes `elasticfilesystem:TagResource` first. `elasticfilesystem:CreateAction`, `CreateAccessPoint` against the file system's tags, `ClientMount`/`ClientWrite`: — need phase 6 file systems |
|
||||
| Secrets tag-on-create | ✓ `CreateSecret` with tags authorizes `secretsmanager:TagResource` separately (refused without `TagOwnSecrets`), and that statement's `StringEqualsIfExists`/`Null` conditions pass at create |
|
||||
| `secretsmanager:KmsKeyId` | ✓ carries the request's value: a key ARN passes, a bare key ID, an alias or no key is refused |
|
||||
| Secrets Manager's KMS calls | ✓ made as the caller with `kms:ViaService`. ✗→fixed: `CreateSecret`/`PutSecretValue` on a customer key also need `kms:Decrypt`, which the policy lacked ("Access to KMS is not allowed"). `PutSecretValue` on an own secret now passes |
|
||||
| `aws/secretsmanager` key | ✓ denied, and the Deny is what denies it: the same policy without `NeverEncryptSecretsWithOtherKeys` creates the secret |
|
||||
| Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) |
|
||||
| `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources |
|
||||
| Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` |
|
||||
| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) |
|
||||
| Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed |
|
||||
|
||||
Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s
|
||||
`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/
|
||||
`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule
|
||||
points above. The simulator does not apply session policies, so the first real create is the final
|
||||
check.
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ManageEnvironmentStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageBoundedTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:CreateRole",
|
||||
"iam:PutRolePolicy",
|
||||
"iam:AttachRolePolicy",
|
||||
"iam:DetachRolePolicy",
|
||||
"iam:DeleteRolePolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagReadAndDeleteTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:DeleteRole",
|
||||
"iam:TagRole",
|
||||
"iam:GetRole"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "PassTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:PassRole"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PassedToService": [
|
||||
"ecs-tasks.amazonaws.com",
|
||||
"ec2.amazonaws.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "KeepTheBoundaryOnTripleCRoles",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"iam:DeleteRolePermissionsBoundary"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "KeepTheBoundaryPolicyUnchanged",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"iam:CreatePolicyVersion",
|
||||
"iam:DeletePolicy",
|
||||
"iam:DeletePolicyVersion",
|
||||
"iam:SetDefaultPolicyVersion"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:policy/triple-c-permissions-boundary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "OnlyTheTripleCBoundaryOnTripleCRoles",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"iam:PutRolePermissionsBoundary"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringNotEquals": {
|
||||
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "NoServiceRoleForTripleCStacks",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"cloudformation:CreateStack",
|
||||
"cloudformation:UpdateStack",
|
||||
"cloudformation:CreateChangeSet"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"cloudformation:RoleArn": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "InspectOwnPrincipal",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:GetRole",
|
||||
"iam:SimulatePrincipalPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
|
||||
"arn:aws:iam::*:role/triple-c-*",
|
||||
"arn:aws:iam::*:user/${aws:username}",
|
||||
"arn:aws:iam::*:user/*/${aws:username}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageClusters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCluster",
|
||||
"ecs:DeleteCluster",
|
||||
"ecs:PutClusterCapacityProviders"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:cluster/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageCapacityProviders",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCapacityProvider",
|
||||
"ecs:DeleteCapacityProvider"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:capacity-provider/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTaskDefinitions",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:RegisterTaskDefinition",
|
||||
"ecs:DeregisterTaskDefinition"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateSecurityGroupsInVpcs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "TagSecurityGroupsAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ec2:CreateAction": "CreateSecurityGroup"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DeleteSecurityGroup",
|
||||
"ec2:AuthorizeSecurityGroup*",
|
||||
"ec2:RevokeSecurityGroup*",
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DescribeEc2",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:Describe*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedFileSystems",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:CreateFileSystem"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagFileSystemsAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:file-system/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"elasticfilesystem:CreateAction": "CreateFileSystem"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCFileSystems",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:DeleteFileSystem",
|
||||
"elasticfilesystem:CreateMountTarget",
|
||||
"elasticfilesystem:DeleteMountTarget",
|
||||
"elasticfilesystem:PutFileSystemPolicy",
|
||||
"elasticfilesystem:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:file-system/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedKeys",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:CreateKey"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCKeys",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:ScheduleKeyDeletion",
|
||||
"kms:PutKeyPolicy",
|
||||
"kms:TagResource",
|
||||
"kms:CreateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCKeyAliases",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:CreateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:alias/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageLogGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:DeleteLogGroup",
|
||||
"logs:PutRetentionPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:*:*:log-group:/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageRepositories",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreateRepository",
|
||||
"ecr:DeleteRepository"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecr:*:*:repository/triple-c-*",
|
||||
"arn:aws:ecr:*:*:repository/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreatePullThroughCacheRules",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreatePullThroughCacheRule"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageEnvironmentParameters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:PutParameter",
|
||||
"ssm:DeleteParameter"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*:*:parameter/triple-c/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "EcrAuthorization",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:GetAuthorizationToken"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "PullTripleCImages",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:BatchImportUpstreamImage",
|
||||
"ecr:CreateRepository"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecr:*:*:repository/triple-c-*",
|
||||
"arn:aws:ecr:*:*:repository/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "WriteTripleCLogs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:*:*:log-group:/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "EcsExecChannels",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssmmessages:CreateControlChannel",
|
||||
"ssmmessages:CreateDataChannel",
|
||||
"ssmmessages:OpenControlChannel",
|
||||
"ssmmessages:OpenDataChannel"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadTripleCSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:GetSecretValue"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "DecryptThroughSecretsManagerOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Decrypt"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"kms:ViaService": "secretsmanager.*.amazonaws.com"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "MountEnvironmentFileSystems",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:ClientMount",
|
||||
"elasticfilesystem:ClientWrite"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:file-system/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "WhoAmI",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:GetCallerIdentity"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "InvokeBedrockModels",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock:InvokeModel",
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:bedrock:*::foundation-model/*",
|
||||
"arn:aws:bedrock:*:*:inference-profile/*",
|
||||
"arn:aws:bedrock:*:*:application-inference-profile/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "RunOwnTasks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:RunTask"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:task-definition/triple-c-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}",
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"ArnLike": {
|
||||
"ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagOwnTasksAtRunTask",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ecs:CreateAction": "RunTask",
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PassOwnTaskRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:PassRole"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:ResourceTag/triple-c:owner": "${aws:userid}",
|
||||
"iam:PassedToService": "ecs-tasks.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "StopAndExecOwnTasks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:StopTask",
|
||||
"ecs:ExecuteCommand"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ExecOnTripleCClusters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:ExecuteCommand"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:cluster/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "StartSessionsToOwnTasks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:StartSession"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "UsePortForwardingDocument",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:StartSession"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "UseEndOrResumeOwnSessions",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:TerminateSession",
|
||||
"ssm:ResumeSession",
|
||||
"ssmmessages:OpenDataChannel"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*:*:session/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateOwnSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:CreateSecret"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
},
|
||||
"ArnLike": {
|
||||
"secretsmanager:KmsKeyId": "arn:aws:kms:*:*:key/*"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagOwnSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"StringEqualsIfExists": {
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "true"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageOwnSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:DeleteSecret"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "NeverTouchEnvironmentSecrets",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"secretsmanager:TagResource",
|
||||
"secretsmanager:UntagResource",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:DeleteSecret"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateOwnAccessPoints",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:CreateAccessPoint"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:file-system/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagOwnAccessPointsAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:access-point/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"elasticfilesystem:CreateAction": "CreateAccessPoint",
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"ForAllValues:StringEquals": {
|
||||
"aws:TagKeys": [
|
||||
"triple-c:owner",
|
||||
"triple-c:project",
|
||||
"triple-c:environment"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DeleteOwnAccessPoints",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:DeleteAccessPoint"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:access-point/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Encrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:Decrypt"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"kms:ViaService": "secretsmanager.*.amazonaws.com"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "NeverEncryptSecretsWithOtherKeys",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"kms:Encrypt",
|
||||
"kms:GenerateDataKey*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"kms:ViaService": "secretsmanager.*.amazonaws.com"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ReadEcs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:Describe*",
|
||||
"ecs:List*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadEnvironmentParameters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:GetParameter*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*:*:parameter/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:DescribeStacks",
|
||||
"cloudformation:ListStacks"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ListSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:ListSecrets"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadFileSystems",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:Describe*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadLogs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:GetLogEvents",
|
||||
"logs:FilterLogEvents"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:*:*:log-group:/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "InspectOwnPrincipal",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:GetRole",
|
||||
"iam:SimulatePrincipalPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
|
||||
"arn:aws:iam::*:role/triple-c-*",
|
||||
"arn:aws:iam::*:user/${aws:username}",
|
||||
"arn:aws:iam::*:user/*/${aws:username}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "WhoAmI",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:GetCallerIdentity"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
# AWS Connection + Permission Check (Phase 3) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A user can add an AWS connection in Settings → Hosts (an AWS profile, including SSO, or access keys, plus a default region). They see "Signed in as `<arn>`", and can run a permission check that reports Environment admin, Workspace user or Insufficient. For anything missing, it names the exact actions and offers the policy JSON to send to an admin.
|
||||
|
||||
**Architecture:**
|
||||
- A new `aws/` Rust module builds SDK configs from a `HostConnection`: an explicit profile provider, or static keys read from the keyring.
|
||||
- It calls `sts:GetCallerIdentity` and runs a table-driven permission check. That check uses `iam:SimulatePrincipalPolicy`, falling back to read-only probes when simulation is denied.
|
||||
- The two tier policies ship as JSON in `aws/policies/` and are compiled in with `include_str!`. A test keeps the check table and the policy files identical action-for-action.
|
||||
- Tauri commands in `commands/host_commands.rs`. The React UI is `components/settings/HostsSettings.tsx`.
|
||||
|
||||
**Tech Stack:**
|
||||
- Rust: Tauri v2. Already present: `aws-config` 1.13 (`sso`, `credentials-process`), `aws-sdk-ecs`, `aws-sdk-ssm`. Added: `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager`, all with `default-features = false` and features `default-https-client`, `rt-tokio`, `behavior-version-latest`, the same as the existing ones.
|
||||
- `keyring` through `storage/secure.rs`.
|
||||
- React + TypeScript + vitest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: "User flow" §1 and §3, "Multi-user accounts", "Data model", "Shipped AWS artefacts", "Connection model".
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Connection model **C**: a connection is credentials plus a **default** region. Region and environment belong to a project later (phase 5), not to the connection.
|
||||
- Auth options are the same as the AWS CLI's: `AwsAuth::Profile { name }` (any profile in `~/.aws/config`, SSO included) or `AwsAuth::Keys { keyring_ref }`.
|
||||
- Access keys are stored in the OS keyring through `storage/secure.rs`, **never in `settings.json`**.
|
||||
- `AppSettings.hosts: Vec<HostConnection>`. Local is implicit and is not stored. Existing `settings.json` files must deserialize unchanged (`#[serde(default)]`).
|
||||
- The owner ID is `UserId` from `GetCallerIdentity`, i.e. `aws:userid`. Every per-user resource is tagged `triple-c:owner = ${aws:userid}`.
|
||||
- Tags: `triple-c:owner`, `triple-c:project`, `triple-c:environment`, `triple-c:component`.
|
||||
- Resource naming fixed by this phase, which phase 4's templates must use:
|
||||
- clusters `triple-c-*`;
|
||||
- task definition families `triple-c-*`;
|
||||
- secrets `triple-c/*`;
|
||||
- SSM parameters `/triple-c/*`;
|
||||
- stacks `triple-c-*`;
|
||||
- IAM roles `triple-c-*`.
|
||||
- The simulator does not apply SCPs, permission boundaries or session policies. The UI must say so, and write actions that were only probed are shown as **"unverified — will be confirmed on first use"**, never as OK.
|
||||
- AWS calls must not inherit the app process's `AWS_*` environment for a **profile** connection. Use `ProfileFileCredentialsProvider` with the explicit profile name, not the default chain.
|
||||
- Every new Tauri command is registered in `lib.rs` and granted in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass.
|
||||
- Deferred on purpose:
|
||||
- **EFS.** The spec's `elasticfilesystem:DescribeAccessPoints` probe waits for the EFS crate, which arrives with EFS mode in phase 6. Until then, EFS read checks are `Unverified` in probe mode.
|
||||
- **Role policies.** `task-execution-role.json` and `task-role-standard.json` ship with the phase 4 templates that use them.
|
||||
- **Per-environment caching.** Results are cached per `(connection, region, environment)` and re-run on a denial. Environments arrive in phase 4, so for now the check runs on demand and the UI keeps the last result.
|
||||
- No AWS spending. The permission check uses only IAM simulation, STS and read-only list/describe calls.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **SSO profile with an expired token.** Expect a typed `AwsError::SsoExpired` and a "Sign in" button that runs `aws sso login --profile <name>`, not a raw SDK error. Test: `an_expired_sso_token_is_reported_as_sso_expired` (Task 3).
|
||||
2. **An SSO or assumed-role caller.** `GetCallerIdentity` returns `arn:aws:sts::<acct>:assumed-role/<Role>/<session>`, but simulation needs the role ARN *with its path*. SSO roles live under `/aws-reserved/sso.amazonaws.com/<region>/`. Look the path up with `iam:GetRole`. If that is denied, fall back to probes rather than simulating a wrong ARN. Tests: `assumed_role_arn_maps_to_role_arn_with_path` and `a_denied_get_role_falls_back_to_probes` (Tasks 3 and 5).
|
||||
3. **The caller can't run `iam:SimulatePrincipalPolicy`** (common for locked-down SSO roles). Expect the probe fallback: probed reads are marked allowed or denied, writes unverified, and the tier labelled "unverified". Test: `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified` (Task 5).
|
||||
4. **Removing a connection whose keyring entry is already gone** (deleted by hand, or another OS user). Expect removal to still succeed, because a missing entry is not an error. Test: `removing_a_keys_connection_with_no_keyring_entry_succeeds` (Task 2).
|
||||
5. **Settings import on another machine.** A `Keys` connection must arrive with its keys (exported like marketplace tokens), and a `Profile` connection arrives as-is even if that profile doesn't exist there. Checking it then says "profile not found", not a panic. Tests: `aws_connection_keys_round_trip_through_export` (Task 2) and `a_missing_profile_is_reported_by_name` (Task 3).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data model — `HostConnection` in settings (Rust + TS)
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src-tauri/src/models/host_connection.rs`
|
||||
- Modify: `app/src-tauri/src/models/mod.rs` (add `pub mod host_connection;` and re-export)
|
||||
- Modify: `app/src-tauri/src/models/app_settings.rs` (add `#[serde(default)] pub hosts: Vec<HostConnection>` after `marketplaces`, plus `Default`)
|
||||
- Modify: `app/src/lib/types.ts` (add `HostConnection`, `AwsAuth`; add `hosts: HostConnection[]` to `AppSettings`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostConnection { pub id: String, pub name: String, pub kind: HostKind }
|
||||
#[derive(..)] #[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HostKind { Aws { auth: AwsAuth, default_region: String } }
|
||||
#[derive(..)] #[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum AwsAuth { Profile { name: String }, Keys { keyring_ref: String } }
|
||||
pub fn validate_connection_name(name: &str) -> Result<String, String> // trimmed, 1..=64 chars, no control chars
|
||||
pub fn validate_region(region: &str) -> Result<String, String> // ^[a-z]{2}(-[a-z]+)+-\d+$
|
||||
pub fn validate_profile_name(name: &str) -> Result<String, String> // non-empty, ≤128, [A-Za-z0-9_.@+=,-]
|
||||
```
|
||||
TS: `type AwsAuth = { method: "profile"; name: string } | { method: "keys"; keyring_ref: string }`; `interface HostConnection { id: string; name: string; kind: { type: "aws"; auth: AwsAuth; default_region: string } }`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** in `host_connection.rs` `mod tests`:
|
||||
- `settings_without_hosts_deserialize_to_empty`: a minimal `AppSettings` JSON with no `hosts` key gives `hosts == vec![]`.
|
||||
- `a_profile_connection_round_trips`: serialize then deserialize gives an equal value, and the JSON contains `"type":"aws"` and `"method":"profile"`.
|
||||
- `regions_are_validated`: accepts `us-west-2`, `eu-central-1`, `cn-north-1`, `us-gov-west-1`; rejects `""`, `US-WEST-2`, `us-west`, `us-west-2; rm`.
|
||||
- `profile_names_are_validated`: accepts `default` and `340315884407_StaffAdministratorAccess`; rejects `""`, `a b`, `x\n`, and 129 chars.
|
||||
- [ ] **Step 2: Run them:** `cd app/src-tauri && cargo test host_connection`. Expected: FAIL (module missing).
|
||||
- [ ] **Step 3: Implement the types and validators.** Add `hosts` to `AppSettings` and its `Default`.
|
||||
- [ ] **Step 4: Run them:** `cargo test host_connection` passes. Run `cargo test` (whole crate) to catch any other `AppSettings { .. }` literal that needs the field.
|
||||
- [ ] **Step 5: Add the TS types.** `cd app && npx tsc --noEmit` passes. Update any test fixture that builds an `AppSettings` with `hosts: []`.
|
||||
- [ ] **Step 6: Commit** `feat(aws): HostConnection model in app settings`.
|
||||
|
||||
### Task 2: Keyring storage for access keys, and settings export
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src-tauri/src/storage/secure.rs` (new section modelled on the marketplace-token one)
|
||||
- Modify: `app/src-tauri/src/models/settings_export.rs` (`ExportedSecrets.aws_connection_keys: BTreeMap<String, String>`, `#[serde(default)]`)
|
||||
- Modify: `app/src-tauri/src/commands/settings_export_commands.rs` (export and restore, mirroring `exported_marketplace_tokens` / `restore_marketplace_tokens`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```rust
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] // Debug impl redacts the secret and token
|
||||
pub struct AwsKeys { pub access_key_id: String, pub secret_access_key: String, pub session_token: Option<String> }
|
||||
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> // service "triple-c-aws-connection-<id>", JSON value
|
||||
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String>
|
||||
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> // missing entry = Ok
|
||||
```
|
||||
`keyring_ref` in `AwsAuth::Keys` is the connection id. Ids are validated like `marketplace_token_service`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:**
|
||||
- `aws_keys_debug_redacts_secrets`: `format!("{:?}", keys)` contains neither the secret nor the token.
|
||||
- `aws_key_service_names_reject_bad_ids`: rejects `"../x"`, `""`, and a 65-char id.
|
||||
- `removing_a_keys_connection_with_no_keyring_entry_succeeds`: run against the existing mock-keyring test pattern in `secure.rs` if there is one; otherwise as a unit test on `delete_entry`'s `NoEntry` → `Ok` mapping.
|
||||
- `aws_connection_keys_round_trip_through_export`, in `settings_export_commands.rs` tests: a `Keys` connection's stored keys appear in `ExportedSecrets.aws_connection_keys` (as JSON), a `Profile` connection contributes nothing, and restore calls the store fn with the same id and keys.
|
||||
- [ ] **Step 2: Run** `cargo test aws_key` and `cargo test aws_connection_keys`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement.** On restore, a value that fails to parse as `AwsKeys` gives a warning naming the connection id, never the value.
|
||||
- [ ] **Step 4: Run** those tests, then all of `cargo test settings_export`. All pass.
|
||||
- [ ] **Step 5: Commit** `feat(aws): keyring storage for AWS access keys, carried by settings export`.
|
||||
|
||||
### Task 3: SDK config and identity (`aws::connection`, `aws::identity`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src-tauri/Cargo.toml` (add `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager` at the current versions matching `aws-config 1.13.x`, with the feature set above)
|
||||
- Create: `app/src-tauri/src/aws/mod.rs`, `app/src-tauri/src/aws/connection.rs`, `app/src-tauri/src/aws/identity.rs`, `app/src-tauri/src/aws/error.rs`
|
||||
- Modify: `app/src-tauri/src/lib.rs` (`mod aws;`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HostConnection`, `AwsAuth` (Task 1); `secure::get_aws_keys` (Task 2).
|
||||
- Produces:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AwsError {
|
||||
SsoExpired { profile: String }, // the UI offers "Sign in"
|
||||
ProfileNotFound { profile: String },
|
||||
KeysMissing, // keyring entry gone
|
||||
InvalidCredentials { message: String },
|
||||
AccessDenied { action: String, message: String },
|
||||
Other { message: String },
|
||||
}
|
||||
impl std::fmt::Display for AwsError
|
||||
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<aws_config::SdkConfig, AwsError>
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct CallerIdentity { pub account: String, pub arn: String, pub user_id: String /* owner id */ }
|
||||
pub async fn caller_identity(cfg: &aws_config::SdkConfig) -> Result<CallerIdentity, AwsError>
|
||||
pub enum PrincipalArn { Exact(String), AssumedRole { account: String, role_name: String } }
|
||||
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError>
|
||||
pub async fn resolve_principal(iam: &aws_sdk_iam::Client, p: PrincipalArn) -> Result<String, AwsError> // GetRole for the path; denied → AccessDenied{action:"iam:GetRole"}
|
||||
pub fn classify_sdk_error(action: &str, err: &(dyn std::error::Error + 'static)) -> AwsError
|
||||
```
|
||||
`sdk_config`:
|
||||
- `Profile` uses `ProfileFileCredentialsProvider::builder().profile_name(name)`, plus `.region(Region::new(region))`. It does not use the default chain.
|
||||
- `Keys` uses `Credentials::new(akid, secret, token, None, "triple-c-connection")`.
|
||||
- The config is built lazily, so profile problems surface on the first call and are classified there.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (pure, no network):
|
||||
- `iam_user_arn_is_used_as_is`: `arn:aws:iam::111122223333:user/alice` gives `Exact`.
|
||||
- `assumed_role_arn_maps_to_role_name`: `arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com` gives `AssumedRole{account:"111122223333", role_name:"AWSReservedSSO_Admin_abc"}`.
|
||||
- `root_and_federated_arns_are_rejected_for_simulation`: `arn:aws:iam::1:root` and `arn:aws:sts::1:federated-user/bob` give `Err(AwsError::Other{..})`, with a message saying simulation isn't possible for this principal (the caller then falls back to probes).
|
||||
- `assumed_role_arn_maps_to_role_arn_with_path`: `resolve_principal` against a fake IAM endpoint. Use an `aws_smithy_runtime` `StaticReplayClient`, or a local `tokio` HTTP listener as in `host/ssm/tests.rs` `a_dropped_open_still_ends_its_session`. The fake returns GetRole XML with `<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>` and `<Arn>`; expect that `Arn`.
|
||||
- `an_expired_sso_token_is_reported_as_sso_expired`: `classify_sdk_error` on an error whose display chain contains "The SSO session associated with this profile has expired" or "Token has expired" gives `SsoExpired`.
|
||||
- `a_missing_profile_is_reported_by_name`: `sdk_config` + `caller_identity` with `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` pointing at an empty temp file gives `ProfileNotFound{profile}`. Use the provider's `ProfileFileCredentialsProvider` profile-files builder to point at the temp files, not process env, so tests don't race.
|
||||
- [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement.** Keep `classify_sdk_error` substring-based on `DisplayErrorContext` text, plus the SDK error code (`AccessDenied`, `AccessDeniedException`, `ExpiredToken`, `InvalidClientTokenId`, `SignatureDoesNotMatch`).
|
||||
- [ ] **Step 4: Run** `cargo test aws::`. Passes. Run `cargo tree -i rustls@0.21` and confirm it is still absent: no legacy TLS stack.
|
||||
- [ ] **Step 5: Commit** `feat(aws): SDK config from a connection, caller identity, principal resolution`.
|
||||
|
||||
### Task 4: Shipped tier policies and the check table
|
||||
|
||||
**Files:**
|
||||
- Create: `aws/policies/workspace-user.json`, `aws/policies/environment-admin.json` (repo root)
|
||||
- Create: `aws/README.md` (short: what each file grants and how to attach it)
|
||||
- Create: `app/src-tauri/src/aws/policies.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")]
|
||||
pub enum Tier { EnvironmentAdmin, WorkspaceUser }
|
||||
pub fn policy_json(tier: Tier) -> &'static str // include_str!("../../../../aws/policies/<file>")
|
||||
pub struct Check {
|
||||
pub action: &'static str, // "ecs:RunTask"
|
||||
pub resource: &'static str, // template; {region} {account} substituted
|
||||
pub context: &'static [(&'static str, ContextValue)],// e.g. ("aws:RequestTag/triple-c:owner", OwnerId)
|
||||
pub write: bool,
|
||||
}
|
||||
pub enum ContextValue { OwnerId, Literal(&'static str) }
|
||||
pub fn checks(tier: Tier) -> &'static [Check]
|
||||
```
|
||||
|
||||
**Workspace-user policy.** These statements follow spec "Multi-user accounts":
|
||||
- `ecs:RunTask` on `arn:aws:ecs:*:*:task-definition/triple-c-*`, with `aws:RequestTag/triple-c:owner = ${aws:userid}`;
|
||||
- `ecs:TagResource` on create;
|
||||
- `iam:PassRole` on `arn:aws:iam::*:role/triple-c-*`, with `iam:ResourceTag/triple-c:owner = ${aws:userid}` and `iam:PassedToService = ecs-tasks.amazonaws.com`;
|
||||
- `ecs:StopTask` and `ecs:ExecuteCommand` on `arn:aws:ecs:*:*:task/triple-c-*/*`, with `aws:ResourceTag/triple-c:owner = ${aws:userid}`;
|
||||
- `ssm:StartSession` on `arn:aws:ecs:*:*:task/triple-c-*/*` (with the same tag) and on `arn:aws:ssm:*::document/AWS-StartPortForwardingSession`;
|
||||
- `ssm:TerminateSession` and `ssm:ResumeSession` on `arn:aws:ssm:*:*:session/*` with `ssm:resourceTag/aws:ssmmessages:session-id = ${aws:userid}` (the AWS-documented owner condition; session IDs are prefixed by the role session name, not `aws:userid`, so an ARN pattern can't scope them);
|
||||
- `secretsmanager:CreateSecret`, `TagResource`, `PutSecretValue` and `DeleteSecret` on `arn:aws:secretsmanager:*:*:secret:triple-c/*`, with the request tag (create) or resource tag (others);
|
||||
- `kms:Encrypt` and `kms:GenerateDataKey` with `kms:ViaService = secretsmanager.*.amazonaws.com`;
|
||||
- read-only: `ecs:Describe*`, `ecs:List*`, `ssm:GetParameter*` on `arn:aws:ssm:*:*:parameter/triple-c/*`, `cloudformation:DescribeStacks`, `cloudformation:ListStacks`, `secretsmanager:ListSecrets`, `elasticfilesystem:Describe*`, `logs:GetLogEvents` and `logs:FilterLogEvents` on `log-group:/triple-c/*`, and `sts:GetCallerIdentity`.
|
||||
|
||||
**Environment-admin policy:**
|
||||
- `cloudformation:*` on `arn:aws:cloudformation:*:*:stack/triple-c-*/*`;
|
||||
- `iam:CreateRole`, `DeleteRole`, `PutRolePolicy`, `DeleteRolePolicy`, `AttachRolePolicy`, `DetachRolePolicy`, `TagRole`, `GetRole` and `PassRole` on `role/triple-c-*`;
|
||||
- `ecs:CreateCluster`, `DeleteCluster`, `RegisterTaskDefinition`, `DeregisterTaskDefinition`, `PutClusterCapacityProviders`, `CreateCapacityProvider` and `DeleteCapacityProvider`;
|
||||
- `ec2:CreateSecurityGroup`, `DeleteSecurityGroup`, `AuthorizeSecurityGroup*`, `RevokeSecurityGroup*`, `CreateTags` and `Describe*`;
|
||||
- `elasticfilesystem:CreateFileSystem`, `DeleteFileSystem`, `CreateMountTarget`, `DeleteMountTarget`, `PutFileSystemPolicy` and `TagResource`;
|
||||
- `kms:CreateKey`, `ScheduleKeyDeletion`, `CreateAlias`, `DeleteAlias`, `PutKeyPolicy` and `TagResource`;
|
||||
- `logs:CreateLogGroup`, `DeleteLogGroup` and `PutRetentionPolicy` on `/triple-c/*`;
|
||||
- `ecr:CreateRepository`, `DeleteRepository` and `CreatePullThroughCacheRule`;
|
||||
- `ssm:PutParameter` and `DeleteParameter` on `parameter/triple-c/*`;
|
||||
- `iam:SimulatePrincipalPolicy` on `*`.
|
||||
|
||||
The check table holds one representative `Check` per action. Each resource template is a concrete name inside the pattern, for example `arn:aws:ecs:{region}:{account}:task-definition/triple-c-probe:1`. Wildcard actions like `ecs:Describe*` are checked as one concrete action, `ecs:DescribeClusters`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** in `policies.rs`:
|
||||
- `policies_are_valid_iam_json`: each file parses. `Version == "2012-10-17"`, and every statement has `Sid`, `Effect: "Allow"`, `Action` and `Resource`.
|
||||
- `every_policy_action_has_a_check_and_vice_versa`: the set of actions in the JSON (wildcard actions matched by glob against the check's action) equals the set covered by `checks(tier)`. This is the drift guard.
|
||||
- `every_check_resource_matches_a_statement_resource`: after substituting `{region}`=`us-west-2` and `{account}`=`111122223333`, each check's resource glob-matches one of the `Resource` patterns of a statement that contains its action.
|
||||
- `owner_scoped_actions_carry_the_owner_context`: `ecs:RunTask`, `ecs:StopTask`, `ecs:ExecuteCommand`, `ssm:StartSession` (task resource), `iam:PassRole` and `secretsmanager:CreateSecret` each have an `OwnerId` context entry with the key their statement's condition uses.
|
||||
- [ ] **Step 2: Run** `cargo test aws::policies`. Expected: FAIL.
|
||||
- [ ] **Step 3: Write the two JSON files and the table.** Write `aws/README.md`, and state there that statements are verified against a real account by Task 7's live test.
|
||||
- [ ] **Step 4: Run** `cargo test aws::policies`. Passes.
|
||||
- [ ] **Step 5: Commit** `feat(aws): shipped tier policies and the permission check table`.
|
||||
|
||||
### Task 5: The permission check
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src-tauri/src/aws/permissions.rs`, `app/src-tauri/src/aws/permissions/tests.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Tier`, `checks`, `Check` (Task 4); `CallerIdentity`, `PrincipalArn`, `resolve_principal`, `AwsError` (Task 3).
|
||||
- Produces:
|
||||
```rust
|
||||
#[async_trait::async_trait] // or native async fn in trait if the crate already avoids async_trait
|
||||
pub trait PermissionApi: Send + Sync {
|
||||
async fn resolve_principal(&self, caller_arn: &str) -> Result<String, AwsError>;
|
||||
async fn simulate(&self, principal: &str, action: &str, resource: &str, context: &[(String, String)]) -> Result<bool, AwsError>;
|
||||
async fn probe(&self, probe: Probe) -> Result<(), AwsError>;
|
||||
}
|
||||
pub enum Probe { EcsListClusters, SsmGetParametersByPath, CfnDescribeStacks, SecretsListSecrets }
|
||||
pub struct SdkPermissionApi { /* iam, ecs, ssm, cfn, secrets clients */ }
|
||||
impl SdkPermissionApi { pub fn new(cfg: &aws_config::SdkConfig) -> Self }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict { Allowed, Denied, Unverified }
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActionResult { pub action: String, pub verdict: Verdict }
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TierResult { pub tier: Tier, pub results: Vec<ActionResult> }
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
|
||||
pub enum Method { Simulated, Probed { reason: String } }
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
|
||||
pub enum Summary { EnvironmentAdmin, WorkspaceUser, Insufficient }
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PermissionReport {
|
||||
pub identity: CallerIdentity, pub region: String, pub method: Method,
|
||||
pub summary: Summary, pub tiers: Vec<TierResult>, // [WorkspaceUser, EnvironmentAdmin]
|
||||
}
|
||||
pub async fn check_permissions(api: &dyn PermissionApi, identity: &CallerIdentity, region: &str) -> PermissionReport
|
||||
```
|
||||
Rules:
|
||||
- **Simulated:** one `simulate` per check. Context values: `OwnerId` → `identity.user_id`, plus `aws:userid` → `identity.user_id` on every call. `EvalDecision == "allowed"` → Allowed, anything else → Denied.
|
||||
- **Fallback to Probed:** if `resolve_principal` or the *first* `simulate` returns `AccessDenied`, or `principal_for_simulation` errors, run all four probes.
|
||||
- A read check whose service has a probe gets that probe's result.
|
||||
- A read check with no probe, and every `write: true` check, is `Unverified`.
|
||||
- **Summary:** WorkspaceUser needs no `Denied` in the workspace tier. EnvironmentAdmin additionally needs no `Denied` in the admin tier. `Unverified` doesn't block a summary; the UI shows the method.
|
||||
- Simulations run with bounded concurrency (4).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** against a `FakeApi { allow: HashSet<&str>, simulate_denied: bool, get_role_denied: bool, probes_ok: HashSet<Probe> }`:
|
||||
- `a_full_workspace_policy_reports_workspace_user`.
|
||||
- `admin_and_workspace_policies_report_environment_admin`.
|
||||
- `a_missing_action_reports_insufficient_and_names_it`: drop `iam:PassRole`. The summary is `Insufficient`, and the workspace tier lists `iam:PassRole` as `Denied`.
|
||||
- `owner_context_is_sent_with_owner_scoped_checks`: the fake records contexts. `ecs:RunTask` was simulated with `aws:RequestTag/triple-c:owner` = identity.user_id.
|
||||
- `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified`.
|
||||
- `a_denied_get_role_falls_back_to_probes`: the method is `Probed`, and its reason mentions `iam:GetRole`.
|
||||
- `a_failed_probe_marks_its_reads_denied`.
|
||||
- [ ] **Step 2: Run** `cargo test aws::permissions`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement** `check_permissions` and `SdkPermissionApi`.
|
||||
- `SimulatePrincipalPolicy`: set `policy_source_arn`, `action_names` = [action], `resource_arns` = [resource], and `context_entries` with `ContextKeyTypeEnum::String`. Read `evaluation_results[0].eval_decision`.
|
||||
- Probes:
|
||||
- `ecs list_clusters` with `max_results(1)`;
|
||||
- `ssm get_parameters_by_path("/triple-c/environments")` with `max_results(1)`;
|
||||
- `cfn describe_stacks` with no name, first page only;
|
||||
- `secretsmanager list_secrets` with `max_results(1)`.
|
||||
- [ ] **Step 4: Run** `cargo test aws::`. All pass.
|
||||
- [ ] **Step 5: Commit** `feat(aws): tiered permission check with simulation and probe fallback`.
|
||||
|
||||
### Task 6: Tauri commands
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src-tauri/src/commands/host_commands.rs`
|
||||
- Modify: `app/src-tauri/src/commands/mod.rs`, `app/src-tauri/src/lib.rs` (`generate_handler!`), `app/src-tauri/capabilities/default.json` (or the census location CLAUDE.md names), and `app/src/lib/tauri-commands.ts`, plus the `PermissionReport`, `CallerIdentity` and `AwsError` TS types in `types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1–5.
|
||||
- Produces these commands. TS wrappers are camelCase with the same arguments.
|
||||
```rust
|
||||
add_aws_profile_connection(name: String, profile: String, default_region: String) -> Result<HostConnection, AwsError>
|
||||
add_aws_keys_connection(name: String, access_key_id: String, secret_access_key: String, session_token: Option<String>, default_region: String) -> Result<HostConnection, AwsError>
|
||||
update_aws_connection(id: String, name: String, default_region: String) -> Result<HostConnection, AwsError>
|
||||
remove_host_connection(id: String) -> Result<AppSettings, String>
|
||||
aws_connection_identity(id: String) -> Result<CallerIdentity, AwsError>
|
||||
aws_connection_sso_login(id: String) -> Result<(), String> // Profile only; wraps aws_commands::run_sso_login
|
||||
check_aws_permissions(id: String, region: Option<String>) -> Result<PermissionReport, AwsError> // None → default_region
|
||||
aws_policy_document(tier: Tier) -> String
|
||||
```
|
||||
- **Add (both kinds):** validate → call `GetCallerIdentity` with the new credentials → only on success store the keys and save settings. Return `AwsError`, and save nothing, if the identity call fails.
|
||||
- Exception: `SsoExpired` for a profile still saves. The UI then shows "Sign in".
|
||||
- **Keys:** if saving settings fails after storing the keys, delete the keyring entry.
|
||||
- **Remove:** delete the keyring entry first; a missing entry is fine. Then drop the connection from settings.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** for the pure parts, in `host_commands.rs` tests:
|
||||
- `adding_saves_nothing_when_identity_fails`: inject an identity fn returning `InvalidCredentials`. Afterwards there are no settings changes and no store call. Structure the commands as thin wrappers over `fn add_connection_with(…, identity: impl AsyncFn, store: impl Fn, settings: &SettingsStore)` so this is testable without AWS.
|
||||
- `an_sso_expired_profile_is_still_saved`.
|
||||
- `keys_are_removed_if_saving_settings_fails`.
|
||||
- `capabilities.test.ts` (existing) still passes with the new commands granted.
|
||||
- [ ] **Step 2: Run them.** Expected: FAIL.
|
||||
- [ ] **Step 3: Implement and register the commands.** Add the TS wrappers and types.
|
||||
- [ ] **Step 4: Run** `cargo test host_commands`, `cd app && npx vitest run src/test/capabilities.test.ts`, and `npx tsc --noEmit`. All pass.
|
||||
- [ ] **Step 5: Commit** `feat(aws): Tauri commands for AWS connections and permission checks`.
|
||||
|
||||
### Task 7: Settings → Hosts UI
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/components/settings/HostsSettings.tsx`, `app/src/components/settings/HostsSettings.test.tsx`
|
||||
- Modify: `app/src/components/settings/SettingsPanel.tsx` (new `<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>` after "Backends")
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the Task 6 TS wrappers and types; `listAwsProfiles` (existing); `useSettings().setAppSettings`.
|
||||
|
||||
Behaviour:
|
||||
- **List.** "Local Docker" is always first and can't be removed. Each AWS connection shows its name, `Profile: <name>` or `Access keys`, and its default region, with **Check**, **Edit** and **Remove** buttons.
|
||||
- **Add AWS connection form:**
|
||||
- name;
|
||||
- auth (segmented: Profile | Access keys). Profile is a dropdown from `listAwsProfiles()`; Access keys are three password-type inputs, the session token optional;
|
||||
- default region (a text input with a datalist of common regions).
|
||||
- Saving shows "Signed in as `<arn>`" on success, or the error. `sso_expired` shows a **Sign in** button that calls `awsConnectionSsoLogin` and then retries.
|
||||
- **Check.** Runs `checkAwsPermissions`, then shows:
|
||||
- the summary as a badge (Environment admin / Workspace user / Insufficient);
|
||||
- when the method is `probed`, a note: "Simulation not permitted (<reason>). Read access was probed; write actions are unverified and will be confirmed on first use.";
|
||||
- a collapsible per-tier list of the denied and unverified actions;
|
||||
- **Copy workspace-user policy** and **Copy admin policy** buttons (`awsPolicyDocument` → clipboard);
|
||||
- a one-line caveat that the simulation doesn't account for SCPs, permission boundaries or session policies.
|
||||
- **Remove** asks for confirmation first.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** in `HostsSettings.test.tsx`, mocking `@tauri-apps/api/core` `invoke` as the existing `MarketplaceSettings.test.tsx` does:
|
||||
- `shows Local Docker and existing AWS connections`.
|
||||
- `adds a profile connection and shows the signed-in identity`.
|
||||
- `offers Sign in when the profile's SSO session expired`.
|
||||
- `access key inputs are password fields and the session token is optional`.
|
||||
- `check shows Insufficient with the denied actions and the copy-policy buttons`.
|
||||
- `a probed check says writes are unverified`.
|
||||
- [ ] **Step 2: Run** `cd app && npx vitest run src/components/settings/HostsSettings.test.tsx`. Expected: FAIL.
|
||||
- [ ] **Step 3: Implement**, following `MarketplaceSettings.tsx` for structure and styling.
|
||||
- [ ] **Step 4: Run** `npx vitest run` (the whole suite) and `npx tsc --noEmit`. Both pass.
|
||||
- [ ] **Step 5: Commit** `feat(aws): Settings → Hosts with AWS connections and permission check`.
|
||||
|
||||
### Task 8: Live verification against a real account, and docs
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src-tauri/src/aws/live_tests.rs` (`#[ignore]`, env-gated like `host/ssm/live_tests.rs`)
|
||||
- Modify: `CLAUDE.md` (a new `aws/` module paragraph next to `host/`), `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` (mark phase 3 done and record the verification results)
|
||||
|
||||
The live tests need `TRIPLEC_AWS_E2E_PROFILE` (admin creds for a disposable account) and `TRIPLEC_AWS_E2E_REGION`. Each test creates IAM roles named `triple-c-e2e-<uuid>`, each assumable by the caller's account, with the policy under test inline, and deletes them in a guard on exit. IAM is free.
|
||||
|
||||
- [ ] **Step 1: Write the ignored tests:**
|
||||
- `a_role_with_the_workspace_policy_checks_as_workspace_user`: create the role with `workspace-user.json`, tag it `triple-c:owner=<simulated userid>`, and run `check_permissions` with an `SdkPermissionApi` whose principal is that role ARN. Expect `WorkspaceUser`, with no `Denied` in the workspace tier.
|
||||
- `a_role_with_both_policies_checks_as_environment_admin`.
|
||||
- `a_role_with_no_policy_checks_as_insufficient_and_lists_every_action`.
|
||||
- `a_role_without_simulate_permission_falls_back_to_probes`: assume the role (STS `AssumeRole`) and run the full `check_permissions` *as* it.
|
||||
- `the_owner_condition_denies_another_owners_task`: simulate `ecs:StopTask` with `aws:ResourceTag/triple-c:owner` = a different id, and expect Denied. This is the spec's "most important rule".
|
||||
- [ ] **Step 2: Run them** with fresh credentials from the user: `cargo test aws::live_tests -- --ignored --test-threads=1`. All pass. Fix the policy JSON if any statement doesn't evaluate as the spec intends, and re-run Task 4's tests.
|
||||
- [ ] **Step 3: Confirm no `triple-c-e2e-*` roles remain:** `aws iam list-roles --query "Roles[?starts_with(RoleName,'triple-c-e2e-')]"` returns `[]`.
|
||||
- [ ] **Step 4: Update the docs** and commit `test(aws): live IAM verification of the tier policies; docs`.
|
||||
@@ -137,8 +137,11 @@ How the check works:
|
||||
probes (`ecs:DescribeClusters`, `ssm:GetParameter`, `secretsmanager:ListSecrets` filtered by
|
||||
tag, `elasticfilesystem:DescribeAccessPoints`). Write actions are then marked **"unverified —
|
||||
will be confirmed on first use"**, not "OK".
|
||||
3. Be honest about the gaps: the simulation does not apply SCPs, permission boundaries or session
|
||||
policies. The first real create is the final check, and its error must name the denied action.
|
||||
3. Be honest about the gaps: the simulation does not apply SCPs or session policies (it does
|
||||
apply the principal's own permissions boundary — verified 2026-11-14). The first real create is
|
||||
the final check, and its error must name the denied action.
|
||||
4. A probed run never claims more than **Workspace user**: probes see reads, and the admin tier is
|
||||
nearly all writes.
|
||||
|
||||
The exact policies for each tier ship as files with the app (`aws/policies/*.json`). Settings
|
||||
links to them so an admin can grant them without reading this doc.
|
||||
@@ -395,8 +398,8 @@ user, and the account is already safe to share.
|
||||
|
||||
Strict tenancy's per-user `iam:PassRole` is scoped by a tag on the role
|
||||
(`iam:ResourceTag/triple-c:owner = ${aws:userid}`), not by role name, because `aws:userid`
|
||||
contains a `:`, which role names don't allow. **Verify** that `iam:PassRole` evaluates
|
||||
`iam:ResourceTag` as expected before release.
|
||||
contains a `:`, which role names don't allow. That `iam:PassRole` evaluates `iam:ResourceTag` this
|
||||
way is verified in the simulator (phase 3); confirm it on the first real `RunTask` (phase 5).
|
||||
|
||||
Why EC2 privileged mode needs dedicated capacity: a privileged container, or one with the Docker
|
||||
socket, has root on its EC2 host, and so access to every other tenant's task on that host
|
||||
@@ -565,6 +568,7 @@ aws/
|
||||
policies/
|
||||
environment-admin.json
|
||||
workspace-user.json
|
||||
permissions-boundary.json # every triple-c-* role carries it (phase 3)
|
||||
task-execution-role.json
|
||||
task-role-standard.json
|
||||
```
|
||||
@@ -576,7 +580,8 @@ aws/
|
||||
2. **Agent.** `triple-c-agent` in the image. Optionally use it locally too behind a flag, to
|
||||
harden it before AWS depends on it.
|
||||
3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered
|
||||
simulation and fallback probes, policy files.
|
||||
simulation and fallback probes, policy files. **Done 2026-11-14**; the policies were verified
|
||||
against real IAM (see "Phase 3: policy verification and rulings").
|
||||
4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM
|
||||
parameters + tagged stacks, including half-deployed and outdated ones), join or deploy,
|
||||
self-onboarding or an admin request, **strict tenancy**.
|
||||
@@ -621,6 +626,45 @@ aws/
|
||||
- That GHCR pull-through cache works for the public image.
|
||||
- That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push.
|
||||
|
||||
### Phase 3: policy verification and rulings (2026-11-14)
|
||||
|
||||
Rulings made while writing the tier policies, which phase 4 must build on:
|
||||
|
||||
- **Environment admins are fenced by a permissions boundary.** They create or change `triple-c-*`
|
||||
roles only with the `triple-c-permissions-boundary` managed policy as the boundary, and cannot
|
||||
remove, replace or edit it. An account administrator creates the boundary policy once.
|
||||
- **Destructive admin actions are tag-scoped.** Security groups, EFS file systems and KMS keys are
|
||||
created only with `triple-c:component`, and changed or deleted only if they carry it.
|
||||
- **A probed permission check caps at Workspace user** (above).
|
||||
- **Strict tenancy needs per-user execution roles**, not only task roles: a workspace user can pass
|
||||
only roles tagged with their own owner ID.
|
||||
- **Environment-level secrets carry `triple-c:component`**; workspace users can never write,
|
||||
delete or retag those, and an untagged `triple-c/*` secret could be claimed by any of them.
|
||||
- **User secrets use the environment KMS key, named by ARN** in `KmsKeyId`; any other key,
|
||||
including `aws/secretsmanager`, is explicitly denied.
|
||||
|
||||
`aws/README.md` ("Live verification") has the per-statement results, and
|
||||
`app/src-tauri/src/aws/live_tests.rs` repeats them. What reality changed:
|
||||
|
||||
- IAM refuses `${aws:PrincipalArn}` as a Resource, so self-inspection is `user/${aws:username}`
|
||||
for IAM users and, for role sessions (no role-name variable exists), SSO permission-set roles
|
||||
and `triple-c-*` roles only (ruling R18). Callers on other roles are probed and reported
|
||||
Insufficient; an admin can add their roles to `InspectOwnPrincipal`.
|
||||
- `ecs:ExecuteCommand` is also authorized against the cluster; the owner rule stays on the task.
|
||||
- Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` on a customer key.
|
||||
- `elasticfilesystem:CreateFileSystem` has no resource type (`Resource: "*"`).
|
||||
- The session client needs `ssmmessages:OpenDataChannel` on its own session (AWS docs).
|
||||
- The tier policies must be managed policies (both together exceed a role's inline budget); the
|
||||
workspace policy is close to the 6,144-character limit (about 6,110), and a unit test guards it.
|
||||
- `SimulatePrincipalPolicy` throttles within one check; the app retries adaptively.
|
||||
- The `aws/secretsmanager` Deny is load-bearing, but not for the reason first given: a bounded task
|
||||
role *can* decrypt such a secret, because that key's policy grants the caller directly.
|
||||
|
||||
Left for the phases with real ECS/EFS/EC2 resources: `RunTask`'s `aws:TagKeys` (and any
|
||||
`aws:ecs:*` or propagated tags), `iam:PassRole` with `iam:ResourceTag` on a real run, the owner
|
||||
tag on a real task for stop/exec/session, session ownership for an SSO-started session, EFS
|
||||
`CreateAction`/`CreateAccessPoint`/`ClientMount`, and `security-group-rule` authorization.
|
||||
|
||||
## Connection model
|
||||
|
||||
**Decided: C** (user, 2026-11-13). The comparison below is kept for the record.
|
||||
|
||||
Reference in new issue
Block a user