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:
1 parent
7fdd5dc277
commit
634d1f5a7e
6 files changed
+934
-4
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user