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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 16:43:35 -08:00
1 parent 7fdd5dc277
commit 634d1f5a7e
6 files changed
+934 -4

No files matched your search

+1
View File
@@ -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<SdkConfig, AwsError> {
sdk_config_with(conn, region, None).await
}
+1
View File
@@ -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<CallerIdentity, AwsError> {
const ACTION: &str = "sts:GetCallerIdentity";
let out = aws_sdk_sts::Client::new(cfg)
+11 -4
View File
@@ -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,
};
+434
View File
@@ -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<ContextEntry>,
}
/// 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<String>,
}
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<ContextEntry> = 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<Probe> {
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<Output = Result<String, AwsError>> + Send;
/// Whether the simulator says `allowed`.
fn simulate(
&self,
request: &SimulationRequest,
) -> impl Future<Output = Result<bool, AwsError>> + Send;
fn probe(&self, probe: Probe) -> impl Future<Output = Result<(), AwsError>> + 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<String, AwsError> {
identity::resolve_principal(&self.iam, principal).await
}
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
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<ActionResult>,
}
#[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<TierResult>,
}
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<Verdict>) -> TierResult {
let mut rows: Vec<(&'static str, Vec<Verdict>)> = 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<A: PermissionApi>(
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<TierResult> = 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<bool, AwsError>) -> 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<A: PermissionApi>(
api: &A,
identity: &CallerIdentity,
region: &str,
) -> Result<[Vec<Verdict>; 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<SimulationRequest> = 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<Verdict> = 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<A: PermissionApi>(api: &A) -> [Vec<Verdict>; 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;
+484
View File
@@ -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<ContextEntry>,
}
struct FakeApi {
allow: HashSet<&'static str>,
simulate_denied: bool,
get_role_denied: bool,
probes_ok: HashSet<Probe>,
simulated: Mutex<Vec<Simulated>>,
probed: Mutex<Vec<Probe>>,
}
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<Simulated> {
self.simulated.lock().unwrap().clone()
}
}
impl PermissionApi for FakeApi {
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
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<bool, AwsError> {
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<Tier> = 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<String, AwsError> {
self.0.resolve_principal(p).await
}
async fn simulate(&self, r: &SimulationRequest) -> Result<bool, AwsError> {
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: Send>(_: T) {}
fn with<A: PermissionApi>(api: &A, id: &CallerIdentity) {
assert_send(check_permissions(api, id, "r"));
}
let _ = with::<SdkPermissionApi>;
}
+3
View File
@@ -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")
}