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),
Federated(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()
}
pub fn matching_identity_statements(&self, request: &EvalRequest<'_>, allow: bool) -> usize {
let want = if allow { Effect::Allow } else { Effect::Deny };
self.statements
.iter()
.filter(|s| matches!(s.principal, PrincipalPattern::None))
.filter(|s| s.effect == want)
.filter(|s| action_matches(&s.action, &request.action))
.filter(|s| resource_matches(&s.resource, &request.resource))
.filter(|s| {
s.condition
.as_ref()
.is_none_or(|c| c.matches(&request.context))
})
.count()
}
}
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::warn!(
target: "fakecloud::iam::audit",
principal = %other,
"Principal string other than \"*\" is not a recognized shape; statement will not match"
);
}
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));
}
}
"Federated" => {
for s in coerce_string_list(v) {
out.push(PrincipalRef::Federated(s));
}
}
other => {
tracing::warn!(
target: "fakecloud::iam::audit",
principal_type = %other,
"Principal type not recognized; entries dropped — statement \
will not match unless other Principal entries cover the caller"
);
}
}
}
}
_ => {
tracing::warn!(
target: "fakecloud::iam::audit",
"Principal has an unexpected JSON shape; statement will not match"
);
}
}
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_resource_policy_only(
policy: &PolicyDocument,
request: &EvalRequest<'_>,
) -> Decision {
evaluate_inner(std::slice::from_ref(policy), request, true)
}
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;
let is_kms = request
.action
.split_once(':')
.is_some_and(|(svc, _)| svc.eq_ignore_ascii_case("kms"));
if same_account && is_kms {
if let Some(policy) = resource_policy {
return evaluate_kms_same_account(policy, identity_gated, request);
}
}
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_kms_same_account(
key_policy: &PolicyDocument,
identity_gated: Decision,
request: &EvalRequest<'_>,
) -> Decision {
let policies = std::slice::from_ref(key_policy);
let full = evaluate_inner_scoped(policies, request, true, false);
if matches!(full, Decision::ExplicitDeny) {
return Decision::ExplicitDeny;
}
let direct = evaluate_inner_scoped(policies, request, true, true);
if matches!(direct, Decision::Allow) {
return Decision::Allow;
}
if matches!(full, Decision::Allow) && matches!(identity_gated, Decision::Allow) {
return Decision::Allow;
}
Decision::ImplicitDeny
}
fn evaluate_inner(
policies: &[PolicyDocument],
request: &EvalRequest<'_>,
is_resource_policy: bool,
) -> Decision {
evaluate_inner_scoped(policies, request, is_resource_policy, false)
}
fn evaluate_inner_scoped(
policies: &[PolicyDocument],
request: &EvalRequest<'_>,
is_resource_policy: bool,
ignore_account_wide: 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_scoped(refs, request.principal, ignore_account_wide) {
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 {
principal_matches_scoped(refs, principal, false)
}
fn principal_matches_scoped(
refs: &[PrincipalRef],
principal: &Principal,
ignore_account_wide: bool,
) -> bool {
refs.iter().any(|r| match r {
PrincipalRef::AnyAws => !ignore_account_wide,
PrincipalRef::AwsAccountRoot(account) => {
!ignore_account_wide && &principal.account_id == account
}
PrincipalRef::AwsArn(arn) => &principal.arn == arn,
PrincipalRef::Service(service) => principal_is_service(principal, service),
PrincipalRef::Federated(provider) => principal_is_federated(principal, provider),
})
}
fn principal_is_federated(principal: &Principal, provider: &str) -> bool {
matches!(principal.principal_type, PrincipalType::FederatedUser) && principal.arn == provider
}
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> {
if let Some(policy) = state.policies.get(arn) {
return policy
.versions
.iter()
.find(|v| v.is_default)
.or_else(|| policy.versions.first())
.map(|v| v.document.clone());
}
crate::managed_policies::default_document(arn).map(str::to_owned)
}
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)]
#[path = "evaluator_tests.rs"]
mod tests;