From 634d1f5a7ec147e7a02f53c5bead247e24c603e7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:43:35 -0800 Subject: [PATCH] feat(aws): tiered permission check with simulation and probe fallback check_permissions simulates every check of both tiers against the caller's resolved principal (bounded to 4 in flight), rendering resources and literal context values, sending OwnerId and aws:userid as the caller's user id, and grouping repeated context keys into one StringList entry. An unsimulatable principal, a denied iam:GetRole or a denied first simulation falls back to four read-only probes. Results are one row per action (worst verdict wins). The trait returns Send futures so the check is Send for Tauri commands. The module-wide dead_code/unused_imports allow is replaced by allows on the Task 6 entry points and the re-exports. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/connection.rs | 1 + app/src-tauri/src/aws/identity.rs | 1 + app/src-tauri/src/aws/mod.rs | 15 +- app/src-tauri/src/aws/permissions.rs | 434 ++++++++++++++++++ app/src-tauri/src/aws/permissions/tests.rs | 484 +++++++++++++++++++++ app/src-tauri/src/aws/policies.rs | 3 + 6 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 app/src-tauri/src/aws/permissions.rs create mode 100644 app/src-tauri/src/aws/permissions/tests.rs diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs index 2b5f466..a75b5eb 100644 --- a/app/src-tauri/src/aws/connection.rs +++ b/app/src-tauri/src/aws/connection.rs @@ -20,6 +20,7 @@ use crate::storage::secure; /// The SDK config for `conn` in `region`. Credentials are resolved lazily, /// so a missing profile or an expired SSO session surfaces on the first /// call and is classified there (`classify_sdk_error`). +#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result { sdk_config_with(conn, region, None).await } diff --git a/app/src-tauri/src/aws/identity.rs b/app/src-tauri/src/aws/identity.rs index 98b7fae..2860e66 100644 --- a/app/src-tauri/src/aws/identity.rs +++ b/app/src-tauri/src/aws/identity.rs @@ -17,6 +17,7 @@ pub struct CallerIdentity { /// `sts:GetCallerIdentity`, which needs no IAM permission: it is the /// connection test itself. +#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn caller_identity(cfg: &SdkConfig) -> Result { const ACTION: &str = "sts:GetCallerIdentity"; let out = aws_sdk_sts::Client::new(cfg) diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 02f47b4..3970a31 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -1,16 +1,23 @@ //! AWS connection plumbing: an SDK config built from a saved -//! `HostConnection`, the caller's identity, and error classification. - -// The permission check (Task 5) and the Tauri commands (Task 6) wire these in. -#![allow(dead_code, unused_imports)] +//! `HostConnection`, the caller's identity, the tier policies and the +//! permission check. +//! +//! Nothing outside this module calls in yet: the Tauri commands (Task 6) +//! will. Until then the entry points they call carry `#[allow(dead_code)]` +//! (which keeps everything they reach live) and the re-exports below carry +//! `unused_imports`; no blanket allow. mod connection; mod error; mod identity; +pub mod permissions; pub mod policies; +#[allow(unused_imports)] pub use connection::sdk_config; +#[allow(unused_imports)] pub use error::{classify_sdk_error, AwsError}; +#[allow(unused_imports)] pub use identity::{ caller_identity, principal_for_simulation, resolve_principal, CallerIdentity, PrincipalArn, }; diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs new file mode 100644 index 0000000..ac44880 --- /dev/null +++ b/app/src-tauri/src/aws/permissions.rs @@ -0,0 +1,434 @@ +//! The permission check: which Triple-C tier the connection's credentials +//! can act as, by simulating each tier's checks (`policies::checks`) with +//! IAM's policy simulator, or — when the caller may not simulate itself — +//! by a few read-only probes. + +use std::future::Future; + +use futures_util::stream::{self, StreamExt}; +use serde::Serialize; + +use super::error::{classify_sdk_error, AwsError}; +use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn}; +use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier}; + +/// How many simulations are in flight at once. +const CONCURRENCY: usize = 4; + +/// One `SimulatePrincipalPolicy` request, fully rendered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimulationRequest { + pub principal: String, + pub action: String, + pub resource: String, + pub context: Vec, +} + +/// A context key with every value it carries in the request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextEntry { + pub key: String, + pub values: Vec, +} + +impl ContextEntry { + /// Sent as `StringList` when the key has more than one value, `String` + /// otherwise. + pub fn is_list(&self) -> bool { + self.values.len() > 1 + } +} + +/// Builds the simulation for `check`: the resource and every literal context +/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` = +/// `user_id` added. A key appearing more than once becomes one entry holding +/// all its values (in order, without duplicates). +pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest { + let mut context: Vec = Vec::new(); + let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) { + Some(e) if e.values.contains(&value) => {} + Some(e) => e.values.push(value), + None => context.push(ContextEntry { + key: key.to_string(), + values: vec![value], + }), + }; + for (key, value) in check.context { + let value = match value { + ContextValue::OwnerId => user_id.to_string(), + ContextValue::Literal(template) => render(template, p), + }; + add(key, value); + } + add("aws:userid", user_id.to_string()); + SimulationRequest { + principal: p.principal.to_string(), + action: check.action.to_string(), + resource: render(check.resource, p), + context, + } +} + +/// The read-only calls used when simulation isn't possible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Probe { + EcsListClusters, + SsmGetParametersByPath, + CfnDescribeStacks, + SecretsListSecrets, +} + +impl Probe { + const ALL: [Probe; 4] = [ + Probe::EcsListClusters, + Probe::SsmGetParametersByPath, + Probe::CfnDescribeStacks, + Probe::SecretsListSecrets, + ]; + + /// The probe standing in for a read check's service, if there is one. + fn for_action(action: &str) -> Option { + match action.split_once(':')?.0 { + "ecs" => Some(Probe::EcsListClusters), + "ssm" => Some(Probe::SsmGetParametersByPath), + "cloudformation" => Some(Probe::CfnDescribeStacks), + "secretsmanager" => Some(Probe::SecretsListSecrets), + _ => None, + } + } + + fn action(self) -> &'static str { + match self { + Probe::EcsListClusters => "ecs:ListClusters", + Probe::SsmGetParametersByPath => "ssm:GetParametersByPath", + Probe::CfnDescribeStacks => "cloudformation:DescribeStacks", + Probe::SecretsListSecrets => "secretsmanager:ListSecrets", + } + } +} + +/// What the permission check needs from AWS. The methods return `Send` +/// futures so `check_permissions` is `Send` (a Tauri command needs that) for +/// any implementation; an impl may still write them as `async fn`. +pub trait PermissionApi: Send + Sync { + /// The ARN to simulate for the caller's principal (`iam:GetRole` for a + /// role session). + fn resolve_principal( + &self, + principal: PrincipalArn, + ) -> impl Future> + Send; + /// Whether the simulator says `allowed`. + fn simulate( + &self, + request: &SimulationRequest, + ) -> impl Future> + Send; + fn probe(&self, probe: Probe) -> impl Future> + Send; +} + +/// The real thing, from a connection's `SdkConfig`. +pub struct SdkPermissionApi { + iam: aws_sdk_iam::Client, + ecs: aws_sdk_ecs::Client, + ssm: aws_sdk_ssm::Client, + cfn: aws_sdk_cloudformation::Client, + secrets: aws_sdk_secretsmanager::Client, +} + +impl SdkPermissionApi { + #[allow(dead_code)] // Called by the Tauri commands (Task 6). + pub fn new(cfg: &aws_config::SdkConfig) -> Self { + SdkPermissionApi { + iam: aws_sdk_iam::Client::new(cfg), + ecs: aws_sdk_ecs::Client::new(cfg), + ssm: aws_sdk_ssm::Client::new(cfg), + cfn: aws_sdk_cloudformation::Client::new(cfg), + secrets: aws_sdk_secretsmanager::Client::new(cfg), + } + } +} + +impl PermissionApi for SdkPermissionApi { + async fn resolve_principal(&self, principal: PrincipalArn) -> Result { + identity::resolve_principal(&self.iam, principal).await + } + + async fn simulate(&self, request: &SimulationRequest) -> Result { + use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum}; + const ACTION: &str = "iam:SimulatePrincipalPolicy"; + let entries = request.context.iter().map(|e| { + SdkEntry::builder() + .context_key_name(&e.key) + .set_context_key_values(Some(e.values.clone())) + .context_key_type(if e.is_list() { + ContextKeyTypeEnum::StringList + } else { + ContextKeyTypeEnum::String + }) + .build() + }); + let out = self + .iam + .simulate_principal_policy() + .policy_source_arn(&request.principal) + .action_names(&request.action) + .resource_arns(&request.resource) + .set_context_entries(Some(entries.collect())) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let decision = out + .evaluation_results() + .first() + .map(|r| r.eval_decision().as_str()) + .ok_or_else(|| AwsError::Other { + message: format!("{ACTION} returned no result for {}", request.action), + })?; + Ok(decision == "allowed") + } + + async fn probe(&self, probe: Probe) -> Result<(), AwsError> { + let action = probe.action(); + let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e); + match probe { + Probe::EcsListClusters => self + .ecs + .list_clusters() + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::SsmGetParametersByPath => self + .ssm + .get_parameters_by_path() + .path("/triple-c/environments") + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::CfnDescribeStacks => self + .cfn + .describe_stacks() + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::SecretsListSecrets => self + .secrets + .list_secrets() + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Allowed, + Denied, + Unverified, +} + +/// One row per action: an action checked against several resources (or +/// with several contexts) is `Denied` if any of them is, else `Unverified` +/// if any is, else `Allowed`. +#[derive(Debug, Clone, Serialize)] +pub struct ActionResult { + pub action: String, + pub verdict: Verdict, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TierResult { + pub tier: Tier, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Method { + Simulated, + /// Simulation wasn't possible; `reason` says why. + Probed { + reason: String, + }, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Summary { + EnvironmentAdmin, + WorkspaceUser, + Insufficient, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PermissionReport { + pub identity: CallerIdentity, + pub region: String, + pub method: Method, + pub summary: Summary, + /// `[WorkspaceUser, EnvironmentAdmin]`. + pub tiers: Vec, +} + +const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin]; + +/// The worst of `verdicts`: Denied over Unverified over Allowed. +fn aggregate(verdicts: &[Verdict]) -> Verdict { + if verdicts.contains(&Verdict::Denied) { + Verdict::Denied + } else if verdicts.contains(&Verdict::Unverified) { + Verdict::Unverified + } else { + Verdict::Allowed + } +} + +/// Folds per-check verdicts (in `checks(tier)` order) into one row per +/// action, in first-appearance order. +fn tier_result(tier: Tier, verdicts: Vec) -> TierResult { + let mut rows: Vec<(&'static str, Vec)> = Vec::new(); + for (check, verdict) in checks(tier).iter().zip(verdicts) { + match rows.iter_mut().find(|(a, _)| *a == check.action) { + Some((_, vs)) => vs.push(verdict), + None => rows.push((check.action, vec![verdict])), + } + } + TierResult { + tier, + results: rows + .into_iter() + .map(|(action, vs)| ActionResult { + action: action.to_string(), + verdict: aggregate(&vs), + }) + .collect(), + } +} + +fn summary(tiers: &[TierResult]) -> Summary { + let clear = |t: Tier| { + tiers + .iter() + .filter(|r| r.tier == t) + .all(|r| r.results.iter().all(|a| a.verdict != Verdict::Denied)) + }; + match (clear(Tier::WorkspaceUser), clear(Tier::EnvironmentAdmin)) { + (true, true) => Summary::EnvironmentAdmin, + (true, false) => Summary::WorkspaceUser, + (false, _) => Summary::Insufficient, + } +} + +/// Which tiers the caller's credentials can act as, in `region`. +/// +/// Simulates every check of both tiers against the caller's principal. If +/// the principal can't be simulated (root, federated user), can't be +/// resolved (`iam:GetRole` denied), or the first simulation is denied, it +/// falls back to probes: a read check whose service has a probe takes that +/// probe's result, every other check is `Unverified`. +#[allow(dead_code)] // Called by the Tauri commands (Task 6). +pub async fn check_permissions( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> PermissionReport { + let (method, verdicts) = match simulate_all(api, identity, region).await { + Ok(verdicts) => (Method::Simulated, verdicts), + Err(reason) => (Method::Probed { reason }, probe_all(api).await), + }; + let tiers: Vec = TIERS + .into_iter() + .zip(verdicts) + .map(|(tier, vs)| tier_result(tier, vs)) + .collect(); + PermissionReport { + identity: identity.clone(), + region: region.to_string(), + method, + summary: summary(&tiers), + tiers, + } +} + +fn simulated_verdict(r: Result) -> Verdict { + match r { + Ok(true) => Verdict::Allowed, + Ok(false) => Verdict::Denied, + // A simulation that failed (throttled, network) says nothing about + // the permission itself. + Err(_) => Verdict::Unverified, + } +} + +/// Per-tier verdicts by simulation, or why simulation isn't possible. +async fn simulate_all( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> Result<[Vec; 2], String> { + let principal = principal_for_simulation(&identity.arn).map_err(|e| e.to_string())?; + let principal = api + .resolve_principal(principal) + .await + .map_err(|e| e.to_string())?; + let p = Placeholders { + region, + account: &identity.account, + principal: &principal, + }; + let requests: Vec = TIERS + .into_iter() + .flat_map(|t| checks(t).iter()) + .map(|c| simulation_request(c, &p, &identity.user_id)) + .collect(); + let mut requests = requests.into_iter(); + let Some(first) = requests.next() else { + return Ok([Vec::new(), Vec::new()]); + }; + // The first simulation alone: if the caller may not simulate at all, + // that's the moment to fall back, before spending the rest. + let first = match api.simulate(&first).await { + Err(e @ AwsError::AccessDenied { .. }) => return Err(e.to_string()), + r => simulated_verdict(r), + }; + // Owned requests: a closure over borrowed ones makes the future's + // `Send` unprovable (rustc's higher-ranked closure limitation). + let rest: Vec = stream::iter(requests) + .map(|r| async move { simulated_verdict(api.simulate(&r).await) }) + .buffered(CONCURRENCY) + .collect() + .await; + let mut all = std::iter::once(first).chain(rest); + Ok(TIERS.map(|t| all.by_ref().take(checks(t).len()).collect())) +} + +/// Per-tier verdicts from the four probes. +async fn probe_all(api: &A) -> [Vec; 2] { + let results = + futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) })) + .await; + let probe_verdict = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) { + Some((_, Ok(()))) => Verdict::Allowed, + Some((_, Err(AwsError::AccessDenied { .. }))) => Verdict::Denied, + _ => Verdict::Unverified, + }; + TIERS.map(|t| { + checks(t) + .iter() + .map(|c| match Probe::for_action(c.action) { + Some(probe) if !c.write => probe_verdict(probe), + _ => Verdict::Unverified, + }) + .collect() + }) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs new file mode 100644 index 0000000..6ffb668 --- /dev/null +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -0,0 +1,484 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use super::*; +use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier}; + +const USER_ID: &str = "AROAEXAMPLEID:alice@example.com"; +const ROLE_ARN: &str = + "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc"; + +fn identity() -> CallerIdentity { + CallerIdentity { + account: "111122223333".into(), + arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com" + .into(), + user_id: USER_ID.into(), + } +} + +const ALL_PROBES: [Probe; 4] = [ + Probe::EcsListClusters, + Probe::SsmGetParametersByPath, + Probe::CfnDescribeStacks, + Probe::SecretsListSecrets, +]; + +/// A simulation call as the fake saw it. +#[derive(Debug, Clone)] +struct Simulated { + principal: String, + action: String, + resource: String, + context: Vec, +} + +struct FakeApi { + allow: HashSet<&'static str>, + simulate_denied: bool, + get_role_denied: bool, + probes_ok: HashSet, + simulated: Mutex>, + probed: Mutex>, +} + +impl FakeApi { + fn allowing(tiers: &[Tier]) -> Self { + let allow = tiers + .iter() + .flat_map(|t| checks(*t).iter().map(|c| c.action)) + .collect(); + FakeApi { + allow, + simulate_denied: false, + get_role_denied: false, + probes_ok: ALL_PROBES.into_iter().collect(), + simulated: Mutex::new(Vec::new()), + probed: Mutex::new(Vec::new()), + } + } + + fn simulated(&self) -> Vec { + self.simulated.lock().unwrap().clone() + } +} + +impl PermissionApi for FakeApi { + async fn resolve_principal(&self, principal: PrincipalArn) -> Result { + if self.get_role_denied { + return Err(AwsError::AccessDenied { + action: "iam:GetRole".into(), + message: "not authorized to perform: iam:GetRole".into(), + }); + } + Ok(match principal { + PrincipalArn::Exact(arn) => arn, + PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(), + }) + } + + async fn simulate(&self, request: &SimulationRequest) -> Result { + self.simulated.lock().unwrap().push(Simulated { + principal: request.principal.clone(), + action: request.action.clone(), + resource: request.resource.clone(), + context: request.context.clone(), + }); + if self.simulate_denied { + return Err(AwsError::AccessDenied { + action: "iam:SimulatePrincipalPolicy".into(), + message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(), + }); + } + Ok(self.allow.contains(request.action.as_str())) + } + + async fn probe(&self, probe: Probe) -> Result<(), AwsError> { + self.probed.lock().unwrap().push(probe); + if self.probes_ok.contains(&probe) { + Ok(()) + } else { + Err(AwsError::AccessDenied { + action: format!("{probe:?}"), + message: "denied".into(), + }) + } + } +} + +fn run(api: &FakeApi) -> PermissionReport { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(api, &identity(), "eu-west-1")) +} + +fn tier(report: &PermissionReport, t: Tier) -> &TierResult { + report.tiers.iter().find(|r| r.tier == t).unwrap() +} + +fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict { + tier(report, t) + .results + .iter() + .find(|r| r.action == action) + .unwrap_or_else(|| panic!("{action} not in {t:?}")) + .verdict + .clone() +} + +#[test] +fn a_full_workspace_policy_reports_workspace_user() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + assert_eq!(report.summary, Summary::WorkspaceUser); + assert_eq!(report.region, "eu-west-1"); + assert_eq!(report.identity, identity()); + let tiers: Vec = report.tiers.iter().map(|t| t.tier).collect(); + assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + assert!(tier(&report, Tier::WorkspaceUser) + .results + .iter() + .all(|r| r.verdict == Verdict::Allowed)); + // Every check was simulated against the resolved role, not the session. + let sims = api.simulated(); + let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len(); + assert_eq!(sims.len(), total); + assert!(sims.iter().all(|s| s.principal == ROLE_ARN)); +} + +#[test] +fn admin_and_workspace_policies_report_environment_admin() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + assert_eq!(report.summary, Summary::EnvironmentAdmin); +} + +#[test] +fn a_missing_action_reports_insufficient_and_names_it() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.allow.remove("iam:PassRole"); + let report = run(&api); + assert_eq!(report.summary, Summary::Insufficient); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "iam:PassRole"), + Verdict::Denied + ); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"), + Verdict::Allowed + ); +} + +#[test] +fn an_admin_gap_still_reports_workspace_user() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.allow.remove("cloudformation:CreateStack"); + let report = run(&api); + assert_eq!(report.summary, Summary::WorkspaceUser); + assert_eq!( + verdict( + &report, + Tier::EnvironmentAdmin, + "cloudformation:CreateStack" + ), + Verdict::Denied + ); +} + +/// An action checked against several resources is one row; any denial wins. +#[test] +fn an_action_checked_twice_is_one_row_and_denied_if_either_is() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let report = run(&api); + let rows = tier(&report, Tier::WorkspaceUser) + .results + .iter() + .filter(|r| r.action == "ssm:StartSession") + .count(); + assert_eq!(rows, 1); + assert_eq!( + aggregate(&[Verdict::Allowed, Verdict::Denied, Verdict::Unverified]), + Verdict::Denied + ); + assert_eq!( + aggregate(&[Verdict::Allowed, Verdict::Unverified]), + Verdict::Unverified + ); + assert_eq!(aggregate(&[Verdict::Allowed]), Verdict::Allowed); +} + +fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> { + s.context.iter().find(|e| e.key == key) +} + +#[test] +fn owner_context_is_sent_with_owner_scoped_checks() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + run(&api); + let sims = api.simulated(); + let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap(); + let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap(); + assert_eq!(owner.values, [USER_ID]); + // Literals are rendered like resources. + let cluster = context_value(run_task, "ecs:cluster").unwrap(); + assert_eq!( + cluster.values, + ["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"] + ); + assert_eq!( + run_task.resource, + "arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1" + ); + // aws:userid on every simulation. + for s in &sims { + let userid = context_value(s, "aws:userid") + .unwrap_or_else(|| panic!("{} has no aws:userid", s.action)); + assert_eq!(userid.values, [USER_ID], "{}", s.action); + } +} + +#[test] +fn the_principal_placeholder_is_the_resolved_role() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + run(&api); + let sims = api.simulated(); + let get_role = sims + .iter() + .find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN) + .expect("iam:GetRole on the caller's own role"); + assert_eq!( + context_value(get_role, "aws:PrincipalArn").unwrap().values, + [ROLE_ARN] + ); +} + +#[test] +fn a_repeated_context_key_is_one_string_list_entry() { + static CTX: &[(&str, ContextValue)] = &[ + ("aws:TagKeys", ContextValue::Literal("triple-c:owner")), + ( + "ecs:cluster", + ContextValue::Literal("arn:{region}:{account}"), + ), + ("aws:TagKeys", ContextValue::Literal("triple-c:component")), + ("aws:TagKeys", ContextValue::OwnerId), + ]; + let check = Check { + action: "ecs:TagResource", + resource: "arn:aws:ecs:{region}:{account}:task/x", + context: CTX, + write: true, + }; + let p = Placeholders { + region: "eu-west-1", + account: "111122223333", + principal: ROLE_ARN, + }; + let req = simulation_request(&check, &p, USER_ID); + assert_eq!(req.principal, ROLE_ARN); + assert_eq!(req.action, "ecs:TagResource"); + assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x"); + assert_eq!( + req.context, + [ + ContextEntry { + key: "aws:TagKeys".into(), + values: vec![ + "triple-c:owner".into(), + "triple-c:component".into(), + USER_ID.into() + ], + }, + ContextEntry { + key: "ecs:cluster".into(), + values: vec!["arn:eu-west-1:111122223333".into()], + }, + ContextEntry { + key: "aws:userid".into(), + values: vec![USER_ID.into()], + }, + ] + ); + assert!(req.context[0].is_list()); + assert!(!req.context[1].is_list()); +} + +#[test] +fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() { + static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)]; + let check = Check { + action: "sts:GetCallerIdentity", + resource: "*", + context: CTX, + write: false, + }; + let p = Placeholders { + region: "r", + account: "a", + principal: "p", + }; + let req = simulation_request(&check, &p, USER_ID); + assert_eq!( + req.context, + [ContextEntry { + key: "aws:userid".into(), + values: vec![USER_ID.into()], + }] + ); +} + +#[test] +fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.simulate_denied = true; + let report = run(&api); + match &report.method { + Method::Probed { reason } => { + assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}") + } + m => panic!("expected Probed, got {m:?}"), + } + // Only the first simulation was tried. + assert_eq!(api.simulated().len(), 1); + let mut probed = api.probed.lock().unwrap().clone(); + probed.sort_by_key(|p| format!("{p:?}")); + let mut all = ALL_PROBES.to_vec(); + all.sort_by_key(|p| format!("{p:?}")); + assert_eq!(probed, all); + + let ws = Tier::WorkspaceUser; + assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified); + assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified); + // Reads with a probe get its result. + assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed); + assert_eq!( + verdict(&report, ws, "ecs:DescribeClusters"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "ssm:GetParametersByPath"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "cloudformation:DescribeStacks"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "secretsmanager:ListSecrets"), + Verdict::Allowed + ); + // Reads with no probe are unverified. + assert_eq!( + verdict(&report, ws, "logs:FilterLogEvents"), + Verdict::Unverified + ); + assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified); + assert_eq!( + verdict(&report, ws, "sts:GetCallerIdentity"), + Verdict::Unverified + ); + // Nothing denied: unverified doesn't block the summary. + assert_eq!(report.summary, Summary::EnvironmentAdmin); +} + +#[test] +fn a_denied_get_role_falls_back_to_probes() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.get_role_denied = true; + let report = run(&api); + match &report.method { + Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"), + m => panic!("expected Probed, got {m:?}"), + } + assert!(api.simulated().is_empty()); + assert_eq!(api.probed.lock().unwrap().len(), 4); +} + +#[test] +fn an_unsimulatable_principal_falls_back_to_probes() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let mut id = identity(); + id.arn = "arn:aws:iam::111122223333:root".into(); + let report = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(&api, &id, "eu-west-1")); + match &report.method { + Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"), + m => panic!("expected Probed, got {m:?}"), + } + assert!(api.simulated().is_empty()); +} + +#[test] +fn a_failed_probe_marks_its_reads_denied() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.simulate_denied = true; + api.probes_ok.remove(&Probe::EcsListClusters); + let report = run(&api); + let ws = Tier::WorkspaceUser; + assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied); + assert_eq!( + verdict(&report, ws, "ecs:DescribeClusters"), + Verdict::Denied + ); + assert_eq!( + verdict(&report, ws, "secretsmanager:ListSecrets"), + Verdict::Allowed + ); + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn a_probe_failing_for_another_reason_leaves_its_reads_unverified() { + struct Flaky(FakeApi); + impl PermissionApi for Flaky { + async fn resolve_principal(&self, p: PrincipalArn) -> Result { + self.0.resolve_principal(p).await + } + async fn simulate(&self, r: &SimulationRequest) -> Result { + self.0.simulate(r).await + } + async fn probe(&self, _: Probe) -> Result<(), AwsError> { + Err(AwsError::Other { + message: "connection reset".into(), + }) + } + } + let mut inner = FakeApi::allowing(&[Tier::WorkspaceUser]); + inner.simulate_denied = true; + let report = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(&Flaky(inner), &identity(), "eu-west-1")); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "ecs:ListClusters"), + Verdict::Unverified + ); +} + +#[test] +fn the_report_serializes_for_the_ui() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.get_role_denied = true; + let v = serde_json::to_value(run(&api)).unwrap(); + // Probed, every probe passes: nothing is denied. + assert_eq!(v["summary"], "environment_admin"); + assert_eq!(v["tiers"][0]["tier"], "workspace_user"); + assert!(v["method"]["probed"]["reason"].is_string(), "{v}"); + assert!(["allowed", "denied", "unverified"] + .contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap())); +} + +/// Tauri commands need a `Send` future. +#[allow(dead_code)] +fn check_permissions_is_send() { + fn assert_send(_: T) {} + fn with(api: &A, id: &CallerIdentity) { + assert_send(check_permissions(api, id, "r")); + } + let _ = with::; +} diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 7cb99d1..2203e2b 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -20,6 +20,7 @@ pub enum Tier { } /// The policy document an admin attaches for `tier`. +#[allow(dead_code)] // Only tests read the policy text so far. pub fn policy_json(tier: Tier) -> &'static str { match tier { Tier::EnvironmentAdmin => include_str!("../../../../aws/policies/environment-admin.json"), @@ -29,10 +30,12 @@ pub fn policy_json(tier: Tier) -> &'static str { /// The managed policy (no path) every role an environment admin creates must /// carry as its permissions boundary. +#[allow(dead_code)] // Only tests read the policy text so far. pub const PERMISSIONS_BOUNDARY_NAME: &str = "triple-c-permissions-boundary"; /// The document for [`PERMISSIONS_BOUNDARY_NAME`]: the most any /// Triple-C-created role may ever hold. +#[allow(dead_code)] // Only tests read the policy text so far. pub fn permissions_boundary_json() -> &'static str { include_str!("../../../../aws/policies/permissions-boundary.json") }