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:
1 parent
01eab26a48
commit
b527c82400
14 files changed
+1286
-89
No files matched your search
@@ -304,6 +304,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
|
||||
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
|
||||
(`DOCKER_ONLY`); shrink it, never grow it.
|
||||
- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs`
|
||||
builds a saved `HostConnection`'s `SdkConfig` **by hand, never with
|
||||
`aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider`
|
||||
for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's
|
||||
endpoint/service settings can't redirect or re-credential a connection's signed requests.
|
||||
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
|
||||
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
|
||||
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
|
||||
`policies.rs` embeds `aws/policies/*.json` (the two tier policies and the permissions boundary)
|
||||
and the table of checks the simulator is asked; `permissions.rs` runs it
|
||||
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
|
||||
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
|
||||
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
|
||||
evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped,
|
||||
the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character
|
||||
managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`,
|
||||
admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored
|
||||
--test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles,
|
||||
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
|
||||
`aws/README.md` ("Live verification").
|
||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
//! The tier policies against real IAM: `check_permissions` run against
|
||||
//! throwaway roles that hold the shipped policies, and the few statements
|
||||
//! whose behaviour only a real request shows (Secrets Manager's KMS calls,
|
||||
//! the permissions boundary, the admin tier's role and key fences).
|
||||
//!
|
||||
//! Ignored; needs credentials for a **disposable** account that may create
|
||||
//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets:
|
||||
//!
|
||||
//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials
|
||||
//! files (read the way a saved connection reads it, so `AWS_*` key
|
||||
//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and
|
||||
//! `AWS_CONFIG_FILE` still choose the files);
|
||||
//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd app/src-tauri
|
||||
//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
|
||||
//! cargo test aws::live_tests -- --ignored --test-threads=1
|
||||
//! ```
|
||||
//!
|
||||
//! Every resource is named `triple-c-e2e-<id>…` (secrets
|
||||
//! `triple-c/triple-c-e2e-<id>/…`, because the policies only cover
|
||||
//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test
|
||||
//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days).
|
||||
//! IAM is free; a run costs a few cents at most (secrets for minutes, keys
|
||||
//! pending deletion).
|
||||
|
||||
use std::future::Future;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use futures_util::FutureExt;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::permissions::{
|
||||
check_permissions, simulation_request, Method, PermissionApi, PermissionReport,
|
||||
SdkPermissionApi, Summary, Verdict,
|
||||
};
|
||||
use super::policies::{
|
||||
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
|
||||
};
|
||||
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::storage::secure::AwsKeys;
|
||||
|
||||
const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3");
|
||||
const PATH: &str = "/triple-c-e2e/";
|
||||
const SESSION: &str = "triple-c-e2e";
|
||||
|
||||
fn var(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
|
||||
}
|
||||
|
||||
/// What one test created, for the guard to delete.
|
||||
#[derive(Default)]
|
||||
struct Created {
|
||||
roles: Vec<String>,
|
||||
policies: Vec<String>,
|
||||
secrets: Vec<String>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
struct E2e {
|
||||
cfg: SdkConfig,
|
||||
iam: aws_sdk_iam::Client,
|
||||
sts: aws_sdk_sts::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
kms: aws_sdk_kms::Client,
|
||||
region: String,
|
||||
account: String,
|
||||
/// `triple-c-e2e-<random>`: the prefix of everything this test creates.
|
||||
prefix: String,
|
||||
created: Mutex<Created>,
|
||||
}
|
||||
|
||||
/// A role this test created.
|
||||
struct Role {
|
||||
name: String,
|
||||
arn: String,
|
||||
/// `RoleId`: an assumed session's `aws:userid` is `<id>:<session>`.
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
fn session_user_id(&self) -> String {
|
||||
format!("{}:{SESSION}", self.id)
|
||||
}
|
||||
|
||||
/// The identity `check_permissions` sees for the role itself (not a
|
||||
/// session of it): an IAM role ARN, simulated as-is.
|
||||
fn identity(&self, account: &str) -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: account.to_string(),
|
||||
arn: self.arn.clone(),
|
||||
user_id: self.session_user_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` with a fresh fixture and deletes what it created afterwards,
|
||||
/// whether or not `body` panicked.
|
||||
async fn e2e<F, Fut>(body: F)
|
||||
where
|
||||
F: FnOnce(Arc<E2e>) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let region = var("TRIPLEC_AWS_E2E_REGION");
|
||||
let conn = HostConnection {
|
||||
id: "e2e".into(),
|
||||
name: "e2e".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile {
|
||||
name: var("TRIPLEC_AWS_E2E_PROFILE"),
|
||||
},
|
||||
default_region: region.clone(),
|
||||
},
|
||||
};
|
||||
let cfg = sdk_config(&conn, ®ion).await.expect("sdk config");
|
||||
let me = caller_identity(&cfg)
|
||||
.await
|
||||
.expect("the e2e profile's identity");
|
||||
let e = Arc::new(E2e {
|
||||
iam: aws_sdk_iam::Client::new(&cfg),
|
||||
sts: aws_sdk_sts::Client::new(&cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(&cfg),
|
||||
kms: aws_sdk_kms::Client::new(&cfg),
|
||||
cfg,
|
||||
region,
|
||||
account: me.account,
|
||||
prefix: format!(
|
||||
"triple-c-e2e-{}",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..12]
|
||||
),
|
||||
created: Mutex::default(),
|
||||
});
|
||||
let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await;
|
||||
e.cleanup().await;
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag {
|
||||
aws_sdk_iam::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag {
|
||||
aws_sdk_secretsmanager::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// `Err(AccessDenied)` for a denied call; panics on anything else.
|
||||
fn denied<T: std::fmt::Debug, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) {
|
||||
match r {
|
||||
Err(e) => match classify_sdk_error(what, &e) {
|
||||
AwsError::AccessDenied { .. } => {}
|
||||
other => panic!("{what}: expected AccessDenied, got {other}"),
|
||||
},
|
||||
Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok<T, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) -> T {
|
||||
r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e)))
|
||||
}
|
||||
|
||||
impl E2e {
|
||||
fn name(&self, suffix: &str) -> String {
|
||||
format!("{}-{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
fn secret_name(&self, suffix: &str) -> String {
|
||||
format!("triple-c/{}/{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
/// A role assumable by this account, under `/triple-c-e2e/` (so its ARN
|
||||
/// has a path, as an SSO role's does), optionally bounded.
|
||||
async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role {
|
||||
let name = self.name(suffix);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
self.account
|
||||
);
|
||||
let mut req = self
|
||||
.iam
|
||||
.create_role()
|
||||
.role_name(&name)
|
||||
.path(PATH)
|
||||
.assume_role_policy_document(trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(boundary.map(str::to_string));
|
||||
for (k, v) in tags {
|
||||
req = req.tags(iam_tag(k, v));
|
||||
}
|
||||
let out = ok("iam:CreateRole", req.send().await);
|
||||
self.created.lock().unwrap().roles.push(name.clone());
|
||||
let r = out.role().unwrap();
|
||||
Role {
|
||||
name,
|
||||
arn: r.arn().to_string(),
|
||||
id: r.role_id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tag_role(&self, role: &Role, k: &str, v: &str) {
|
||||
ok(
|
||||
"iam:TagRole",
|
||||
self.iam
|
||||
.tag_role()
|
||||
.role_name(&role.name)
|
||||
.tags(iam_tag(k, v))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A customer managed policy (the tier policies are too big to hold
|
||||
/// both inline on one role).
|
||||
async fn managed_policy(&self, suffix: &str, document: &str) -> String {
|
||||
let out = ok(
|
||||
"iam:CreatePolicy",
|
||||
self.iam
|
||||
.create_policy()
|
||||
.policy_name(self.name(suffix))
|
||||
.policy_document(document)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
let arn = out.policy().unwrap().arn().unwrap().to_string();
|
||||
self.created.lock().unwrap().policies.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
async fn attach(&self, role: &Role, policy_arn: &str) {
|
||||
ok(
|
||||
"iam:AttachRolePolicy",
|
||||
self.iam
|
||||
.attach_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_arn(policy_arn)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
async fn put_inline(&self, role: &Role, document: &str) {
|
||||
ok(
|
||||
"iam:PutRolePolicy",
|
||||
self.iam
|
||||
.put_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_name("under-test")
|
||||
.policy_document(document)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A role holding the given tier policies, tagged as owned by its own
|
||||
/// sessions (the strict-tenancy task role shape `iam:PassRole` checks).
|
||||
async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role {
|
||||
let role = self.role(suffix, None, &[]).await;
|
||||
self.tag_role(&role, "triple-c:owner", &role.session_user_id())
|
||||
.await;
|
||||
for &tier in tiers {
|
||||
let name = match tier {
|
||||
Tier::WorkspaceUser => "workspace",
|
||||
Tier::EnvironmentAdmin => "admin",
|
||||
};
|
||||
let arn = self
|
||||
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
|
||||
.await;
|
||||
self.attach(&role, &arn).await;
|
||||
}
|
||||
role
|
||||
}
|
||||
|
||||
/// An SDK config for a session of `role`, once IAM lets it be assumed
|
||||
/// and its policies have propagated.
|
||||
async fn assume(&self, role: &Role) -> SdkConfig {
|
||||
let mut last = None;
|
||||
for _ in 0..20 {
|
||||
match self
|
||||
.sts
|
||||
.assume_role()
|
||||
.role_arn(&role.arn)
|
||||
.role_session_name(SESSION)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => {
|
||||
let c = out.credentials().unwrap();
|
||||
let keys = AwsKeys {
|
||||
access_key_id: c.access_key_id().to_string(),
|
||||
secret_access_key: c.secret_access_key().to_string(),
|
||||
session_token: Some(c.session_token().to_string()),
|
||||
};
|
||||
// New sessions see a just-attached policy after a few
|
||||
// seconds; denials in that window would be noise.
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return sdk_config_for_keys(&keys, &self.region);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("could not assume {}: {last:?}", role.arn);
|
||||
}
|
||||
|
||||
/// `check_permissions` as the e2e profile, simulating `identity`'s
|
||||
/// principal. Retried once if a check failed (IAM is eventually
|
||||
/// consistent for a role created a moment ago).
|
||||
async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport {
|
||||
let api = SdkPermissionApi::new(&self.cfg);
|
||||
for attempt in 0..3 {
|
||||
let report = check_permissions(&api, identity, &self.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let failed = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.any(|r| r.note.is_some());
|
||||
if !failed || attempt == 2 {
|
||||
return report;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String {
|
||||
let tag = |k: &str, v: &str| {
|
||||
aws_sdk_kms::types::Tag::builder()
|
||||
.tag_key(k)
|
||||
.tag_value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let r = kms
|
||||
.create_key()
|
||||
.description(self.name(suffix))
|
||||
.tags(tag("triple-c:component", "environment"))
|
||||
.tags(tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await;
|
||||
let arn = ok("kms:CreateKey", r)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.created.lock().unwrap().keys.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
fn record_secret(&self, arn: &str) {
|
||||
self.created.lock().unwrap().secrets.push(arn.to_string());
|
||||
}
|
||||
|
||||
/// An admin-created secret encrypted with `key`.
|
||||
async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String {
|
||||
let mut req = self
|
||||
.secrets
|
||||
.create_secret()
|
||||
.name(self.secret_name(suffix))
|
||||
.kms_key_id(key)
|
||||
.secret_string("e2e")
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1));
|
||||
if let Some((k, v)) = tag {
|
||||
req = req.tags(sm_tag(k, v));
|
||||
}
|
||||
let arn = ok("secretsmanager:CreateSecret", req.send().await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.record_secret(&arn);
|
||||
arn
|
||||
}
|
||||
|
||||
/// Deletes everything recorded, logging (not failing on) errors so one
|
||||
/// failure doesn't strand the rest.
|
||||
async fn cleanup(&self) {
|
||||
let created = std::mem::take(&mut *self.created.lock().unwrap());
|
||||
let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}");
|
||||
for arn in &created.secrets {
|
||||
if let Err(e) = self
|
||||
.secrets
|
||||
.delete_secret()
|
||||
.secret_id(arn)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("secretsmanager:DeleteSecret", &e);
|
||||
if !e.to_string().contains("ResourceNotFound") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for arn in &created.keys {
|
||||
if let Err(e) = self
|
||||
.kms
|
||||
.schedule_key_deletion()
|
||||
.key_id(arn)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e);
|
||||
if !e.to_string().contains("pending") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &created.roles {
|
||||
if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await {
|
||||
for p in out.policy_names() {
|
||||
let _ = self
|
||||
.iam
|
||||
.delete_role_policy()
|
||||
.role_name(name)
|
||||
.policy_name(p)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Ok(out) = self
|
||||
.iam
|
||||
.list_attached_role_policies()
|
||||
.role_name(name)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
for p in out.attached_policies() {
|
||||
let _ = self
|
||||
.iam
|
||||
.detach_role_policy()
|
||||
.role_name(name)
|
||||
.policy_arn(p.policy_arn().unwrap_or_default())
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.iam.delete_role().role_name(name).send().await {
|
||||
warn(name, &classify_sdk_error("iam:DeleteRole", &e));
|
||||
}
|
||||
}
|
||||
for arn in &created.policies {
|
||||
if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await {
|
||||
warn(arn, &classify_sdk_error("iam:DeletePolicy", &e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
report
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|t| t.tier == tier)
|
||||
.unwrap()
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| (r.action.clone(), r.verdict.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
verdicts(report, tier)
|
||||
.into_iter()
|
||||
.filter(|(_, v)| *v != Verdict::Allowed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_the_workspace_policy_checks_as_workspace_user() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(
|
||||
not_allowed(&report, Tier::WorkspaceUser),
|
||||
[],
|
||||
"every workspace check must be allowed"
|
||||
);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert!(
|
||||
verdicts(&report, Tier::EnvironmentAdmin)
|
||||
.iter()
|
||||
.any(|(_, v)| *v == Verdict::Denied),
|
||||
"the workspace policy alone grants admin actions"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_both_policies_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let role = e
|
||||
.tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []);
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
|
||||
e2e(|e| async move {
|
||||
let role = e.role("none", None, &[]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] {
|
||||
let rows = verdicts(&report, tier);
|
||||
for c in checks(tier) {
|
||||
assert!(
|
||||
rows.iter().any(|(a, _)| a == c.action),
|
||||
"{tier:?}: no row for {}",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
for (action, v) in rows {
|
||||
assert_eq!(v, Verdict::Denied, "{tier:?} {action}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The workspace policy without `InspectOwnPrincipal`, assumed and checked
|
||||
/// *as* itself: `iam:GetRole` is denied, so the check probes.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_without_simulate_permission_falls_back_to_probes() {
|
||||
e2e(|e| async move {
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
|
||||
doc["Statement"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|s| s["Sid"] != "InspectOwnPrincipal");
|
||||
let role = e.role("nosim", None, &[]).await;
|
||||
let arn = e.managed_policy("nosim", &doc.to_string()).await;
|
||||
e.attach(&role, &arn).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert_eq!(me.user_id, role.session_user_id());
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let Method::Probed { reason } = &report.method else {
|
||||
panic!("expected probes, got {:?}", report.method);
|
||||
};
|
||||
assert!(reason.contains("iam:GetRole"), "{reason}");
|
||||
let rows = verdicts(&report, Tier::WorkspaceUser);
|
||||
let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone();
|
||||
for probed in [
|
||||
"ecs:ListClusters",
|
||||
"ssm:GetParametersByPath",
|
||||
"cloudformation:DescribeStacks",
|
||||
"secretsmanager:ListSecrets",
|
||||
] {
|
||||
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
|
||||
}
|
||||
assert_eq!(verdict("iam:GetRole"), Verdict::Denied);
|
||||
assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified);
|
||||
// A probed run never claims the admin tier, and the denied
|
||||
// own-principal check keeps it from claiming the workspace tier.
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The whole workspace policy, assumed: the real `iam:GetRole` on its own
|
||||
/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn an_assumed_workspace_role_checks_itself_by_simulation() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("self", &[Tier::WorkspaceUser]).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert!(me.arn.contains(":assumed-role/"), "{}", me.arn);
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The e2e profile itself (an account administrator): every check allowed.
|
||||
/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/`
|
||||
/// role path through `iam:GetRole`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_e2e_profile_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let me = caller_identity(&e.cfg).await.expect("identity");
|
||||
let report = e.check_as(&me).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let failed: Vec<_> = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.filter(|r| r.verdict != Verdict::Allowed)
|
||||
.collect();
|
||||
assert!(failed.is_empty(), "{failed:#?}");
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The spec's most important rule: no shell, stop or session in someone
|
||||
/// else's task.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_owner_condition_denies_another_owners_task() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await;
|
||||
let me = role.session_user_id();
|
||||
let api = SdkPermissionApi::new(&e.cfg);
|
||||
let p = Placeholders {
|
||||
region: &e.region,
|
||||
account: &e.account,
|
||||
principal: &role.arn,
|
||||
};
|
||||
let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| {
|
||||
["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action)
|
||||
&& c.resource.contains(":task/")
|
||||
});
|
||||
let mut n = 0;
|
||||
for c in task_checks {
|
||||
n += 1;
|
||||
let mine = simulation_request(c, &p, &me);
|
||||
assert!(
|
||||
api.simulate(&mine).await.unwrap(),
|
||||
"{} on my own task",
|
||||
c.action
|
||||
);
|
||||
let mut theirs = mine.clone();
|
||||
for entry in &mut theirs.context {
|
||||
if entry.key == "aws:ResourceTag/triple-c:owner" {
|
||||
entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()];
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on another owner's task",
|
||||
c.action
|
||||
);
|
||||
theirs
|
||||
.context
|
||||
.retain(|x| x.key != "aws:ResourceTag/triple-c:owner");
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on an untagged task",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
assert_eq!(n, 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// SimulatePrincipalPolicy *does* apply the principal's own permissions
|
||||
/// boundary (it does not apply SCPs or session policies).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_simulator_applies_the_principals_permissions_boundary() {
|
||||
e2e(|e| async move {
|
||||
let boundary = e.managed_policy("boundary", permissions_boundary_json()).await;
|
||||
let role = e.role("bounded", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&role,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let sim = |name: &str| {
|
||||
e.iam
|
||||
.simulate_principal_policy()
|
||||
.policy_source_arn(&role.arn)
|
||||
.action_names("secretsmanager:GetSecretValue")
|
||||
.resource_arns(format!(
|
||||
"arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf",
|
||||
e.region, e.account
|
||||
))
|
||||
.send()
|
||||
};
|
||||
let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| {
|
||||
out.evaluation_results()[0].eval_decision().as_str().to_string()
|
||||
};
|
||||
assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed");
|
||||
assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The secret statements and the KMS rules behind them, with real calls:
|
||||
/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it),
|
||||
/// the explicit Deny on the AWS-managed key, owner and component fences,
|
||||
/// and the permissions boundary reading `aws:ResourceTag` of the key.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() {
|
||||
e2e(|e| async move {
|
||||
let key = e.environment_key(&e.kms, "env-key").await;
|
||||
let aws_managed = ok(
|
||||
"kms:DescribeKey",
|
||||
e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await,
|
||||
)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await;
|
||||
let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await;
|
||||
let task = e.role("secrets-task", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&task,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let environment = e
|
||||
.admin_secret("environment", &key, Some(("triple-c:component", "environment")))
|
||||
.await;
|
||||
let someone_elses = e
|
||||
.admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob")))
|
||||
.await;
|
||||
|
||||
let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await);
|
||||
let me = user.session_user_id();
|
||||
let create = |suffix: &str, key: Option<&str>| {
|
||||
as_user
|
||||
.create_secret()
|
||||
.name(e.secret_name(suffix))
|
||||
.set_kms_key_id(key.map(str::to_string))
|
||||
.secret_string("v1")
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
};
|
||||
|
||||
// Own secret on the environment key, named by ARN.
|
||||
let own = ok("create own secret", create("own", Some(&key)).await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
e.record_secret(&own);
|
||||
ok(
|
||||
"tag for cleanup",
|
||||
e.secrets
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
ok(
|
||||
"put own",
|
||||
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
|
||||
);
|
||||
|
||||
// Any other key is refused.
|
||||
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let r = create(&format!("refused-{i}"), k).await;
|
||||
if let Ok(out) = &r {
|
||||
e.record_secret(out.arn().unwrap());
|
||||
}
|
||||
denied(what, r);
|
||||
}
|
||||
// Only the ordinary tag keys, and never claiming or changing others'.
|
||||
denied(
|
||||
"tag own with triple-c:component",
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.tags(sm_tag("triple-c:component", "x"))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
|
||||
denied(
|
||||
&format!("put {what}"),
|
||||
as_user.put_secret_value().secret_id(arn).secret_string("x").send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("delete {what}"),
|
||||
as_user.delete_secret().secret_id(arn).send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("claim {what}"),
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(arn)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
// A task role under the boundary decrypts through Secrets Manager
|
||||
// only with the component-tagged key.
|
||||
let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await);
|
||||
let value = ok(
|
||||
"task reads own",
|
||||
as_task.get_secret_value().secret_id(&own).send().await,
|
||||
);
|
||||
assert_eq!(value.secret_string(), Some("v2"));
|
||||
|
||||
ok(
|
||||
"delete own",
|
||||
as_user
|
||||
.delete_secret()
|
||||
.secret_id(&own)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The admin tier's fences, for real: roles only with the Triple-C
|
||||
/// boundary, the boundary never removed or swapped or edited, and keys
|
||||
/// created only with `triple-c:component` (tag-on-create passes the
|
||||
/// `kms:TagResource` rule) and destroyed only if they carry it.
|
||||
///
|
||||
/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates
|
||||
/// that policy and deletes it again, unless the account already has one, in
|
||||
/// which case it uses it and leaves it alone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
|
||||
e2e(|e| async move {
|
||||
let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account);
|
||||
if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() {
|
||||
ok(
|
||||
"create the boundary",
|
||||
e.iam
|
||||
.create_policy()
|
||||
.policy_name(PERMISSIONS_BOUNDARY_NAME)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
e.created.lock().unwrap().policies.push(boundary.clone());
|
||||
}
|
||||
let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await;
|
||||
let admin = e
|
||||
.tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let cfg = e.assume(&admin).await;
|
||||
let iam = aws_sdk_iam::Client::new(&cfg);
|
||||
let kms = aws_sdk_kms::Client::new(&cfg);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
e.account
|
||||
);
|
||||
let create = |suffix: &str, b: Option<&str>| {
|
||||
iam.create_role()
|
||||
.role_name(e.name(suffix))
|
||||
.assume_role_policy_document(&trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(b.map(str::to_string))
|
||||
.send()
|
||||
};
|
||||
for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] {
|
||||
let r = create(suffix, b).await;
|
||||
if r.is_ok() {
|
||||
e.created.lock().unwrap().roles.push(e.name(suffix));
|
||||
}
|
||||
denied(&format!("create {suffix} role"), r);
|
||||
}
|
||||
let child = e.name("bounded");
|
||||
ok("create bounded role", create("bounded", Some(&boundary)).await);
|
||||
e.created.lock().unwrap().roles.push(child.clone());
|
||||
ok(
|
||||
"put a policy on the bounded role",
|
||||
iam.put_role_policy()
|
||||
.role_name(&child)
|
||||
.policy_name("p")
|
||||
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"remove the boundary",
|
||||
iam.delete_role_permissions_boundary().role_name(&child).send().await,
|
||||
);
|
||||
denied(
|
||||
"swap the boundary",
|
||||
iam.put_role_permissions_boundary()
|
||||
.role_name(&child)
|
||||
.permissions_boundary(&other_boundary)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"edit the boundary",
|
||||
iam.create_policy_version()
|
||||
.policy_arn(&boundary)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
|
||||
// Keys: tagged create passes (tag-on-create authorizes
|
||||
// kms:TagResource against the new key's requested tags).
|
||||
let untagged = kms.create_key().description(e.name("untagged")).send().await;
|
||||
if let Ok(out) = &untagged {
|
||||
e.created
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys
|
||||
.push(out.key_metadata().unwrap().arn().unwrap().to_string());
|
||||
}
|
||||
denied("create an untagged key", untagged);
|
||||
let key = e.environment_key(&kms, "admin-key").await;
|
||||
ok(
|
||||
"schedule the environment key's deletion",
|
||||
kms.schedule_key_deletion()
|
||||
.key_id(&key)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
mod connection;
|
||||
mod error;
|
||||
mod identity;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
pub mod permissions;
|
||||
pub mod policies;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user