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, +{ + 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(what: &str, r: Result) { + 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(what: &str, r: Result) -> 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; +} diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 5b2acf1..2cc45ab 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -6,6 +6,8 @@ mod connection; mod error; mod identity; +#[cfg(test)] +mod live_tests; pub mod permissions; pub mod policies; diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs index 18d2f5c..52e9f81 100644 --- a/app/src-tauri/src/aws/permissions.rs +++ b/app/src-tauri/src/aws/permissions.rs @@ -13,7 +13,7 @@ use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalA use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier}; /// How many simulations are in flight at once. -const CONCURRENCY: usize = 4; +const CONCURRENCY: usize = 2; /// One `SimulatePrincipalPolicy` request, fully rendered. #[derive(Debug, Clone, PartialEq, Eq)] @@ -136,8 +136,15 @@ pub struct SdkPermissionApi { 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::new(cfg), + 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), diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs index 03dd9ce..182bb08 100644 --- a/app/src-tauri/src/aws/permissions/tests.rs +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -293,9 +293,10 @@ fn the_principal_placeholder_is_the_resolved_role() { .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:PrincipalArn").unwrap().values, - [ROLE_ARN] + context_value(get_role, "aws:username").unwrap().values, + [ROLE_ARN.rsplit('/').next().unwrap()] ); } diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index c763879..c95b504 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -73,16 +73,19 @@ pub struct Placeholders<'a> { /// `{account}` pub account: &'a str, /// `{principal}`: the caller's IAM user or role ARN (the simulation's - /// `PolicySourceArn`, which is what `${aws:PrincipalArn}` resolves to). + /// `PolicySourceArn`). `{principal_name}` is its last path segment: the + /// user or role name. pub principal: &'a str, } -/// Substitutes `{region}`, `{account}` and `{principal}` in a resource or -/// [`ContextValue::Literal`] template. +/// Substitutes `{region}`, `{account}`, `{principal}` and +/// `{principal_name}` in a resource or [`ContextValue::Literal`] template. pub fn render(template: &str, p: &Placeholders) -> String { + let name = p.principal.rsplit('/').next().unwrap_or(p.principal); template .replace("{region}", p.region) .replace("{account}", p.account) + .replace("{principal_name}", name) .replace("{principal}", p.principal) } @@ -102,8 +105,11 @@ const OWNER_RESOURCE_TAG: &str = "aws:ResourceTag/triple-c:owner"; /// The tag SSM puts on a session; its value is the starter's `aws:userid`. const SESSION_OWNER: &str = "ssm:resourceTag/aws:ssmmessages:session-id"; -/// `${aws:PrincipalArn}` in a Resource resolves from this context entry. -const OWN_PRINCIPAL: Context = &[("aws:PrincipalArn", Literal("{principal}"))]; +/// IAM refuses `${aws:PrincipalArn}` as a Resource (verified live), so a +/// user's own ARN is `user/${aws:username}` (or `user/*/${aws:username}` +/// with a path), resolved from this entry. A role cannot be named that way +/// (there is no role-name variable); the policies allow `role/*`. +const OWN_PRINCIPAL: Context = &[("aws:username", Literal("{principal_name}"))]; /// Role writes name the boundary; `${aws:PrincipalAccount}` resolves from /// the second entry. const WITH_BOUNDARY: Context = &[ @@ -193,6 +199,8 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ PROBE_TASK, &[(OWNER_RESOURCE_TAG, OwnerId)], ), + // ExecuteCommand is authorized against the cluster too (verified live). + write("ecs:ExecuteCommand", PROBE_CLUSTER, &[]), write( "ssm:StartSession", PROBE_TASK, @@ -214,6 +222,12 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ PROBE_SESSION, &[(SESSION_OWNER, OwnerId)], ), + // The session client opens its data channel on the session. + write( + "ssmmessages:OpenDataChannel", + PROBE_SESSION, + &[(SESSION_OWNER, OwnerId)], + ), // Encrypted with an environment key, never the default // `aws/secretsmanager` key (which can't carry `triple-c:component`). write( @@ -287,6 +301,19 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ (COMPONENT_RESOURCE_TAG, Literal("environment")), ], ), + // CreateSecret and PutSecretValue on a customer key also need Decrypt + // (verified live: without it, "Access to KMS is not allowed"). + write( + "kms:Decrypt", + PROBE_KEY, + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ), read("iam:GetRole", "{principal}", OWN_PRINCIPAL), read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL), read("ecs:DescribeClusters", PROBE_CLUSTER, &[]), @@ -368,9 +395,11 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ COMPONENT_TAGGED, ), read("ec2:DescribeSecurityGroups", "*", &[]), + // CreateFileSystem has no resource type: IAM authorizes it against `*` + // (verified live; a `file-system/*` Resource never matches). write( "elasticfilesystem:CreateFileSystem", - PROBE_FILE_SYSTEM, + "*", COMPONENT_REQUESTED, ), write( @@ -1045,9 +1074,11 @@ mod tests { } } - /// GetRole and SimulatePrincipalPolicy: the caller's own principal only. + /// GetRole and SimulatePrincipalPolicy: an IAM user only on itself. IAM + /// has no role-name variable and refuses `${aws:PrincipalArn}` as a + /// Resource (verified live), so a role caller may inspect any role. #[test] - fn principal_inspection_is_only_of_the_caller() { + fn principal_inspection_is_of_the_caller_or_of_roles() { for tier in TIERS { for action in ["iam:GetRole", "iam:SimulatePrincipalPolicy"] { let own = checks(tier) @@ -1055,18 +1086,82 @@ mod tests { .find(|c| c.action == action && c.resource == "{principal}") .unwrap_or_else(|| panic!("{tier:?}: no own-principal check for {action}")); assert!(allowed(tier, own, ME, ME)); - let other = Check { - resource: "arn:aws:iam::{account}:role/SomeoneElsesRole", + let as_alice: Context = &[("aws:username", Literal("alice"))]; + let at = |resource: &'static str| Check { + resource, + context: as_alice, ..*own }; - assert!( - !allowed(tier, &other, ME, ME), - "{tier:?}: {action} allowed on another principal" - ); + for mine in [ + "arn:aws:iam::{account}:user/alice", + "arn:aws:iam::{account}:user/team/alice", + "arn:aws:iam::{account}:role/SomeoneElsesRole", + ] { + assert!( + allowed(tier, &at(mine), ME, ME), + "{tier:?}: {action} on {mine}" + ); + } + for theirs in [ + "arn:aws:iam::{account}:user/bob", + "arn:aws:iam::{account}:user/team/xalice", + "arn:aws:iam::{account}:group/alice", + ] { + assert!( + !allowed(tier, &at(theirs), ME, ME), + "{tier:?}: {action} allowed on {theirs}" + ); + } } } } + /// IAM rejects a Resource that is not `*` or an ARN, even one that is a + /// policy variable resolving to an ARN (`${aws:PrincipalArn}`, verified + /// live: "Resource ${aws:PrincipalArn} must be in ARN format or \"*\""). + #[test] + fn every_resource_is_an_arn_or_star() { + let docs = [ + ("workspace-user", policy(Tier::WorkspaceUser)), + ("environment-admin", policy(Tier::EnvironmentAdmin)), + ( + "permissions-boundary", + serde_json::from_str(permissions_boundary_json()).unwrap(), + ), + ]; + for (name, doc) in &docs { + for s in statements_of(doc) { + for r in resources(&s) { + assert!( + r == "*" || r.starts_with("arn:"), + "{name} {}: Resource {r} is not an ARN", + sid(&s) + ); + } + } + } + } + + /// A customer managed policy may hold 6,144 characters, not counting + /// whitespace (verified live: the workspace policy is close). An + /// environment admin needs both tier policies, which together exceed a + /// role's 10,240-character inline budget, so they ship as managed + /// policies. + #[test] + fn every_policy_fits_a_managed_policy() { + for (name, text) in [ + ("workspace-user", policy_json(Tier::WorkspaceUser)), + ("environment-admin", policy_json(Tier::EnvironmentAdmin)), + ("permissions-boundary", permissions_boundary_json()), + ] { + let size = text.chars().filter(|c| !c.is_whitespace()).count(); + assert!( + size <= 6144, + "{name} is {size} characters without whitespace (max 6144)" + ); + } + } + const ROLE_WRITES: &[&str] = &[ "iam:CreateRole", "iam:PutRolePolicy", @@ -1199,7 +1294,7 @@ mod tests { const CREATES: &[(&str, &str)] = &[ ("kms:CreateKey", "*"), ("ec2:CreateSecurityGroup", PROBE_SECURITY_GROUP), - ("elasticfilesystem:CreateFileSystem", PROBE_FILE_SYSTEM), + ("elasticfilesystem:CreateFileSystem", "*"), ]; const DESTROYS: &[(&str, &str)] = &[ ("kms:ScheduleKeyDeletion", PROBE_KEY), diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx index b3560d4..3f39c88 100644 --- a/app/src/components/settings/HostsSettings.test.tsx +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -153,7 +153,7 @@ describe("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, permission boundaries/)).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" }); diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx index b4ee0eb..64befdc 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -177,7 +177,7 @@ function PermissionReportView({ report }: { report: PermissionReport }) {

)}

- 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.