use std::collections::HashSet;
use fakecloud_core::auth::{Principal, PrincipalType};
use serde_json::Value;
use crate::condition::{CompiledCondition, ConditionContext};
use crate::state::IamState;
pub type RequestContext = ConditionContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
ImplicitDeny,
ExplicitDeny,
}
impl Decision {
pub fn is_allow(self) -> bool {
matches!(self, Decision::Allow)
}
}
#[derive(Debug, Clone)]
pub struct EvalRequest<'a> {
pub principal: &'a Principal,
pub action: String,
pub resource: String,
pub context: RequestContext,
}
#[derive(Debug, Clone)]
pub(crate) struct ParsedStatement {
pub effect: Effect,
pub action: ActionMatch,
pub resource: ResourceMatch,
pub condition: Option<CompiledCondition>,
pub principal: PrincipalPattern,
}
#[derive(Debug, Clone)]
pub(crate) enum PrincipalPattern {
None,
Principal(Vec<PrincipalRef>),
NotPrincipal(Vec<PrincipalRef>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PrincipalRef {
AnyAws,
AwsAccountRoot(String),
AwsArn(String),
Service(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Effect {
Allow,
Deny,
}
#[derive(Debug, Clone)]
pub(crate) enum ActionMatch {
Action(Vec<String>),
NotAction(Vec<String>),
}
#[derive(Debug, Clone)]
pub(crate) enum ResourceMatch {
Resource(Vec<String>),
NotResource(Vec<String>),
Implicit,
}
#[derive(Debug, Clone, Default)]
pub struct PolicyDocument {
pub(crate) statements: Vec<ParsedStatement>,
}
impl PolicyDocument {
pub fn parse(json: &str) -> Self {
let value: Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to parse policy document JSON; ignoring");
return Self::default();
}
};
Self::from_value(&value)
}
pub fn from_value(value: &Value) -> Self {
let statements = match value.get("Statement") {
Some(Value::Array(arr)) => arr.iter().filter_map(parse_statement).collect::<Vec<_>>(),
Some(obj @ Value::Object(_)) => parse_statement(obj).into_iter().collect(),
_ => Vec::new(),
};
Self { statements }
}
pub fn statement_count(&self) -> usize {
self.statements.len()
}
}
fn parse_statement(value: &Value) -> Option<ParsedStatement> {
let obj = value.as_object()?;
let effect = match obj.get("Effect")?.as_str()? {
"Allow" => Effect::Allow,
"Deny" => Effect::Deny,
other => {
tracing::warn!(effect = other, "unknown Effect; ignoring statement");
return None;
}
};
let action = if let Some(a) = obj.get("Action") {
ActionMatch::Action(coerce_string_list(a))
} else if let Some(na) = obj.get("NotAction") {
ActionMatch::NotAction(coerce_string_list(na))
} else {
tracing::warn!("statement has no Action or NotAction; ignoring");
return None;
};
let resource = if let Some(r) = obj.get("Resource") {
ResourceMatch::Resource(coerce_string_list(r))
} else if let Some(nr) = obj.get("NotResource") {
ResourceMatch::NotResource(coerce_string_list(nr))
} else {
ResourceMatch::Implicit
};
let condition = obj.get("Condition").map(CompiledCondition::parse);
let principal = if let Some(np) = obj.get("NotPrincipal") {
PrincipalPattern::NotPrincipal(parse_principal(np))
} else if let Some(p) = obj.get("Principal") {
PrincipalPattern::Principal(parse_principal(p))
} else {
PrincipalPattern::None
};
Some(ParsedStatement {
effect,
action,
resource,
condition,
principal,
})
}
fn parse_principal(value: &Value) -> Vec<PrincipalRef> {
let mut out = Vec::new();
match value {
Value::String(s) if s == "*" => out.push(PrincipalRef::AnyAws),
Value::String(other) => {
tracing::debug!(
target: "fakecloud::iam::audit",
principal = %other,
"Principal string other than \"*\" is not a recognized shape; skipping"
);
}
Value::Object(map) => {
for (key, v) in map {
match key.as_str() {
"AWS" => {
for s in coerce_string_list(v) {
out.push(classify_aws_principal(&s));
}
}
"Service" => {
for s in coerce_string_list(v) {
out.push(PrincipalRef::Service(s));
}
}
other => {
tracing::debug!(
target: "fakecloud::iam::audit",
principal_type = %other,
"Principal type not implemented in this rollout; skipping entry"
);
}
}
}
}
_ => {
tracing::debug!(
target: "fakecloud::iam::audit",
"Principal has an unexpected JSON shape; skipping"
);
}
}
out
}
fn classify_aws_principal(s: &str) -> PrincipalRef {
if s == "*" {
return PrincipalRef::AnyAws;
}
if let Some(rest) = s.strip_prefix("arn:aws:iam::") {
if let Some((account, tail)) = rest.split_once(':') {
if tail == "root" && !account.is_empty() {
return PrincipalRef::AwsAccountRoot(account.to_string());
}
}
}
if s.len() == 12 && s.chars().all(|c| c.is_ascii_digit()) {
return PrincipalRef::AwsAccountRoot(s.to_string());
}
PrincipalRef::AwsArn(s.to_string())
}
fn coerce_string_list(value: &Value) -> Vec<String> {
match value {
Value::String(s) => vec![s.clone()],
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
pub fn evaluate(policies: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
evaluate_with_gates(policies, None, None, request)
}
pub fn evaluate_with_gates(
identity: &[PolicyDocument],
boundary: Option<&[PolicyDocument]>,
session: Option<&[PolicyDocument]>,
request: &EvalRequest<'_>,
) -> Decision {
evaluate_with_gates_and_scps(identity, boundary, session, None, request)
}
pub fn evaluate_with_gates_and_scps(
identity: &[PolicyDocument],
boundary: Option<&[PolicyDocument]>,
session: Option<&[PolicyDocument]>,
scps: Option<&[PolicyDocument]>,
request: &EvalRequest<'_>,
) -> Decision {
let identity_decision = evaluate_inner(identity, request, false);
intersect_layers(identity_decision, boundary, session, scps, request)
}
fn intersect_layers(
identity_decision: Decision,
boundary: Option<&[PolicyDocument]>,
session: Option<&[PolicyDocument]>,
scps: Option<&[PolicyDocument]>,
request: &EvalRequest<'_>,
) -> Decision {
if matches!(identity_decision, Decision::ExplicitDeny) {
return Decision::ExplicitDeny;
}
let scp_decision = scps.map(|docs| evaluate_scp_chain(docs, request));
if matches!(scp_decision, Some(Decision::ExplicitDeny)) {
if let Some(scps_slice) = scps {
tracing::debug!(
target: "fakecloud::iam::audit",
action = %request.action,
principal_arn = %request.principal.arn,
scp_count = scps_slice.len(),
"SCP ceiling produced ExplicitDeny"
);
}
return Decision::ExplicitDeny;
}
let boundary_decision = boundary.map(|policies| evaluate_inner(policies, request, false));
if matches!(boundary_decision, Some(Decision::ExplicitDeny)) {
return Decision::ExplicitDeny;
}
let session_decision = session.map(|policies| evaluate_inner(policies, request, false));
if matches!(session_decision, Some(Decision::ExplicitDeny)) {
return Decision::ExplicitDeny;
}
let identity_allows = matches!(identity_decision, Decision::Allow);
let boundary_allows = boundary_decision
.map(|d| matches!(d, Decision::Allow))
.unwrap_or(true);
let session_allows = session_decision
.map(|d| matches!(d, Decision::Allow))
.unwrap_or(true);
let scp_allows = scp_decision
.map(|d| matches!(d, Decision::Allow))
.unwrap_or(true);
if identity_allows && boundary_allows && session_allows && scp_allows {
Decision::Allow
} else {
if scps.is_some() && !scp_allows {
tracing::debug!(
target: "fakecloud::iam::audit",
action = %request.action,
principal_arn = %request.principal.arn,
"SCP ceiling did not allow action; capped to ImplicitDeny"
);
}
Decision::ImplicitDeny
}
}
fn evaluate_scp_chain(scps: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
if scps.is_empty() {
return Decision::ImplicitDeny;
}
let mut all_allow = true;
for doc in scps {
match evaluate_inner(std::slice::from_ref(doc), request, false) {
Decision::ExplicitDeny => return Decision::ExplicitDeny,
Decision::Allow => {}
Decision::ImplicitDeny => all_allow = false,
}
}
if all_allow {
Decision::Allow
} else {
Decision::ImplicitDeny
}
}
pub fn evaluate_with_resource_policy(
identity_policies: &[PolicyDocument],
resource_policy: Option<&PolicyDocument>,
request: &EvalRequest<'_>,
resource_account_id: &str,
) -> Decision {
evaluate_with_resource_policy_and_gates(
identity_policies,
None,
None,
resource_policy,
request,
resource_account_id,
)
}
pub fn evaluate_with_resource_policy_and_gates(
identity_policies: &[PolicyDocument],
boundary: Option<&[PolicyDocument]>,
session: Option<&[PolicyDocument]>,
resource_policy: Option<&PolicyDocument>,
request: &EvalRequest<'_>,
resource_account_id: &str,
) -> Decision {
evaluate_with_resource_policy_and_gates_and_scps(
identity_policies,
boundary,
session,
None,
resource_policy,
request,
resource_account_id,
)
}
pub fn evaluate_with_resource_policy_and_gates_and_scps(
identity_policies: &[PolicyDocument],
boundary: Option<&[PolicyDocument]>,
session: Option<&[PolicyDocument]>,
scps: Option<&[PolicyDocument]>,
resource_policy: Option<&PolicyDocument>,
request: &EvalRequest<'_>,
resource_account_id: &str,
) -> Decision {
let identity_raw = evaluate_inner(identity_policies, request, false);
if matches!(identity_raw, Decision::ExplicitDeny) {
return Decision::ExplicitDeny;
}
let identity_gated = intersect_layers(identity_raw, boundary, session, scps, request);
if matches!(identity_gated, Decision::ExplicitDeny) {
return Decision::ExplicitDeny;
}
let same_account = request.principal.account_id == resource_account_id;
if resource_policy.is_none() && same_account {
return identity_gated;
}
let resource = match resource_policy {
Some(policy) => evaluate_inner(std::slice::from_ref(policy), request, true),
None => Decision::ImplicitDeny,
};
if matches!(resource, Decision::ExplicitDeny) {
return Decision::ExplicitDeny;
}
let identity_allows = matches!(identity_gated, Decision::Allow);
let resource_allows = matches!(resource, Decision::Allow);
let allowed = if same_account {
identity_allows || resource_allows
} else {
identity_allows && resource_allows
};
if allowed {
Decision::Allow
} else {
Decision::ImplicitDeny
}
}
fn evaluate_inner(
policies: &[PolicyDocument],
request: &EvalRequest<'_>,
is_resource_policy: bool,
) -> Decision {
let mut allowed = false;
for policy in policies {
for statement in &policy.statements {
match &statement.principal {
PrincipalPattern::None => {
if is_resource_policy {
tracing::debug!(
target: "fakecloud::iam::audit",
action = %request.action,
"resource policy statement has no Principal; skipping"
);
continue;
}
}
PrincipalPattern::Principal(refs) => {
if !principal_matches(refs, request.principal) {
continue;
}
}
PrincipalPattern::NotPrincipal(refs) => {
if refs.is_empty() {
tracing::debug!(
target: "fakecloud::iam::audit",
action = %request.action,
"NotPrincipal has no recognized principal types; statement does not apply"
);
continue;
}
if principal_matches(refs, request.principal) {
continue;
}
}
}
if !action_matches(&statement.action, &request.action) {
continue;
}
if !resource_matches(&statement.resource, &request.resource) {
continue;
}
if let Some(condition) = &statement.condition {
if !condition.matches(&request.context) {
tracing::debug!(
target: "fakecloud::iam::audit",
action = %request.action,
"condition did not match; statement does not apply"
);
continue;
}
}
match statement.effect {
Effect::Deny => return Decision::ExplicitDeny,
Effect::Allow => allowed = true,
}
}
}
if allowed {
Decision::Allow
} else {
Decision::ImplicitDeny
}
}
fn principal_matches(refs: &[PrincipalRef], principal: &Principal) -> bool {
refs.iter().any(|r| match r {
PrincipalRef::AnyAws => true,
PrincipalRef::AwsAccountRoot(account) => &principal.account_id == account,
PrincipalRef::AwsArn(arn) => &principal.arn == arn,
PrincipalRef::Service(service) => principal_is_service(principal, service),
})
}
fn principal_is_service(principal: &Principal, service: &str) -> bool {
matches!(principal.principal_type, PrincipalType::AssumedRole)
&& principal.arn.contains(service)
}
fn action_matches(action: &ActionMatch, request_action: &str) -> bool {
match action {
ActionMatch::Action(patterns) => patterns
.iter()
.any(|p| iam_glob_match(p, request_action, true)),
ActionMatch::NotAction(patterns) => patterns
.iter()
.all(|p| !iam_glob_match(p, request_action, true)),
}
}
fn resource_matches(resource: &ResourceMatch, request_resource: &str) -> bool {
match resource {
ResourceMatch::Resource(patterns) => patterns
.iter()
.any(|p| iam_glob_match(p, request_resource, false)),
ResourceMatch::NotResource(patterns) => patterns
.iter()
.all(|p| !iam_glob_match(p, request_resource, false)),
ResourceMatch::Implicit => true,
}
}
fn iam_glob_match(pattern: &str, value: &str, case_insensitive_service_prefix: bool) -> bool {
if case_insensitive_service_prefix {
if let (Some((p_svc, p_act)), Some((v_svc, v_act))) =
(pattern.split_once(':'), value.split_once(':'))
{
if !glob_match(&p_svc.to_ascii_lowercase(), &v_svc.to_ascii_lowercase()) {
return false;
}
return glob_match(p_act, v_act);
}
}
glob_match(pattern, value)
}
fn glob_match(pattern: &str, value: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let v: Vec<char> = value.chars().collect();
let mut pi = 0usize;
let mut vi = 0usize;
let mut star: Option<usize> = None;
let mut star_v: usize = 0;
while vi < v.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
pi += 1;
vi += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_v = vi;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
star_v += 1;
vi = star_v;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub fn collect_identity_policies(state: &IamState, principal: &Principal) -> Vec<PolicyDocument> {
let mut docs = Vec::new();
let mut seen_managed: HashSet<String> = HashSet::new();
match principal.principal_type {
PrincipalType::User => {
if let Some(user_name) = user_name_from_arn(&principal.arn) {
collect_user_policies(state, user_name, &mut docs, &mut seen_managed);
}
}
PrincipalType::AssumedRole => {
if let Some(role_name) = role_name_from_assumed_role_arn(&principal.arn) {
collect_role_policies(state, role_name, &mut docs, &mut seen_managed);
}
}
PrincipalType::Root => {
}
PrincipalType::FederatedUser | PrincipalType::Unknown => {
}
}
docs
}
fn collect_user_policies(
state: &IamState,
user_name: &str,
docs: &mut Vec<PolicyDocument>,
seen_managed: &mut HashSet<String>,
) {
if let Some(inline) = state.user_inline_policies.get(user_name) {
for doc in inline.values() {
docs.push(PolicyDocument::parse(doc));
}
}
if let Some(arns) = state.user_policies.get(user_name) {
for arn in arns {
if !seen_managed.insert(arn.clone()) {
continue;
}
if let Some(doc) = managed_policy_default_document(state, arn) {
docs.push(PolicyDocument::parse(&doc));
}
}
}
for (group_name, group) in &state.groups {
if !group.members.iter().any(|m| m == user_name) {
continue;
}
for doc in group.inline_policies.values() {
docs.push(PolicyDocument::parse(doc));
}
for arn in &group.attached_policies {
if !seen_managed.insert(arn.clone()) {
continue;
}
if let Some(doc) = managed_policy_default_document(state, arn) {
docs.push(PolicyDocument::parse(&doc));
}
}
let _ = group_name;
}
}
fn collect_role_policies(
state: &IamState,
role_name: &str,
docs: &mut Vec<PolicyDocument>,
seen_managed: &mut HashSet<String>,
) {
if let Some(inline) = state.role_inline_policies.get(role_name) {
for doc in inline.values() {
docs.push(PolicyDocument::parse(doc));
}
}
if let Some(arns) = state.role_policies.get(role_name) {
for arn in arns {
if !seen_managed.insert(arn.clone()) {
continue;
}
if let Some(doc) = managed_policy_default_document(state, arn) {
docs.push(PolicyDocument::parse(&doc));
}
}
}
}
pub fn collect_boundary_policies(
state: &IamState,
principal: &Principal,
) -> Option<Vec<PolicyDocument>> {
if principal.is_root() {
return None;
}
let boundary_arn = match principal.principal_type {
PrincipalType::User => {
let user_name = user_name_from_arn(&principal.arn)?;
let user = state.users.get(user_name)?;
user.permissions_boundary.clone()?
}
PrincipalType::AssumedRole => {
let role_name = role_name_from_assumed_role_arn(&principal.arn)?;
if role_name.starts_with("AWSServiceRoleFor") {
return None;
}
let role = state.roles.get(role_name)?;
role.permissions_boundary.clone()?
}
_ => return None,
};
match managed_policy_default_document(state, &boundary_arn) {
Some(doc) => Some(vec![PolicyDocument::parse(&doc)]),
None => {
tracing::debug!(
target: "fakecloud::iam::audit",
principal_arn = %principal.arn,
boundary_arn = %boundary_arn,
"permission boundary ARN does not resolve to a known managed policy; denying all actions"
);
Some(Vec::new())
}
}
}
fn managed_policy_default_document(state: &IamState, arn: &str) -> Option<String> {
let policy = state.policies.get(arn)?;
policy
.versions
.iter()
.find(|v| v.is_default)
.or_else(|| policy.versions.first())
.map(|v| v.document.clone())
}
fn user_name_from_arn(arn: &str) -> Option<&str> {
let after = arn.rsplit_once(":user/").map(|(_, name)| name)?;
Some(after.rsplit('/').next().unwrap_or(after))
}
fn role_name_from_assumed_role_arn(arn: &str) -> Option<&str> {
let after = arn.rsplit_once(":assumed-role/")?.1;
Some(after.split('/').next().unwrap_or(after))
}
#[cfg(test)]
#[allow(clippy::cloned_ref_to_slice_refs)]
mod tests {
use super::*;
use serde_json::json;
fn principal_user(arn: &str) -> Principal {
Principal {
arn: arn.to_string(),
user_id: "AIDA".into(),
account_id: "123456789012".into(),
principal_type: PrincipalType::User,
source_identity: None,
tags: None,
}
}
fn req<'a>(principal: &'a Principal, action: &str, resource: &str) -> EvalRequest<'a> {
EvalRequest {
principal,
action: action.to_string(),
resource: resource.to_string(),
context: RequestContext::default(),
}
}
fn doc(json: serde_json::Value) -> PolicyDocument {
PolicyDocument::from_value(&json)
}
#[test]
fn glob_literal_match() {
assert!(glob_match("foo", "foo"));
assert!(!glob_match("foo", "bar"));
}
#[test]
fn glob_star_matches_any() {
assert!(glob_match("*", "foo"));
assert!(glob_match("*", ""));
assert!(glob_match("foo*", "foobar"));
assert!(glob_match("*bar", "foobar"));
assert!(glob_match("f*r", "foobar"));
assert!(!glob_match("foo*", "fo"));
}
#[test]
fn glob_question_mark_matches_one() {
assert!(glob_match("f?o", "foo"));
assert!(!glob_match("f?o", "fo"));
assert!(!glob_match("f?o", "foo!"));
}
#[test]
fn glob_no_backtracking_explosion() {
assert!(!glob_match("a*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
}
#[test]
fn iam_action_service_prefix_is_case_insensitive() {
assert!(iam_glob_match("S3:GetObject", "s3:GetObject", true));
assert!(iam_glob_match("s3:GetObject", "S3:GetObject", true));
}
#[test]
fn iam_action_name_is_case_sensitive() {
assert!(!iam_glob_match("s3:getobject", "s3:GetObject", true));
assert!(iam_glob_match("s3:GetObject", "s3:GetObject", true));
}
#[test]
fn iam_action_supports_wildcards() {
assert!(iam_glob_match("s3:Get*", "s3:GetObject", true));
assert!(iam_glob_match("s3:*", "s3:DeleteObject", true));
assert!(iam_glob_match("*", "s3:GetObject", true));
assert!(!iam_glob_match("s3:Get*", "s3:PutObject", true));
}
#[test]
fn empty_policy_set_is_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
assert_eq!(
evaluate(&[], &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")),
Decision::ImplicitDeny
);
}
#[test]
fn allow_with_matching_action_and_resource() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/key"
}]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn deny_takes_precedence_over_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let allow = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}]
}));
let deny = doc(json!({
"Statement": [{
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "*"
}]
}));
assert_eq!(
evaluate(
&[allow.clone(), deny.clone()],
&req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
),
Decision::ExplicitDeny
);
assert_eq!(
evaluate(
&[deny, allow],
&req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
),
Decision::ExplicitDeny
);
}
#[test]
fn allow_with_wrong_action_is_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*"
}]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn allow_with_wrong_resource_is_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::other-bucket/*"
}]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn resource_wildcard_matches_arn_path() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/path/to/key")
),
Decision::Allow
);
}
#[test]
fn not_action_excludes_listed_actions() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"NotAction": "s3:DeleteObject",
"Resource": "*"
}]
}));
assert_eq!(
evaluate(
&[policy.clone()],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn not_resource_excludes_listed_resources() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"NotResource": "arn:aws:s3:::secret-bucket/*"
}]
}));
assert_eq!(
evaluate(
&[policy.clone()],
&req(&p, "s3:GetObject", "arn:aws:s3:::public-bucket/key")
),
Decision::Allow
);
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::secret-bucket/key")
),
Decision::ImplicitDeny
);
}
fn req_with_ctx<'a>(
principal: &'a Principal,
action: &str,
resource: &str,
context: RequestContext,
) -> EvalRequest<'a> {
EvalRequest {
principal,
action: action.to_string(),
resource: resource.to_string(),
context,
}
}
fn ctx_alice() -> RequestContext {
RequestContext {
aws_username: Some("alice".into()),
aws_principal_arn: Some("arn:aws:iam::123456789012:user/alice".into()),
aws_principal_account: Some("123456789012".into()),
aws_principal_type: Some("User".into()),
aws_userid: Some("AIDA".into()),
..Default::default()
}
}
#[test]
fn condition_string_equals_username_allows_match() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": { "StringEquals": { "aws:username": "alice" } }
}]
}));
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
),
Decision::Allow
);
}
#[test]
fn condition_string_equals_username_denies_mismatch() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": { "StringEquals": { "aws:username": "bob" } }
}]
}));
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
),
Decision::ImplicitDeny
);
}
#[test]
fn deny_with_condition_fires_when_condition_matches() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*"
}
]
}));
let mut ctx = ctx_alice();
ctx.aws_secure_transport = Some(false);
assert_eq!(
evaluate(
&[policy.clone()],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
),
Decision::ExplicitDeny
);
let mut ctx_secure = ctx_alice();
ctx_secure.aws_secure_transport = Some(true);
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_secure)
),
Decision::Allow
);
}
#[test]
fn condition_ip_address_allows_within_cidr() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": { "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }
}]
}));
let mut ctx = ctx_alice();
ctx.aws_source_ip = Some("10.0.0.17".parse().unwrap());
assert_eq!(
evaluate(
&[policy.clone()],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
),
Decision::Allow
);
let mut wrong = ctx_alice();
wrong.aws_source_ip = Some("192.168.1.1".parse().unwrap());
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", wrong)
),
Decision::ImplicitDeny
);
}
#[test]
fn condition_date_less_than_blocks_expired() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": {
"DateLessThan": { "aws:CurrentTime": "2020-01-01T00:00:00Z" }
}
}]
}));
let mut ctx = ctx_alice();
ctx.aws_current_time = Some(
chrono::DateTime::parse_from_rfc3339("2024-06-15T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
);
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
),
Decision::ImplicitDeny
);
}
#[test]
fn condition_missing_key_without_if_exists_denies() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": { "IpAddress": { "aws:SourceIp": "10.0.0.0/8" } }
}]
}));
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
),
Decision::ImplicitDeny
);
}
#[test]
fn condition_if_exists_passes_on_missing_key() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": {
"IpAddressIfExists": { "aws:SourceIp": "10.0.0.0/8" }
}
}]
}));
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
),
Decision::Allow
);
}
#[test]
fn condition_multiple_operators_all_must_match() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": {
"StringEquals": { "aws:username": "alice" },
"IpAddress": { "aws:SourceIp": "10.0.0.0/24" }
}
}]
}));
let mut ctx = ctx_alice();
ctx.aws_source_ip = Some("10.0.0.1".parse().unwrap());
assert_eq!(
evaluate(
&[policy.clone()],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
),
Decision::Allow
);
let mut wrong_ip = ctx_alice();
wrong_ip.aws_source_ip = Some("192.168.1.1".parse().unwrap());
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", wrong_ip)
),
Decision::ImplicitDeny
);
}
#[test]
fn condition_unknown_operator_fails_closed() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": { "NotARealOperator": { "aws:username": "alice" } }
}]
}));
assert_eq!(
evaluate(
&[policy],
&req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
),
Decision::ImplicitDeny
);
}
#[test]
fn array_action_matches_any_entry() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "*"
}]
}));
assert_eq!(
evaluate(
&[policy.clone()],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn statement_without_effect_is_dropped() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [
{ "Action": "s3:GetObject", "Resource": "*" },
{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
]
}));
assert_eq!(policy.statement_count(), 1);
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn statement_without_action_is_dropped() {
let policy = doc(json!({
"Statement": [{ "Effect": "Allow", "Resource": "*" }]
}));
assert_eq!(policy.statement_count(), 0);
}
#[test]
fn implicit_resource_acts_like_wildcard() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [{ "Effect": "Allow", "Action": "s3:GetObject" }]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn malformed_policy_json_is_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = PolicyDocument::parse("{ this is not valid json");
assert_eq!(policy.statement_count(), 0);
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn deny_short_circuits_after_match() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let policy = doc(json!({
"Statement": [
{ "Effect": "Deny", "Action": "*", "Resource": "*" },
{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
]
}));
assert_eq!(
evaluate(
&[policy],
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ExplicitDeny
);
}
#[test]
fn user_name_from_arn_strips_iam_path() {
assert_eq!(
user_name_from_arn("arn:aws:iam::123456789012:user/alice"),
Some("alice")
);
assert_eq!(
user_name_from_arn("arn:aws:iam::123456789012:user/engineering/alice"),
Some("alice")
);
assert_eq!(
user_name_from_arn("arn:aws:iam::123456789012:user/path/to/alice"),
Some("alice")
);
assert_eq!(user_name_from_arn("arn:aws:iam::123456789012:role/r"), None);
}
#[test]
fn collect_identity_policies_resolves_pathed_user() {
use crate::state::IamUser;
use chrono::Utc;
let mut state = IamState::new("123456789012");
state.users.insert(
"alice".to_string(),
IamUser {
user_name: "alice".into(),
user_id: "AIDAALICE".into(),
arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
path: "/engineering/".into(),
created_at: Utc::now(),
tags: Vec::new(),
permissions_boundary: None,
},
);
let mut inline = std::collections::HashMap::new();
inline.insert(
"AllowGet".to_string(),
r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
.to_string(),
);
state
.user_inline_policies
.insert("alice".to_string(), inline);
let principal = Principal {
arn: "arn:aws:iam::123456789012:user/engineering/alice".to_string(),
user_id: "AIDAALICE".to_string(),
account_id: "123456789012".to_string(),
principal_type: PrincipalType::User,
source_identity: None,
tags: None,
};
let docs = collect_identity_policies(&state, &principal);
assert_eq!(docs.len(), 1, "pathed user's inline policy was missed");
assert_eq!(
evaluate(
&docs,
&req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn role_name_from_assumed_role_arn_strips_session() {
assert_eq!(
role_name_from_assumed_role_arn("arn:aws:sts::123456789012:assumed-role/ops/session-1"),
Some("ops")
);
}
#[test]
fn collect_identity_policies_picks_up_user_inline() {
use crate::state::IamUser;
use chrono::Utc;
let mut state = IamState::new("123456789012");
state.users.insert(
"alice".to_string(),
IamUser {
user_name: "alice".into(),
user_id: "AIDAALICE".into(),
arn: "arn:aws:iam::123456789012:user/alice".into(),
path: "/".into(),
created_at: Utc::now(),
tags: Vec::new(),
permissions_boundary: None,
},
);
let mut inline = std::collections::HashMap::new();
inline.insert(
"AllowGet".to_string(),
r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
.to_string(),
);
state
.user_inline_policies
.insert("alice".to_string(), inline);
let principal = principal_user("arn:aws:iam::123456789012:user/alice");
let docs = collect_identity_policies(&state, &principal);
assert_eq!(docs.len(), 1);
assert_eq!(
evaluate(
&docs,
&req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn collect_identity_policies_picks_up_managed_via_groups() {
use crate::state::{IamGroup, IamPolicy, IamUser, PolicyVersion};
use chrono::Utc;
let mut state = IamState::new("123456789012");
state.users.insert(
"alice".to_string(),
IamUser {
user_name: "alice".into(),
user_id: "AIDAALICE".into(),
arn: "arn:aws:iam::123456789012:user/alice".into(),
path: "/".into(),
created_at: Utc::now(),
tags: Vec::new(),
permissions_boundary: None,
},
);
let policy_arn = "arn:aws:iam::123456789012:policy/AllowGet".to_string();
state.policies.insert(
policy_arn.clone(),
IamPolicy {
policy_name: "AllowGet".into(),
policy_id: "ANPA1".into(),
arn: policy_arn.clone(),
path: "/".into(),
description: "".into(),
created_at: Utc::now(),
tags: Vec::new(),
default_version_id: "v1".into(),
versions: vec![PolicyVersion {
version_id: "v1".into(),
document: r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#.into(),
is_default: true,
created_at: Utc::now(),
}],
next_version_num: 2,
attachment_count: 1,
},
);
state.groups.insert(
"readers".to_string(),
IamGroup {
group_name: "readers".into(),
group_id: "AGPA1".into(),
arn: "arn:aws:iam::123456789012:group/readers".into(),
path: "/".into(),
created_at: Utc::now(),
members: vec!["alice".into()],
inline_policies: std::collections::HashMap::new(),
attached_policies: vec![policy_arn],
},
);
let principal = principal_user("arn:aws:iam::123456789012:user/alice");
let docs = collect_identity_policies(&state, &principal);
assert_eq!(docs.len(), 1);
assert_eq!(
evaluate(
&docs,
&req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn collect_identity_policies_for_root_returns_empty() {
let state = IamState::new("123456789012");
let principal = Principal {
arn: "arn:aws:iam::123456789012:root".into(),
user_id: "ROOT".into(),
account_id: "123456789012".into(),
principal_type: PrincipalType::Root,
source_identity: None,
tags: None,
};
assert!(collect_identity_policies(&state, &principal).is_empty());
}
const ACCT_A: &str = "111111111111";
const ACCT_B: &str = "222222222222";
fn principal_in(account: &str, user: &str) -> Principal {
Principal {
arn: format!("arn:aws:iam::{account}:user/{user}"),
user_id: format!("AIDA{user}"),
account_id: account.into(),
principal_type: PrincipalType::User,
source_identity: None,
tags: None,
}
}
fn assumed_role_principal(account: &str, role_arn_tail: &str) -> Principal {
Principal {
arn: format!("arn:aws:sts::{account}:assumed-role/{role_arn_tail}"),
user_id: "AROAEXAMPLE".into(),
account_id: account.into(),
principal_type: PrincipalType::AssumedRole,
source_identity: None,
tags: None,
}
}
fn eval_cross(
identity: Option<serde_json::Value>,
resource: Option<serde_json::Value>,
principal: &Principal,
resource_account_id: &str,
) -> Decision {
let identity_docs: Vec<PolicyDocument> = identity.into_iter().map(doc).collect();
let resource_doc = resource.map(doc);
let request = req(principal, "s3:GetObject", "arn:aws:s3:::bucket/key");
evaluate_with_resource_policy(
&identity_docs,
resource_doc.as_ref(),
&request,
resource_account_id,
)
}
fn allow_get_wildcard() -> serde_json::Value {
json!({"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]})
}
fn deny_get_wildcard() -> serde_json::Value {
json!({"Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"*"}]})
}
fn resource_allow_for(principal_arn: &str) -> serde_json::Value {
json!({
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": principal_arn},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/key"
}]
})
}
#[test]
fn same_account_identity_only_allow() {
let p = principal_in(ACCT_A, "alice");
assert_eq!(
eval_cross(Some(allow_get_wildcard()), None, &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn same_account_resource_only_allow_via_user_arn() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for(&p.arn);
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn same_account_both_allow() {
let p = principal_in(ACCT_A, "alice");
assert_eq!(
eval_cross(
Some(allow_get_wildcard()),
Some(resource_allow_for(&p.arn)),
&p,
ACCT_A,
),
Decision::Allow
);
}
#[test]
fn same_account_neither_allows_is_implicit_deny() {
let p = principal_in(ACCT_A, "alice");
assert_eq!(eval_cross(None, None, &p, ACCT_A), Decision::ImplicitDeny);
}
#[test]
fn identity_deny_blocks_resource_allow() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for(&p.arn);
assert_eq!(
eval_cross(Some(deny_get_wildcard()), Some(resource), &p, ACCT_A),
Decision::ExplicitDeny
);
}
#[test]
fn resource_deny_blocks_identity_allow() {
let p = principal_in(ACCT_A, "alice");
let resource_deny = json!({
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(Some(allow_get_wildcard()), Some(resource_deny), &p, ACCT_A,),
Decision::ExplicitDeny
);
}
#[test]
fn cross_account_identity_only_is_implicit_deny() {
let p = principal_in(ACCT_A, "alice");
assert_eq!(
eval_cross(Some(allow_get_wildcard()), None, &p, ACCT_B),
Decision::ImplicitDeny
);
}
#[test]
fn cross_account_resource_only_is_implicit_deny() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for(&p.arn);
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_B),
Decision::ImplicitDeny
);
}
#[test]
fn cross_account_both_allow_succeeds() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for(&p.arn);
assert_eq!(
eval_cross(Some(allow_get_wildcard()), Some(resource), &p, ACCT_B),
Decision::Allow
);
}
#[test]
fn principal_wildcard_star_matches_any_principal() {
let p = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn principal_aws_star_matches_any_principal() {
let p = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn principal_account_root_matches_any_user_in_account() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for("arn:aws:iam::111111111111:root");
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn principal_account_root_does_not_match_other_account() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for("arn:aws:iam::222222222222:root");
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn principal_user_arn_exact_match() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for("arn:aws:iam::111111111111:user/alice");
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn principal_user_arn_mismatch_is_deny() {
let p = principal_in(ACCT_A, "alice");
let resource = resource_allow_for("arn:aws:iam::111111111111:user/bob");
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn principal_service_matches_assumed_role_containing_service_host() {
let p = assumed_role_principal(
ACCT_A,
"AWSServiceRoleForLambda.lambda.amazonaws.com/session",
);
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::Allow
);
}
#[test]
fn principal_service_does_not_match_unrelated_user() {
let p = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn not_principal_deny_excludes_named_user() {
let alice = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*"
},
{
"Effect": "Deny",
"NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:user/bob")},
"Action": "s3:GetObject",
"Resource": "*"
}
]
});
assert_eq!(
eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
Decision::ExplicitDeny
);
let bob = principal_in(ACCT_A, "bob");
assert_eq!(
eval_cross(None, Some(resource), &bob, ACCT_A),
Decision::Allow
);
}
#[test]
fn not_principal_allow_excludes_named_user() {
let alice = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:user/bob")},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
Decision::Allow
);
let bob = principal_in(ACCT_A, "bob");
assert_eq!(
eval_cross(None, Some(resource), &bob, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn not_principal_with_star_never_applies() {
let alice = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"NotPrincipal": "*",
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &alice, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn not_principal_with_account_root() {
let alice = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:root")},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
Decision::ImplicitDeny
);
let eve = principal_in(ACCT_B, "eve");
let resource_deny = json!({
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*"
},
{
"Effect": "Deny",
"NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:root")},
"Action": "s3:GetObject",
"Resource": "*"
}
]
});
assert_eq!(
eval_cross(None, Some(resource_deny.clone()), &eve, ACCT_A),
Decision::ExplicitDeny
);
assert_eq!(
eval_cross(None, Some(resource_deny), &alice, ACCT_A),
Decision::Allow
);
}
#[test]
fn not_principal_with_unrecognized_type_safe_skips() {
let alice = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"NotPrincipal": {"Federated": "cognito-identity.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &alice, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn not_principal_with_multiple_entries() {
let alice = principal_in(ACCT_A, "alice");
let bob = principal_in(ACCT_A, "bob");
let charlie = principal_in(ACCT_A, "charlie");
let resource = json!({
"Statement": [{
"Effect": "Deny",
"NotPrincipal": {"AWS": [
format!("arn:aws:iam::{ACCT_A}:user/alice"),
format!("arn:aws:iam::{ACCT_A}:user/bob")
]},
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
Decision::ImplicitDeny
);
assert_eq!(
eval_cross(None, Some(resource.clone()), &bob, ACCT_A),
Decision::ImplicitDeny
);
assert_eq!(
eval_cross(None, Some(resource), &charlie, ACCT_A),
Decision::ExplicitDeny
);
}
#[test]
fn resource_policy_statement_without_principal_is_skipped() {
let p = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*"
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn resource_policy_condition_block_gates_access() {
use crate::condition::ConditionContext;
use std::net::IpAddr;
let p = principal_in(ACCT_A, "alice");
let resource = json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": {
"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}
}
}]
});
let resource_doc = doc(resource);
let ctx_ok = ConditionContext {
aws_source_ip: Some("10.1.2.3".parse::<IpAddr>().unwrap()),
..ConditionContext::default()
};
let req_ok = EvalRequest {
principal: &p,
action: "s3:GetObject".to_string(),
resource: "arn:aws:s3:::bucket/key".to_string(),
context: ctx_ok,
};
assert_eq!(
evaluate_with_resource_policy(&[], Some(&resource_doc), &req_ok, ACCT_A),
Decision::Allow
);
let ctx_bad = ConditionContext {
aws_source_ip: Some("8.8.8.8".parse::<IpAddr>().unwrap()),
..ConditionContext::default()
};
let req_bad = EvalRequest {
principal: &p,
action: "s3:GetObject".to_string(),
resource: "arn:aws:s3:::bucket/key".to_string(),
context: ctx_bad,
};
assert_eq!(
evaluate_with_resource_policy(&[], Some(&resource_doc), &req_bad, ACCT_A),
Decision::ImplicitDeny
);
}
#[test]
fn classify_aws_principal_recognizes_bare_account_id() {
assert_eq!(
classify_aws_principal("111111111111"),
PrincipalRef::AwsAccountRoot("111111111111".to_string())
);
}
#[test]
fn classify_aws_principal_recognizes_root_arn() {
assert_eq!(
classify_aws_principal("arn:aws:iam::111111111111:root"),
PrincipalRef::AwsAccountRoot("111111111111".to_string())
);
}
#[test]
fn classify_aws_principal_keeps_user_arn_as_arn() {
assert_eq!(
classify_aws_principal("arn:aws:iam::111111111111:user/alice"),
PrincipalRef::AwsArn("arn:aws:iam::111111111111:user/alice".to_string())
);
}
fn allow_all() -> PolicyDocument {
doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}]
}))
}
fn allow_get_object() -> PolicyDocument {
doc(json!({
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*"
}]
}))
}
fn deny_put_object() -> PolicyDocument {
doc(json!({
"Statement": [{
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "*"
}]
}))
}
#[test]
fn gates_absent_behaves_like_phase2_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
assert_eq!(
evaluate_with_gates(
&identity,
None,
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn gates_absent_behaves_like_phase2_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
assert_eq!(
evaluate_with_gates(
&[],
None,
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn boundary_caps_identity_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary = [allow_get_object()];
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
None,
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn empty_boundary_denies_everything() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary: [PolicyDocument; 0] = [];
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn explicit_deny_in_boundary_wins() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary = [deny_put_object()];
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
None,
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::ExplicitDeny
);
}
#[test]
fn identity_implicit_with_boundary_allow_is_implicit_deny() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let boundary = [allow_all()];
assert_eq!(
evaluate_with_gates(
&[],
Some(&boundary),
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
}
#[test]
fn session_policy_caps_identity_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let session = [allow_get_object()];
assert_eq!(
evaluate_with_gates(
&identity,
None,
Some(&session),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
assert_eq!(
evaluate_with_gates(
&identity,
None,
Some(&session),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn session_policy_explicit_deny_wins() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let session = [deny_put_object()];
assert_eq!(
evaluate_with_gates(
&identity,
None,
Some(&session),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::ExplicitDeny
);
}
#[test]
fn boundary_and_session_must_both_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary = [allow_all()];
let session = [allow_get_object()];
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
Some(&session),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
),
Decision::ImplicitDeny
);
assert_eq!(
evaluate_with_gates(
&identity,
Some(&boundary),
Some(&session),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
),
Decision::Allow
);
}
#[test]
fn resource_policy_gated_same_account_resource_bypasses_boundary() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity: [PolicyDocument; 0] = [];
let boundary: [PolicyDocument; 0] = []; let resource = doc(json!({
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::123456789012:user/alice"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/key"
}]
}));
assert_eq!(
evaluate_with_resource_policy_and_gates(
&identity,
Some(&boundary),
None,
Some(&resource),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
"123456789012"
),
Decision::Allow
);
}
#[test]
fn resource_policy_gated_cross_account_identity_must_allow() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity: [PolicyDocument; 0] = [];
let resource = doc(json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/key"
}]
}));
assert_eq!(
evaluate_with_resource_policy_and_gates(
&identity,
None,
None,
Some(&resource),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
"999999999999"
),
Decision::ImplicitDeny
);
}
#[test]
fn resource_policy_gated_cross_account_boundary_caps_identity_side() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary: [PolicyDocument; 0] = [];
let resource = doc(json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/key"
}]
}));
assert_eq!(
evaluate_with_resource_policy_and_gates(
&identity,
Some(&boundary),
None,
Some(&resource),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
"999999999999"
),
Decision::ImplicitDeny
);
}
#[test]
fn resource_policy_gated_explicit_deny_in_session_wins() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let session = [deny_put_object()];
let resource = doc(json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}));
assert_eq!(
evaluate_with_resource_policy_and_gates(
&identity,
None,
Some(&session),
Some(&resource),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
"123456789012"
),
Decision::ExplicitDeny
);
}
#[test]
fn scp_caps_identity_allow_all() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let scps = [allow_get_object()];
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
),
Decision::Allow
);
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
),
Decision::ImplicitDeny
);
}
#[test]
fn scp_explicit_deny_wins() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let scps = [deny_put_object()];
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
),
Decision::ExplicitDeny
);
}
#[test]
fn scp_empty_chain_denies_everything() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let scps: [PolicyDocument; 0] = [];
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
),
Decision::ImplicitDeny
);
}
#[test]
fn scp_none_preserves_identity_only_decision() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let with_scps = evaluate_with_gates_and_scps(
&identity,
None,
None,
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
);
let without = evaluate_with_gates(
&identity,
None,
None,
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
);
assert_eq!(with_scps, without);
assert_eq!(with_scps, Decision::Allow);
}
#[test]
fn scp_chain_intersects_across_ancestors() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let scps = [allow_all(), allow_get_object()];
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
),
Decision::Allow
);
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
None,
None,
Some(&scps),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
),
Decision::ImplicitDeny
);
}
#[test]
fn scp_intersects_with_boundary_and_session() {
let p = principal_user("arn:aws:iam::123456789012:user/alice");
let identity = [allow_all()];
let boundary = [allow_all()];
let session = [allow_all()];
let scps = [allow_get_object()];
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
Some(&boundary),
Some(&session),
Some(&scps),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
),
Decision::ImplicitDeny
);
assert_eq!(
evaluate_with_gates_and_scps(
&identity,
Some(&boundary),
Some(&session),
Some(&scps),
&req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
),
Decision::Allow
);
}
#[test]
fn scp_caps_identity_side_of_resource_policy() {
let p = principal_user("arn:aws:iam::111111111111:user/alice");
let identity = [allow_all()];
let resource = doc(serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}));
let scps = [allow_get_object()];
assert_eq!(
evaluate_with_resource_policy_and_gates_and_scps(
&identity,
None,
None,
Some(&scps),
Some(&resource),
&req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
"222222222222",
),
Decision::ImplicitDeny
);
}
}