From b527c82400305e9f69fe5f3edb8bf95a36beda04 Mon Sep 17 00:00:00 2001
From: Josh Knapp
Date: Sat, 14 Nov 2026 17:45:42 -0800
Subject: [PATCH] test(aws): live IAM verification of the tier policies; docs
Real IAM contradicted the policies in five places, now fixed:
- IAM refuses ${aws:PrincipalArn} as a Resource, so neither tier policy
could be attached. Self-inspection is user/${aws:username} for IAM users
and role/* for role sessions (no role-name variable exists).
- ecs:ExecuteCommand is authorized against the cluster as well as the task;
added an unconditioned cluster statement (the owner rule stays on the task).
- Secrets Manager needs kms:Decrypt with GenerateDataKey on a customer key.
- elasticfilesystem:CreateFileSystem has no resource type (Resource "*").
- ssmmessages:OpenDataChannel on the caller's own sessions (AWS docs).
The permission check now uses adaptive retries for IAM (SimulatePrincipalPolicy
throttled a full check), and the UI no longer says the simulator ignores
permission boundaries (it applies the principal's own; verified).
aws::live_tests (ignored, env-gated) repeats the verification with throwaway
triple-c-e2e-* roles, secrets and keys. New drift tests: every Resource is an
ARN or *, and every policy fits a 6,144-character managed policy.
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 21 +
app/src-tauri/Cargo.lock | 24 +
app/src-tauri/Cargo.toml | 3 +
app/src-tauri/src/aws/live_tests.rs | 958 ++++++++++++++++++
app/src-tauri/src/aws/mod.rs | 2 +
app/src-tauri/src/aws/permissions.rs | 11 +-
app/src-tauri/src/aws/permissions/tests.rs | 5 +-
app/src-tauri/src/aws/policies.rs | 125 ++-
.../settings/HostsSettings.test.tsx | 2 +-
app/src/components/settings/HostsSettings.tsx | 2 +-
aws/README.md | 142 +--
aws/policies/environment-admin.json | 6 +-
aws/policies/workspace-user.json | 22 +-
.../specs/2026-11-13-aws-ecs-host-design.md | 52 +-
14 files changed, 1286 insertions(+), 89 deletions(-)
create mode 100644 app/src-tauri/src/aws/live_tests.rs
diff --git a/CLAUDE.md b/CLAUDE.md
index 8d5cf63..cff62bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 8276b18..260bbfd 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -441,6 +441,29 @@ dependencies = [
"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"
@@ -7219,6 +7242,7 @@ dependencies = [
"aws-sdk-cloudformation",
"aws-sdk-ecs",
"aws-sdk-iam",
+ "aws-sdk-kms",
"aws-sdk-secretsmanager",
"aws-sdk-ssm",
"aws-sdk-sts",
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 2cbba1a..8a922ec 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -86,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 = [] }
diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs
new file mode 100644
index 0000000..9c5b92b
--- /dev/null
+++ b/app/src-tauri/src/aws/live_tests.rs
@@ -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-…` (secrets
+//! `triple-c/triple-c-e2e-/…`, 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,
+ policies: Vec,
+ secrets: Vec,
+ keys: Vec,
+}
+
+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-`: the prefix of everything this test creates.
+ prefix: String,
+ created: Mutex,
+}
+
+/// A role this test created.
+struct Role {
+ name: String,
+ arn: String,
+ /// `RoleId`: an assumed session's `aws:userid` is `:`.
+ 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(body: F)
+where
+ F: FnOnce(Arc) -> Fut,
+ Fut: Future
)}
- The simulation doesn't account for SCPs, permission boundaries or session policies.
+ The simulation doesn't account for SCPs or session policies.
);
diff --git a/aws/README.md b/aws/README.md
index 3ae6d19..f22f0e2 100644
--- a/aws/README.md
+++ b/aws/README.md
@@ -24,18 +24,29 @@ following:
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.
+ 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`, and the
- user can use KMS (`Encrypt`/`GenerateDataKey`) only through Secrets Manager and only with
- environment keys.
+ `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. A secret on such a
- key would be unreadable by the workspace's own task role, which is limited by the boundary.
+ 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
@@ -46,8 +57,12 @@ following:
`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.** They can read it (`iam:GetRole`) and simulate it
- (`iam:SimulatePrincipalPolicy`). The app's permission check uses both.
+- **Inspect principals.** `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). A role session may inspect **any role** in the
+ account: IAM refuses `${aws:PrincipalArn}` as a Resource and has no role-name variable, so the
+ caller's own role cannot be named. This exposes role metadata and simulated permissions, never
+ another user's resources.
- **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.
@@ -98,10 +113,12 @@ Triple-C's.
account-wide:
- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister
- *any* task definition revision in the account. Narrowing this to `task-definition/triple-c-*`
- depends on resource-level support, which is to be verified live.
-- `ecr:CreatePullThroughCacheRule` on `*`. The admin can add pull-through cache rules for any
- prefix. This cannot delete or overwrite anything.
+ *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).
@@ -143,9 +160,14 @@ this step; one option is to list it with the environment stack's prerequisites.
## Attaching the tier policies
-In the console, go to **IAM → Policies → Create policy → JSON**, paste the file, and attach the
-policy to a user, group or role. For IAM Identity Center, add it as an inline policy on the
-permission set.
+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,050, 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 (up to 32,768 characters) or
+reference the managed policies.
With the CLI:
@@ -186,48 +208,54 @@ aws iam attach-user-policy --user-name \
point to its owner's task role, or access points created by the onboarding stack rather than by
the user.
-## To verify live (Task 8)
+## Live verification (2026-11-14)
-The unit tests use a small IAM-style evaluator. These points need the real IAM policy simulator or
-a real account:
+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`:
-- **KMS key tags.** Does `CreateKey` with tags authorize `kms:TagResource` against the new,
- untagged key? If so, the tag rule on `kms:TagResource` denies it. Do **not** fix that by removing
- the condition from `TagResource`.
-- **Principal variables.** Check how `${aws:PrincipalAccount}` and `${aws:PrincipalArn}` resolve.
- For an SSO session, does the role ARN include the `/aws-reserved/sso.amazonaws.com//`
- path? Does the simulator substitute them in `Resource`?
-- **ECS Exec on the cluster.** `ecs:ExecuteCommand` may also be authorized on the cluster ARN.
-- **ECS tag keys.** On ECS resources, check `aws:ResourceTag` against `ecs:ResourceTag`.
-- **SSM sessions.** Check `ssm:StartSession` on an ECS task with `aws:ResourceTag`.
-- **Session ownership for SSO callers.** Check the
- `ssm:resourceTag/aws:ssmmessages:session-id` condition when the caller signed in through SSO.
-- **EC2 and EFS tagging.** Check the `ec2:CreateAction` and `elasticfilesystem:CreateAction`
- tag-on-create keys. Check whether `ec2:AuthorizeSecurityGroup*` and `RevokeSecurityGroup*` are
- also authorized against `security-group-rule` resources.
-- **Secrets tagging.** Check that `CreateSecret` with tags authorizes `secretsmanager:TagResource`,
- and that the `StringEqualsIfExists` and `Null` resource-tag conditions pass at create time.
-- **Secret keys.**
- - Check that `secretsmanager:KmsKeyId` carries the value exactly as the request gave it, so that
- `ArnLike arn:aws:kms:*:*:key/*` works.
- - Check that Secrets Manager calls `GenerateDataKey`/`Encrypt` as the caller, so the KMS
- statement's `aws:ResourceTag/triple-c:component` rule is what enforces the environment key.
- - Check that `PutSecretValue` on such a secret passes.
- - Check that a `CreateSecret` naming the AWS-managed `aws/secretsmanager` key's ARN is denied.
- KMS should leave `aws:ResourceTag/triple-c:component` absent for that key, so the
- `NeverEncryptSecretsWithOtherKeys` Deny fires even though the key policy allows the call.
-- **Tag keys.** Check that `aws:TagKeys` is populated for `RunTask`, for `CreateSecret` and secret
- `TagResource`, for `CreateAccessPoint`, and for the ECS and EFS tag-on-create `TagResource`
- calls. Check also whether ECS-managed (`aws:ecs:*`) or propagated task-definition tags appear in
- `RunTask`'s `aws:TagKeys` and would break the key allow-list.
-- **Resource tags the boundary and access points rely on.** Check `aws:ResourceTag` on:
- - `elasticfilesystem:ClientMount`/`ClientWrite` (the file system's tags, at mount);
- - `elasticfilesystem:CreateAccessPoint`, which is authorized against the file system;
- - `kms:Decrypt` through Secrets Manager (the key's tags).
-- **CloudFormation service roles.** Check that `cloudformation:RoleArn` is present in the request
- context when `RoleARN` is given.
-- **Task definition scoping.** Check whether `ecs:DeregisterTaskDefinition` and
- `ecr:CreatePullThroughCacheRule` support resource-level scoping, so they can be narrowed.
+```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
+```
-The simulator does not apply SCPs, permission boundaries or session policies, so the first real
-create is the final check.
+It creates `triple-c-e2e--*` roles and policies, `triple-c/triple-c-e2e-/*` secrets and
+`triple-c-e2e-` 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 `role/*` and `user/${aws:username}`, `user/*/${aws:username}` (see Inspect principals) |
+| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com//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:::task//`, 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.
diff --git a/aws/policies/environment-admin.json b/aws/policies/environment-admin.json
index 6caf883..0077d05 100644
--- a/aws/policies/environment-admin.json
+++ b/aws/policies/environment-admin.json
@@ -123,7 +123,9 @@
"iam:SimulatePrincipalPolicy"
],
"Resource": [
- "${aws:PrincipalArn}"
+ "arn:aws:iam::*:role/*",
+ "arn:aws:iam::*:user/${aws:username}",
+ "arn:aws:iam::*:user/*/${aws:username}"
]
},
{
@@ -235,7 +237,7 @@
"elasticfilesystem:CreateFileSystem"
],
"Resource": [
- "arn:aws:elasticfilesystem:*:*:file-system/*"
+ "*"
],
"Condition": {
"Null": {
diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json
index 9e5e6e7..58fd074 100644
--- a/aws/policies/workspace-user.json
+++ b/aws/policies/workspace-user.json
@@ -82,6 +82,16 @@
}
}
},
+ {
+ "Sid": "ExecOnTripleCClusters",
+ "Effect": "Allow",
+ "Action": [
+ "ecs:ExecuteCommand"
+ ],
+ "Resource": [
+ "arn:aws:ecs:*:*:cluster/triple-c-*"
+ ]
+ },
{
"Sid": "StartSessionsToOwnTasks",
"Effect": "Allow",
@@ -108,11 +118,12 @@
]
},
{
- "Sid": "EndOrResumeOwnSessions",
+ "Sid": "UseEndOrResumeOwnSessions",
"Effect": "Allow",
"Action": [
"ssm:TerminateSession",
- "ssm:ResumeSession"
+ "ssm:ResumeSession",
+ "ssmmessages:OpenDataChannel"
],
"Resource": [
"arn:aws:ssm:*:*:session/*"
@@ -278,7 +289,8 @@
"Effect": "Allow",
"Action": [
"kms:Encrypt",
- "kms:GenerateDataKey"
+ "kms:GenerateDataKey",
+ "kms:Decrypt"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
@@ -382,7 +394,9 @@
"iam:SimulatePrincipalPolicy"
],
"Resource": [
- "${aws:PrincipalArn}"
+ "arn:aws:iam::*:role/*",
+ "arn:aws:iam::*:user/${aws:username}",
+ "arn:aws:iam::*:user/*/${aws:username}"
]
},
{
diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md
index e19e04e..ee2776b 100644
--- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md
+++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md
@@ -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,43 @@ 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 **any role** for role sessions (no role-name variable exists).
+- `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, 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.