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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 17:45:42 -08:00
1 parent 01eab26a48
commit b527c82400
14 files changed
+1286 -89

No files matched your search

+21
View File
@@ -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
+24
View File
@@ -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",
+3
View File
@@ -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 = [] }
+958
View File
@@ -0,0 +1,958 @@
//! The tier policies against real IAM: `check_permissions` run against
//! throwaway roles that hold the shipped policies, and the few statements
//! whose behaviour only a real request shows (Secrets Manager's KMS calls,
//! the permissions boundary, the admin tier's role and key fences).
//!
//! Ignored; needs credentials for a **disposable** account that may create
//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets:
//!
//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials
//! files (read the way a saved connection reads it, so `AWS_*` key
//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and
//! `AWS_CONFIG_FILE` still choose the files);
//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`.
//!
//! ```sh
//! cd app/src-tauri
//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
//! cargo test aws::live_tests -- --ignored --test-threads=1
//! ```
//!
//! Every resource is named `triple-c-e2e-<id>…` (secrets
//! `triple-c/triple-c-e2e-<id>/…`, because the policies only cover
//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test
//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days).
//! IAM is free; a run costs a few cents at most (secrets for minutes, keys
//! pending deletion).
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use aws_config::SdkConfig;
use futures_util::FutureExt;
use super::error::{classify_sdk_error, AwsError};
use super::permissions::{
check_permissions, simulation_request, Method, PermissionApi, PermissionReport,
SdkPermissionApi, Summary, Verdict,
};
use super::policies::{
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
};
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::storage::secure::AwsKeys;
const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3");
const PATH: &str = "/triple-c-e2e/";
const SESSION: &str = "triple-c-e2e";
fn var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
}
/// What one test created, for the guard to delete.
#[derive(Default)]
struct Created {
roles: Vec<String>,
policies: Vec<String>,
secrets: Vec<String>,
keys: Vec<String>,
}
struct E2e {
cfg: SdkConfig,
iam: aws_sdk_iam::Client,
sts: aws_sdk_sts::Client,
secrets: aws_sdk_secretsmanager::Client,
kms: aws_sdk_kms::Client,
region: String,
account: String,
/// `triple-c-e2e-<random>`: the prefix of everything this test creates.
prefix: String,
created: Mutex<Created>,
}
/// A role this test created.
struct Role {
name: String,
arn: String,
/// `RoleId`: an assumed session's `aws:userid` is `<id>:<session>`.
id: String,
}
impl Role {
fn session_user_id(&self) -> String {
format!("{}:{SESSION}", self.id)
}
/// The identity `check_permissions` sees for the role itself (not a
/// session of it): an IAM role ARN, simulated as-is.
fn identity(&self, account: &str) -> CallerIdentity {
CallerIdentity {
account: account.to_string(),
arn: self.arn.clone(),
user_id: self.session_user_id(),
}
}
}
/// Runs `body` with a fresh fixture and deletes what it created afterwards,
/// whether or not `body` panicked.
async fn e2e<F, Fut>(body: F)
where
F: FnOnce(Arc<E2e>) -> Fut,
Fut: Future<Output = ()>,
{
let region = var("TRIPLEC_AWS_E2E_REGION");
let conn = HostConnection {
id: "e2e".into(),
name: "e2e".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile {
name: var("TRIPLEC_AWS_E2E_PROFILE"),
},
default_region: region.clone(),
},
};
let cfg = sdk_config(&conn, &region).await.expect("sdk config");
let me = caller_identity(&cfg)
.await
.expect("the e2e profile's identity");
let e = Arc::new(E2e {
iam: aws_sdk_iam::Client::new(&cfg),
sts: aws_sdk_sts::Client::new(&cfg),
secrets: aws_sdk_secretsmanager::Client::new(&cfg),
kms: aws_sdk_kms::Client::new(&cfg),
cfg,
region,
account: me.account,
prefix: format!(
"triple-c-e2e-{}",
&uuid::Uuid::new_v4().simple().to_string()[..12]
),
created: Mutex::default(),
});
let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await;
e.cleanup().await;
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag {
aws_sdk_iam::types::Tag::builder()
.key(k)
.value(v)
.build()
.unwrap()
}
fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag {
aws_sdk_secretsmanager::types::Tag::builder()
.key(k)
.value(v)
.build()
}
/// `Err(AccessDenied)` for a denied call; panics on anything else.
fn denied<T: std::fmt::Debug, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) {
match r {
Err(e) => match classify_sdk_error(what, &e) {
AwsError::AccessDenied { .. } => {}
other => panic!("{what}: expected AccessDenied, got {other}"),
},
Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"),
}
}
fn ok<T, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) -> T {
r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e)))
}
impl E2e {
fn name(&self, suffix: &str) -> String {
format!("{}-{suffix}", self.prefix)
}
fn secret_name(&self, suffix: &str) -> String {
format!("triple-c/{}/{suffix}", self.prefix)
}
/// A role assumable by this account, under `/triple-c-e2e/` (so its ARN
/// has a path, as an SSO role's does), optionally bounded.
async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role {
let name = self.name(suffix);
let trust = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
self.account
);
let mut req = self
.iam
.create_role()
.role_name(&name)
.path(PATH)
.assume_role_policy_document(trust)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.set_permissions_boundary(boundary.map(str::to_string));
for (k, v) in tags {
req = req.tags(iam_tag(k, v));
}
let out = ok("iam:CreateRole", req.send().await);
self.created.lock().unwrap().roles.push(name.clone());
let r = out.role().unwrap();
Role {
name,
arn: r.arn().to_string(),
id: r.role_id().to_string(),
}
}
async fn tag_role(&self, role: &Role, k: &str, v: &str) {
ok(
"iam:TagRole",
self.iam
.tag_role()
.role_name(&role.name)
.tags(iam_tag(k, v))
.send()
.await,
);
}
/// A customer managed policy (the tier policies are too big to hold
/// both inline on one role).
async fn managed_policy(&self, suffix: &str, document: &str) -> String {
let out = ok(
"iam:CreatePolicy",
self.iam
.create_policy()
.policy_name(self.name(suffix))
.policy_document(document)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
let arn = out.policy().unwrap().arn().unwrap().to_string();
self.created.lock().unwrap().policies.push(arn.clone());
arn
}
async fn attach(&self, role: &Role, policy_arn: &str) {
ok(
"iam:AttachRolePolicy",
self.iam
.attach_role_policy()
.role_name(&role.name)
.policy_arn(policy_arn)
.send()
.await,
);
}
async fn put_inline(&self, role: &Role, document: &str) {
ok(
"iam:PutRolePolicy",
self.iam
.put_role_policy()
.role_name(&role.name)
.policy_name("under-test")
.policy_document(document)
.send()
.await,
);
}
/// A role holding the given tier policies, tagged as owned by its own
/// sessions (the strict-tenancy task role shape `iam:PassRole` checks).
async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role {
let role = self.role(suffix, None, &[]).await;
self.tag_role(&role, "triple-c:owner", &role.session_user_id())
.await;
for &tier in tiers {
let name = match tier {
Tier::WorkspaceUser => "workspace",
Tier::EnvironmentAdmin => "admin",
};
let arn = self
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
.await;
self.attach(&role, &arn).await;
}
role
}
/// An SDK config for a session of `role`, once IAM lets it be assumed
/// and its policies have propagated.
async fn assume(&self, role: &Role) -> SdkConfig {
let mut last = None;
for _ in 0..20 {
match self
.sts
.assume_role()
.role_arn(&role.arn)
.role_session_name(SESSION)
.send()
.await
{
Ok(out) => {
let c = out.credentials().unwrap();
let keys = AwsKeys {
access_key_id: c.access_key_id().to_string(),
secret_access_key: c.secret_access_key().to_string(),
session_token: Some(c.session_token().to_string()),
};
// New sessions see a just-attached policy after a few
// seconds; denials in that window would be noise.
tokio::time::sleep(Duration::from_secs(10)).await;
return sdk_config_for_keys(&keys, &self.region);
}
Err(e) => {
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
}
panic!("could not assume {}: {last:?}", role.arn);
}
/// `check_permissions` as the e2e profile, simulating `identity`'s
/// principal. Retried once if a check failed (IAM is eventually
/// consistent for a role created a moment ago).
async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport {
let api = SdkPermissionApi::new(&self.cfg);
for attempt in 0..3 {
let report = check_permissions(&api, identity, &self.region)
.await
.expect("check_permissions");
let failed = report
.tiers
.iter()
.flat_map(|t| &t.results)
.any(|r| r.note.is_some());
if !failed || attempt == 2 {
return report;
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
unreachable!()
}
async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String {
let tag = |k: &str, v: &str| {
aws_sdk_kms::types::Tag::builder()
.tag_key(k)
.tag_value(v)
.build()
.unwrap()
};
let r = kms
.create_key()
.description(self.name(suffix))
.tags(tag("triple-c:component", "environment"))
.tags(tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await;
let arn = ok("kms:CreateKey", r)
.key_metadata()
.unwrap()
.arn()
.unwrap()
.to_string();
self.created.lock().unwrap().keys.push(arn.clone());
arn
}
fn record_secret(&self, arn: &str) {
self.created.lock().unwrap().secrets.push(arn.to_string());
}
/// An admin-created secret encrypted with `key`.
async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String {
let mut req = self
.secrets
.create_secret()
.name(self.secret_name(suffix))
.kms_key_id(key)
.secret_string("e2e")
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1));
if let Some((k, v)) = tag {
req = req.tags(sm_tag(k, v));
}
let arn = ok("secretsmanager:CreateSecret", req.send().await)
.arn()
.unwrap()
.to_string();
self.record_secret(&arn);
arn
}
/// Deletes everything recorded, logging (not failing on) errors so one
/// failure doesn't strand the rest.
async fn cleanup(&self) {
let created = std::mem::take(&mut *self.created.lock().unwrap());
let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}");
for arn in &created.secrets {
if let Err(e) = self
.secrets
.delete_secret()
.secret_id(arn)
.force_delete_without_recovery(true)
.send()
.await
{
let e = classify_sdk_error("secretsmanager:DeleteSecret", &e);
if !e.to_string().contains("ResourceNotFound") {
warn(arn, &e);
}
}
}
for arn in &created.keys {
if let Err(e) = self
.kms
.schedule_key_deletion()
.key_id(arn)
.pending_window_in_days(7)
.send()
.await
{
let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e);
if !e.to_string().contains("pending") {
warn(arn, &e);
}
}
}
for name in &created.roles {
if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await {
for p in out.policy_names() {
let _ = self
.iam
.delete_role_policy()
.role_name(name)
.policy_name(p)
.send()
.await;
}
}
if let Ok(out) = self
.iam
.list_attached_role_policies()
.role_name(name)
.send()
.await
{
for p in out.attached_policies() {
let _ = self
.iam
.detach_role_policy()
.role_name(name)
.policy_arn(p.policy_arn().unwrap_or_default())
.send()
.await;
}
}
if let Err(e) = self.iam.delete_role().role_name(name).send().await {
warn(name, &classify_sdk_error("iam:DeleteRole", &e));
}
}
for arn in &created.policies {
if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await {
warn(arn, &classify_sdk_error("iam:DeletePolicy", &e));
}
}
}
}
fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
report
.tiers
.iter()
.find(|t| t.tier == tier)
.unwrap()
.results
.iter()
.map(|r| (r.action.clone(), r.verdict.clone()))
.collect()
}
fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
verdicts(report, tier)
.into_iter()
.filter(|(_, v)| *v != Verdict::Allowed)
.collect()
}
#[tokio::test]
#[ignore]
async fn a_role_with_the_workspace_policy_checks_as_workspace_user() {
e2e(|e| async move {
let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(
not_allowed(&report, Tier::WorkspaceUser),
[],
"every workspace check must be allowed"
);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert!(
verdicts(&report, Tier::EnvironmentAdmin)
.iter()
.any(|(_, v)| *v == Verdict::Denied),
"the workspace policy alone grants admin actions"
);
})
.await;
}
#[tokio::test]
#[ignore]
async fn a_role_with_both_policies_checks_as_environment_admin() {
e2e(|e| async move {
let role = e
.tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
.await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []);
assert_eq!(report.summary, Summary::EnvironmentAdmin);
})
.await;
}
#[tokio::test]
#[ignore]
async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
e2e(|e| async move {
let role = e.role("none", None, &[]).await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::Insufficient);
for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] {
let rows = verdicts(&report, tier);
for c in checks(tier) {
assert!(
rows.iter().any(|(a, _)| a == c.action),
"{tier:?}: no row for {}",
c.action
);
}
for (action, v) in rows {
assert_eq!(v, Verdict::Denied, "{tier:?} {action}");
}
}
})
.await;
}
/// The workspace policy without `InspectOwnPrincipal`, assumed and checked
/// *as* itself: `iam:GetRole` is denied, so the check probes.
#[tokio::test]
#[ignore]
async fn a_role_without_simulate_permission_falls_back_to_probes() {
e2e(|e| async move {
let mut doc: serde_json::Value =
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
doc["Statement"]
.as_array_mut()
.unwrap()
.retain(|s| s["Sid"] != "InspectOwnPrincipal");
let role = e.role("nosim", None, &[]).await;
let arn = e.managed_policy("nosim", &doc.to_string()).await;
e.attach(&role, &arn).await;
let cfg = e.assume(&role).await;
let me = caller_identity(&cfg).await.expect("identity");
assert_eq!(me.user_id, role.session_user_id());
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
.await
.expect("check_permissions");
let Method::Probed { reason } = &report.method else {
panic!("expected probes, got {:?}", report.method);
};
assert!(reason.contains("iam:GetRole"), "{reason}");
let rows = verdicts(&report, Tier::WorkspaceUser);
let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone();
for probed in [
"ecs:ListClusters",
"ssm:GetParametersByPath",
"cloudformation:DescribeStacks",
"secretsmanager:ListSecrets",
] {
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
}
assert_eq!(verdict("iam:GetRole"), Verdict::Denied);
assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified);
// A probed run never claims the admin tier, and the denied
// own-principal check keeps it from claiming the workspace tier.
assert_eq!(report.summary, Summary::Insufficient);
})
.await;
}
/// The whole workspace policy, assumed: the real `iam:GetRole` on its own
/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself.
#[tokio::test]
#[ignore]
async fn an_assumed_workspace_role_checks_itself_by_simulation() {
e2e(|e| async move {
let role = e.tier_role("self", &[Tier::WorkspaceUser]).await;
let cfg = e.assume(&role).await;
let me = caller_identity(&cfg).await.expect("identity");
assert!(me.arn.contains(":assumed-role/"), "{}", me.arn);
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
.await
.expect("check_permissions");
assert_eq!(report.method, Method::Simulated);
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
assert_eq!(report.summary, Summary::WorkspaceUser);
})
.await;
}
/// The e2e profile itself (an account administrator): every check allowed.
/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/`
/// role path through `iam:GetRole`.
#[tokio::test]
#[ignore]
async fn the_e2e_profile_checks_as_environment_admin() {
e2e(|e| async move {
let me = caller_identity(&e.cfg).await.expect("identity");
let report = e.check_as(&me).await;
assert_eq!(report.method, Method::Simulated);
let failed: Vec<_> = report
.tiers
.iter()
.flat_map(|t| &t.results)
.filter(|r| r.verdict != Verdict::Allowed)
.collect();
assert!(failed.is_empty(), "{failed:#?}");
assert_eq!(report.summary, Summary::EnvironmentAdmin);
})
.await;
}
/// The spec's most important rule: no shell, stop or session in someone
/// else's task.
#[tokio::test]
#[ignore]
async fn the_owner_condition_denies_another_owners_task() {
e2e(|e| async move {
let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await;
let me = role.session_user_id();
let api = SdkPermissionApi::new(&e.cfg);
let p = Placeholders {
region: &e.region,
account: &e.account,
principal: &role.arn,
};
let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| {
["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action)
&& c.resource.contains(":task/")
});
let mut n = 0;
for c in task_checks {
n += 1;
let mine = simulation_request(c, &p, &me);
assert!(
api.simulate(&mine).await.unwrap(),
"{} on my own task",
c.action
);
let mut theirs = mine.clone();
for entry in &mut theirs.context {
if entry.key == "aws:ResourceTag/triple-c:owner" {
entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()];
}
}
assert!(
!api.simulate(&theirs).await.unwrap(),
"{} allowed on another owner's task",
c.action
);
theirs
.context
.retain(|x| x.key != "aws:ResourceTag/triple-c:owner");
assert!(
!api.simulate(&theirs).await.unwrap(),
"{} allowed on an untagged task",
c.action
);
}
assert_eq!(n, 3);
})
.await;
}
/// SimulatePrincipalPolicy *does* apply the principal's own permissions
/// boundary (it does not apply SCPs or session policies).
#[tokio::test]
#[ignore]
async fn the_simulator_applies_the_principals_permissions_boundary() {
e2e(|e| async move {
let boundary = e.managed_policy("boundary", permissions_boundary_json()).await;
let role = e.role("bounded", Some(&boundary), &[]).await;
e.put_inline(
&role,
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#,
)
.await;
let sim = |name: &str| {
e.iam
.simulate_principal_policy()
.policy_source_arn(&role.arn)
.action_names("secretsmanager:GetSecretValue")
.resource_arns(format!(
"arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf",
e.region, e.account
))
.send()
};
let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| {
out.evaluation_results()[0].eval_decision().as_str().to_string()
};
assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed");
assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny");
})
.await;
}
/// The secret statements and the KMS rules behind them, with real calls:
/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it),
/// the explicit Deny on the AWS-managed key, owner and component fences,
/// and the permissions boundary reading `aws:ResourceTag` of the key.
#[tokio::test]
#[ignore]
async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() {
e2e(|e| async move {
let key = e.environment_key(&e.kms, "env-key").await;
let aws_managed = ok(
"kms:DescribeKey",
e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await,
)
.key_metadata()
.unwrap()
.arn()
.unwrap()
.to_string();
let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await;
let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await;
let task = e.role("secrets-task", Some(&boundary), &[]).await;
e.put_inline(
&task,
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#,
)
.await;
let environment = e
.admin_secret("environment", &key, Some(("triple-c:component", "environment")))
.await;
let someone_elses = e
.admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob")))
.await;
let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await);
let me = user.session_user_id();
let create = |suffix: &str, key: Option<&str>| {
as_user
.create_secret()
.name(e.secret_name(suffix))
.set_kms_key_id(key.map(str::to_string))
.secret_string("v1")
.tags(sm_tag("triple-c:owner", &me))
.send()
};
// Own secret on the environment key, named by ARN.
let own = ok("create own secret", create("own", Some(&key)).await)
.arn()
.unwrap()
.to_string();
e.record_secret(&own);
ok(
"tag for cleanup",
e.secrets
.tag_resource()
.secret_id(&own)
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
ok(
"put own",
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
);
// Any other key is refused.
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
.into_iter()
.enumerate()
{
let r = create(&format!("refused-{i}"), k).await;
if let Ok(out) = &r {
e.record_secret(out.arn().unwrap());
}
denied(what, r);
}
// Only the ordinary tag keys, and never claiming or changing others'.
denied(
"tag own with triple-c:component",
as_user
.tag_resource()
.secret_id(&own)
.tags(sm_tag("triple-c:owner", &me))
.tags(sm_tag("triple-c:component", "x"))
.send()
.await,
);
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
denied(
&format!("put {what}"),
as_user.put_secret_value().secret_id(arn).secret_string("x").send().await,
);
denied(
&format!("delete {what}"),
as_user.delete_secret().secret_id(arn).send().await,
);
denied(
&format!("claim {what}"),
as_user
.tag_resource()
.secret_id(arn)
.tags(sm_tag("triple-c:owner", &me))
.send()
.await,
);
}
// A task role under the boundary decrypts through Secrets Manager
// only with the component-tagged key.
let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await);
let value = ok(
"task reads own",
as_task.get_secret_value().secret_id(&own).send().await,
);
assert_eq!(value.secret_string(), Some("v2"));
ok(
"delete own",
as_user
.delete_secret()
.secret_id(&own)
.force_delete_without_recovery(true)
.send()
.await,
);
})
.await;
}
/// The admin tier's fences, for real: roles only with the Triple-C
/// boundary, the boundary never removed or swapped or edited, and keys
/// created only with `triple-c:component` (tag-on-create passes the
/// `kms:TagResource` rule) and destroyed only if they carry it.
///
/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates
/// that policy and deletes it again, unless the account already has one, in
/// which case it uses it and leaves it alone.
#[tokio::test]
#[ignore]
async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
e2e(|e| async move {
let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account);
if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() {
ok(
"create the boundary",
e.iam
.create_policy()
.policy_name(PERMISSIONS_BOUNDARY_NAME)
.policy_document(permissions_boundary_json())
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
e.created.lock().unwrap().policies.push(boundary.clone());
}
let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await;
let admin = e
.tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
.await;
let cfg = e.assume(&admin).await;
let iam = aws_sdk_iam::Client::new(&cfg);
let kms = aws_sdk_kms::Client::new(&cfg);
let trust = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
e.account
);
let create = |suffix: &str, b: Option<&str>| {
iam.create_role()
.role_name(e.name(suffix))
.assume_role_policy_document(&trust)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.set_permissions_boundary(b.map(str::to_string))
.send()
};
for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] {
let r = create(suffix, b).await;
if r.is_ok() {
e.created.lock().unwrap().roles.push(e.name(suffix));
}
denied(&format!("create {suffix} role"), r);
}
let child = e.name("bounded");
ok("create bounded role", create("bounded", Some(&boundary)).await);
e.created.lock().unwrap().roles.push(child.clone());
ok(
"put a policy on the bounded role",
iam.put_role_policy()
.role_name(&child)
.policy_name("p")
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#)
.send()
.await,
);
denied(
"remove the boundary",
iam.delete_role_permissions_boundary().role_name(&child).send().await,
);
denied(
"swap the boundary",
iam.put_role_permissions_boundary()
.role_name(&child)
.permissions_boundary(&other_boundary)
.send()
.await,
);
denied(
"edit the boundary",
iam.create_policy_version()
.policy_arn(&boundary)
.policy_document(permissions_boundary_json())
.send()
.await,
);
// Keys: tagged create passes (tag-on-create authorizes
// kms:TagResource against the new key's requested tags).
let untagged = kms.create_key().description(e.name("untagged")).send().await;
if let Ok(out) = &untagged {
e.created
.lock()
.unwrap()
.keys
.push(out.key_metadata().unwrap().arn().unwrap().to_string());
}
denied("create an untagged key", untagged);
let key = e.environment_key(&kms, "admin-key").await;
ok(
"schedule the environment key's deletion",
kms.schedule_key_deletion()
.key_id(&key)
.pending_window_in_days(7)
.send()
.await,
);
})
.await;
}
+2
View File
@@ -6,6 +6,8 @@
mod connection;
mod error;
mod identity;
#[cfg(test)]
mod live_tests;
pub mod permissions;
pub mod policies;
+9 -2
View File
@@ -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),
+3 -2
View File
@@ -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()]
);
}
+110 -15
View File
@@ -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),
@@ -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" });
@@ -177,7 +177,7 @@ function PermissionReportView({ report }: { report: PermissionReport }) {
</p>
)}
<p className="text-xs text-[var(--text-secondary)] leading-snug">
The simulation doesn't account for SCPs, permission boundaries or session policies.
The simulation doesn't account for SCPs or session policies.
</p>
</div>
);
+85 -57
View File
@@ -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 <user> \
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/<region>/`
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=<admin profile of a disposable account> 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-<id>-*` roles and policies, `triple-c/triple-c-e2e-<id>/*` secrets and
`triple-c-e2e-<id>` KMS keys, all tagged `triple-c-test=phase3`, and deletes them when each test
ends (keys are scheduled for deletion, 7 days). One test creates the real
`triple-c-permissions-boundary` policy if the account has none, and deletes it again.
✓ verified as written · ✗→fixed: the policy changed · — could not verify, and why.
| Point | Result |
|---|---|
| `${aws:PrincipalArn}` as a Resource | ✗→fixed. IAM refuses it ("must be in ARN format or \"*\""), so neither tier policy could be attached at all. Now `role/*` and `user/${aws:username}`, `user/*/${aws:username}` (see Inspect principals) |
| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com/<region>/AWSReservedSSO_…`, and the check simulates that ARN. The SSO admin session checks as Environment admin, Simulated |
| `${aws:PrincipalAccount}` in `iam:PermissionsBoundary` | ✓ `CreateRole` with `triple-c-permissions-boundary` succeeds as the admin tier; without a boundary, or with another one, it is denied |
| Boundary fences | ✓ removing, replacing or editing the boundary is an explicit deny |
| Simulator and context | ✓ the simulator substitutes `${aws:userid}` and `${aws:username}` from context entries. It **does** apply the simulated principal's own permissions boundary (verified); it does not apply session policies. SCPs: not verifiable here |
| Simulator rate limit | ✗→fixed (app). `SimulatePrincipalPolicy` throttles ("Rate exceeded") within a full check; the check now uses adaptive retries and two requests at a time |
| KMS `CreateKey` with tags | ✓ allowed: tag-on-create passes the tag rule on `kms:TagResource`. Untagged `CreateKey`, and `CreateAlias`/`TagResource`/`ScheduleKeyDeletion` on an untagged key, are denied |
| ECS Exec on the cluster | ✗→fixed. `ecs:ExecuteCommand` is authorized against the cluster, then the task. Added `ExecOnTripleCClusters`; with only the cluster allowed, the call is denied on the task, so the owner rule still decides |
| ECS tag keys | ✓ (service reference) `aws:ResourceTag` is supported on tasks, clusters and task definitions. Live: — needs phase 4/5 ECS resources |
| SSM `StartSession` to a task | ✓ authorized against `arn:aws:ecs:<region>:<account>:task/<cluster>/<task-id>`, which `task/triple-c-*/*` matches. The tag condition on a real task: — needs phase 5 |
| SSO session ownership | ✓ (AWS docs) the `aws:ssmmessages:session-id` tag is the starter's `aws:userid` for users and role sessions alike. Live: — needs a session. Added `ssmmessages:OpenDataChannel` on own sessions, which AWS's current examples require |
| EC2 tag-on-create | ✓ `CreateSecurityGroup` (DryRun) is allowed with a `triple-c:component` tag and denied without one or with only other tags. `security-group-rule` resources on `AuthorizeSecurityGroup*`: — needs a security group |
| EFS | ✗→fixed: `CreateFileSystem` has no resource type, so `Resource` is now `*`. ✓ a tagged `CreateFileSystem`/`CreateAccessPoint` authorizes `elasticfilesystem:TagResource` first. `elasticfilesystem:CreateAction`, `CreateAccessPoint` against the file system's tags, `ClientMount`/`ClientWrite`: — need phase 6 file systems |
| Secrets tag-on-create | ✓ `CreateSecret` with tags authorizes `secretsmanager:TagResource` separately (refused without `TagOwnSecrets`), and that statement's `StringEqualsIfExists`/`Null` conditions pass at create |
| `secretsmanager:KmsKeyId` | ✓ carries the request's value: a key ARN passes, a bare key ID, an alias or no key is refused |
| Secrets Manager's KMS calls | ✓ made as the caller with `kms:ViaService`. ✗→fixed: `CreateSecret`/`PutSecretValue` on a customer key also need `kms:Decrypt`, which the policy lacked ("Access to KMS is not allowed"). `PutSecretValue` on an own secret now passes |
| `aws/secretsmanager` key | ✓ denied, and the Deny is what denies it: the same policy without `NeverEncryptSecretsWithOtherKeys` creates the secret |
| Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) |
| `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources |
| Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` |
| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) |
| Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed |
Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s
`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/
`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule
points above. The simulator does not apply session policies, so the first real create is the final
check.
+4 -2
View File
@@ -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": {
+18 -4
View File
@@ -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}"
]
},
{
@@ -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.