use crate::service::auth::decision::{
AllowReason, Decision, DenyReason, NonEmpty, PolicySource, StatementRef,
};
use crate::service::auth::policy_evaluator::{self, PolicyEffect, StatementHit};
use crate::store::traits::{GroupStore, PolicyStore, RoleStore, UserStore};
use std::sync::Arc;
use tokio::sync::RwLock;
use wami_core::arn::WamiArn;
use wami_core::context::WamiContext;
use wami_core::error::{AmiError, Result};
pub struct AuthorizationService<S>
where
S: UserStore + GroupStore + RoleStore + PolicyStore + Send + Sync,
{
store: Arc<RwLock<S>>,
}
impl<S> AuthorizationService<S>
where
S: UserStore + GroupStore + RoleStore + PolicyStore + Send + Sync,
{
pub fn new(store: Arc<RwLock<S>>) -> Self {
Self { store }
}
pub async fn authorize(
&self,
context: &WamiContext,
action: &str,
resource_arn: &WamiArn,
) -> Result<Decision> {
if context.is_root() {
return Ok(Decision::Allow(AllowReason::RootBypass));
}
let user_name = self.extract_user_name_from_arn(context.caller_arn())?;
self.evaluate_user_policies(context, &user_name, action, resource_arn)
.await
}
pub async fn check_or_deny(
&self,
context: &WamiContext,
action: &str,
resource_arn: &WamiArn,
) -> Result<()> {
match self.authorize(context, action, resource_arn).await? {
Decision::Allow(_) => Ok(()),
denied => Err(AmiError::AccessDenied {
message: format!(
"{} is not authorized to perform {} on {}: {}",
context.caller_arn(),
action,
resource_arn,
denied
),
}),
}
}
async fn evaluate_user_policies(
&self,
context: &WamiContext,
user_name: &str,
action: &str,
resource_arn: &WamiArn,
) -> Result<Decision> {
let store = self.store.read().await;
let mut hits: Vec<(PolicySource, StatementHit)> = Vec::new();
let mut consider = |document: &str, source: PolicySource| -> Result<()> {
let parsed = policy_evaluator::parse_policy_doc(document, &source)?;
if let Some(hit) =
policy_evaluator::evaluate_policy_document(&parsed, action, resource_arn, context)
{
hits.push((source, hit));
}
Ok(())
};
for policy_arn in store.list_attached_user_policies(user_name).await? {
if let Some(policy) = store.get_policy(&policy_arn).await? {
consider(
&policy.policy_document,
PolicySource::UserManaged { arn: policy_arn },
)?;
}
}
for policy_name in store.list_user_policies(user_name).await? {
if let Some(document) = store.get_user_policy(user_name, &policy_name).await? {
consider(&document, PolicySource::UserInline { name: policy_name })?;
}
}
for group in store.list_groups_for_user(user_name).await? {
let group_name = &group.group_name;
for policy_arn in store.list_attached_group_policies(group_name).await? {
if let Some(policy) = store.get_policy(&policy_arn).await? {
consider(
&policy.policy_document,
PolicySource::GroupManaged {
group: group_name.clone(),
arn: policy_arn,
},
)?;
}
}
for policy_name in store.list_group_policies(group_name).await? {
if let Some(document) = store.get_group_policy(group_name, &policy_name).await? {
consider(
&document,
PolicySource::GroupInline {
group: group_name.clone(),
name: policy_name,
},
)?;
}
}
}
if let Some(session) = context.session_info() {
if let Some(ref role_arn) = session.assumed_role_arn {
if role_arn.resource.resource_type == "role" {
let role_name = &role_arn.resource.resource_id;
for policy_arn in store.list_attached_role_policies(role_name).await? {
if let Some(policy) = store.get_policy(&policy_arn).await? {
consider(
&policy.policy_document,
PolicySource::RoleManaged {
role: role_name.clone(),
arn: policy_arn,
},
)?;
}
}
for policy_name in store.list_role_policies(role_name).await? {
if let Some(document) =
store.get_role_policy(role_name, &policy_name).await?
{
consider(
&document,
PolicySource::RoleInline {
role: role_name.clone(),
name: policy_name,
},
)?;
}
}
}
}
}
hits.sort_by(|(a, _), (b, _)| a.cmp(b));
let refs = |effect: PolicyEffect| -> Vec<StatementRef> {
hits.iter()
.filter(|(_, hit)| hit.effect == effect)
.map(|(source, hit)| StatementRef {
policy: source.clone(),
sid: hit.sid.clone(),
index: hit.index,
})
.collect()
};
if let Ok(denied_by) = NonEmpty::new(refs(PolicyEffect::Deny)) {
return Ok(Decision::Deny(DenyReason::Statements(denied_by)));
}
let Ok(allowed_by) = NonEmpty::new(refs(PolicyEffect::Allow)) else {
return Ok(Decision::Deny(DenyReason::NoMatch));
};
let Some(user) = store.get_user(user_name).await? else {
return Ok(Decision::Allow(AllowReason::Statements(allowed_by)));
};
let Some(boundary_arn) = user.permissions_boundary else {
return Ok(Decision::Allow(AllowReason::Statements(allowed_by)));
};
let Some(boundary) = store.get_policy(&boundary_arn).await? else {
return Ok(Decision::Deny(DenyReason::BoundaryMissing {
arn: boundary_arn,
}));
};
let boundary_source = PolicySource::PermissionsBoundary {
arn: boundary_arn.clone(),
};
let boundary_doc =
policy_evaluator::parse_policy_doc(&boundary.policy_document, &boundary_source)?;
let boundary_hit = policy_evaluator::evaluate_policy_document(
&boundary_doc,
action,
resource_arn,
context,
);
match boundary_hit {
Some(hit) if hit.effect == PolicyEffect::Allow => {
Ok(Decision::Allow(AllowReason::Statements(allowed_by)))
}
other => Ok(Decision::Deny(DenyReason::BoundaryRestricted {
arn: boundary_arn,
allowed_by,
denied_by: other
.into_iter()
.map(|hit| StatementRef {
policy: boundary_source.clone(),
sid: hit.sid,
index: hit.index,
})
.collect(),
})),
}
}
fn extract_user_name_from_arn(&self, arn: &WamiArn) -> Result<String> {
if arn.resource.resource_type == "user" {
Ok(arn.resource.resource_id.clone())
} else {
Err(AmiError::InvalidParameter {
message: "Caller ARN is not a user ARN".to_string(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::memory::InMemoryWamiStore;
use wami_core::arn::matching::MatchContext;
use wami_core::types::{PolicyDocument, PolicyStatement};
#[test]
fn test_matches_action() {
assert!(policy_evaluator::matches_action(
&["iam:GetUser".to_string()],
"iam:GetUser"
));
assert!(policy_evaluator::matches_action(
&["*".to_string()],
"iam:GetUser"
));
assert!(policy_evaluator::matches_action(
&["iam:*".to_string()],
"iam:GetUser"
));
assert!(policy_evaluator::matches_action(
&["iam:*".to_string()],
"iam:CreateUser"
));
assert!(!policy_evaluator::matches_action(
&["s3:GetObject".to_string()],
"iam:GetUser"
));
}
#[test]
fn test_matches_resource() {
let ctx = MatchContext::default();
assert!(policy_evaluator::matches_resource(
&["arn:wami:iam:12345678:wami:999:user/alice".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
assert!(policy_evaluator::matches_resource(
&["*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
assert!(policy_evaluator::matches_resource(
&["arn:wami:iam:*:user/*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
assert!(!policy_evaluator::matches_resource(
&["arn:wami:iam:12345678:wami:999:role/*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
}
#[test]
fn test_matches_action_edge_cases() {
assert!(!policy_evaluator::matches_action(&[], "iam:GetUser"));
assert!(policy_evaluator::matches_action(
&["iam:*".to_string(), "s3:*".to_string()],
"iam:GetUser"
));
assert!(policy_evaluator::matches_action(
&["s3:GetObject".to_string(), "iam:GetUser".to_string()],
"iam:GetUser"
));
assert!(!policy_evaluator::matches_action(
&["s3:GetObject".to_string()],
"iam:GetUser"
));
assert!(policy_evaluator::matches_action(
&["iam:Get*".to_string()],
"iam:GetUser"
));
}
#[test]
fn test_matches_resource_edge_cases() {
let ctx = MatchContext::default();
assert!(!policy_evaluator::matches_resource(
&[],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx
));
assert!(policy_evaluator::matches_resource(
&[
"arn:wami:iam:*:role/*".to_string(),
"arn:wami:iam:*:user/*".to_string()
],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
assert!(policy_evaluator::matches_resource(
&["arn:wami:iam:*:wami:*:user/al*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
assert!(policy_evaluator::matches_resource(
&["arn:wami:hub:*:wami:*:space/le-zinc/**".to_string()],
"arn:wami:hub:12345678:wami:999:space/le-zinc/db/menu",
&ctx,
));
}
#[test]
fn test_matches_resource_with_variable_substitution() {
let ctx = MatchContext {
tenant: Some("12345678".into()),
principal: Some("alice".into()),
service: Some("iam".into()),
};
assert!(policy_evaluator::matches_resource(
&["arn:wami:iam:${tenant}:wami:*:user/*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&ctx,
));
let other_ctx = MatchContext {
tenant: Some("99999999".into()),
..Default::default()
};
assert!(!policy_evaluator::matches_resource(
&["arn:wami:iam:${tenant}:wami:*:user/*".to_string()],
"arn:wami:iam:12345678:wami:999:user/alice",
&other_ctx,
));
}
#[test]
fn test_evaluate_policy_deny_overrides_allow() {
let policy = PolicyDocument {
version: "2012-10-17".to_string(),
statement: vec![
PolicyStatement {
sid: None,
effect: "Allow".to_string(),
action: vec!["iam:*".to_string()],
resource: vec!["*".to_string()],
condition: None,
},
PolicyStatement {
sid: None,
effect: "Deny".to_string(),
action: vec!["iam:DeleteUser".to_string()],
resource: vec!["*".to_string()],
condition: None,
},
],
};
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = dummy_context();
let effect =
policy_evaluator::evaluate_policy_document(&policy, "iam:DeleteUser", &resource, &ctx);
assert_eq!(effect.map(|hit| hit.effect), Some(PolicyEffect::Deny));
}
#[test]
fn test_evaluate_policy_no_match() {
let policy = PolicyDocument {
version: "2012-10-17".to_string(),
statement: vec![PolicyStatement {
sid: None,
effect: "Allow".to_string(),
action: vec!["s3:GetObject".to_string()],
resource: vec!["*".to_string()],
condition: None,
}],
};
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = dummy_context();
let effect =
policy_evaluator::evaluate_policy_document(&policy, "iam:GetUser", &resource, &ctx);
assert!(effect.is_none());
}
#[test]
fn test_evaluate_policy_case_insensitive_effect() {
let policy = PolicyDocument {
version: "2012-10-17".to_string(),
statement: vec![PolicyStatement {
sid: None,
effect: "DENY".to_string(), action: vec!["iam:GetUser".to_string()],
resource: vec!["*".to_string()],
condition: None,
}],
};
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = dummy_context();
let effect =
policy_evaluator::evaluate_policy_document(&policy, "iam:GetUser", &resource, &ctx);
assert_eq!(effect.map(|hit| hit.effect), Some(PolicyEffect::Deny));
}
fn dummy_context() -> wami_core::context::WamiContext {
let arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/test".parse().unwrap();
wami_core::context::WamiContext::builder()
.instance_id("999")
.tenant_path(wami_core::arn::TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.build()
.unwrap()
}
use crate::store::traits::{GroupStore, PolicyStore, RoleStore, UserStore};
use crate::wami::identity::group::builder as group_builder;
use crate::wami::identity::role::builder as role_builder;
use crate::wami::identity::user::builder as user_builder;
use crate::wami::policies::policy::builder as policy_builder;
use wami_core::arn::{TenantPath, WamiArn as Arn};
use wami_core::context::{SessionInfo, WamiContext};
fn test_context() -> WamiContext {
let arn: Arn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.build()
.unwrap()
}
fn allow_policy_json(actions: &[&str], resources: &[&str]) -> String {
let actions_json: Vec<String> = actions.iter().map(|a| format!("\"{}\"", a)).collect();
let resources_json: Vec<String> = resources.iter().map(|r| format!("\"{}\"", r)).collect();
format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":[{}],"Resource":[{}]}}]}}"#,
actions_json.join(","),
resources_json.join(",")
)
}
fn deny_policy_json(actions: &[&str], resources: &[&str]) -> String {
let actions_json: Vec<String> = actions.iter().map(|a| format!("\"{}\"", a)).collect();
let resources_json: Vec<String> = resources.iter().map(|r| format!("\"{}\"", r)).collect();
format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Deny","Action":[{}],"Resource":[{}]}}]}}"#,
actions_json.join(","),
resources_json.join(",")
)
}
async fn setup_store_with_user(ctx: &WamiContext) -> Arc<RwLock<InMemoryWamiStore>> {
let store = Arc::new(RwLock::new(InMemoryWamiStore::default()));
let user =
user_builder::build_user("alice".to_string(), Some("/".to_string()), ctx).unwrap();
store.write().await.create_user(user).await.unwrap();
store
}
#[tokio::test]
async fn test_group_attached_policy_allows() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
let group = group_builder::build_group("developers".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(group).await.unwrap();
s.add_user_to_group("developers", "alice").await.unwrap();
}
let policy = policy_builder::build_policy(
"ReadPolicy".to_string(),
allow_policy_json(&["iam:GetUser"], &["*"]),
None,
None,
None,
&ctx,
)
.unwrap();
let policy_arn = policy.arn.clone();
{
let mut s = store.write().await;
s.create_policy(policy).await.unwrap();
s.attach_group_policy("developers", &policy_arn)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"Group attached policy should allow iam:GetUser"
);
}
#[tokio::test]
async fn test_group_inline_policy_allows() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
let group = group_builder::build_group("admins".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(group).await.unwrap();
s.add_user_to_group("admins", "alice").await.unwrap();
s.put_group_policy(
"admins",
"AdminInline",
allow_policy_json(&["iam:*"], &["*"]),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"Group inline policy should allow iam:DeleteUser"
);
}
#[tokio::test]
async fn test_group_deny_overrides_user_allow() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "UserAllow", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
let group = group_builder::build_group("restricted".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(group).await.unwrap();
s.add_user_to_group("restricted", "alice").await.unwrap();
s.put_group_policy(
"restricted",
"DenyDelete",
deny_policy_json(&["iam:DeleteUser"], &["*"]),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!allowed.is_allowed(),
"Group deny should override user allow"
);
let allowed_get = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(
allowed_get.is_allowed(),
"iam:GetUser should still be allowed"
);
}
#[tokio::test]
async fn test_assumed_role_attached_policy_allows() {
let role_arn: WamiArn = "arn:wami:iam:12345678:wami:999:role/admin-role"
.parse()
.unwrap();
let session = SessionInfo {
session_token: "tok-123".to_string(),
expiration: chrono::Utc::now().timestamp() + 3600,
assumed_role_arn: Some(role_arn),
};
let user_arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(user_arn)
.is_root(false)
.session_info(session)
.build()
.unwrap();
let store = setup_store_with_user(&ctx).await;
let role = role_builder::build_role(
"admin-role".to_string(),
r#"{"Version":"2012-10-17","Statement":[]}"#.to_string(),
None,
None,
None,
&ctx,
)
.unwrap();
let policy = policy_builder::build_policy(
"AdminAccess".to_string(),
allow_policy_json(&["iam:*"], &["*"]),
None,
None,
None,
&ctx,
)
.unwrap();
let policy_arn = policy.arn.clone();
{
let mut s = store.write().await;
s.create_role(role).await.unwrap();
s.create_policy(policy).await.unwrap();
s.attach_role_policy("admin-role", &policy_arn)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:CreateUser", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"Assumed role policy should allow iam:CreateUser"
);
}
#[tokio::test]
async fn test_assumed_role_inline_policy_allows() {
let role_arn: WamiArn = "arn:wami:iam:12345678:wami:999:role/reader-role"
.parse()
.unwrap();
let session = SessionInfo {
session_token: "tok-456".to_string(),
expiration: chrono::Utc::now().timestamp() + 3600,
assumed_role_arn: Some(role_arn),
};
let user_arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(user_arn)
.is_root(false)
.session_info(session)
.build()
.unwrap();
let store = setup_store_with_user(&ctx).await;
let role = role_builder::build_role(
"reader-role".to_string(),
r#"{"Version":"2012-10-17","Statement":[]}"#.to_string(),
None,
None,
None,
&ctx,
)
.unwrap();
{
let mut s = store.write().await;
s.create_role(role).await.unwrap();
s.put_role_policy(
"reader-role",
"ReadOnly",
allow_policy_json(&["iam:Get*", "iam:List*"], &["*"]),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"Role inline policy should allow iam:GetUser"
);
let denied = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"Role inline policy should not allow iam:DeleteUser"
);
}
#[tokio::test]
async fn test_assumed_role_deny_overrides_user_and_group_allow() {
let role_arn: WamiArn = "arn:wami:iam:12345678:wami:999:role/restrictive-role"
.parse()
.unwrap();
let session = SessionInfo {
session_token: "tok-789".to_string(),
expiration: chrono::Utc::now().timestamp() + 3600,
assumed_role_arn: Some(role_arn),
};
let user_arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(user_arn)
.is_root(false)
.session_info(session)
.build()
.unwrap();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
let group = group_builder::build_group("power-users".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(group).await.unwrap();
s.add_user_to_group("power-users", "alice").await.unwrap();
s.put_group_policy(
"power-users",
"AllowAll",
allow_policy_json(&["iam:*"], &["*"]),
)
.await
.unwrap();
}
let role = role_builder::build_role(
"restrictive-role".to_string(),
r#"{"Version":"2012-10-17","Statement":[]}"#.to_string(),
None,
None,
None,
&ctx,
)
.unwrap();
{
let mut s = store.write().await;
s.create_role(role).await.unwrap();
s.put_role_policy(
"restrictive-role",
"DenyDelete",
deny_policy_json(&["iam:DeleteUser"], &["*"]),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let denied = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"Role deny must override user+group allow"
);
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(allowed.is_allowed(), "iam:GetUser should still be allowed");
}
#[tokio::test]
async fn test_no_session_means_no_role_policies() {
let ctx = test_context(); let store = setup_store_with_user(&ctx).await;
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(!allowed.is_allowed(), "No policies → default deny");
}
#[tokio::test]
async fn test_root_bypasses_all() {
let root_arn: Arn = "arn:wami:iam:12345678:wami:999:user/root".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(root_arn)
.is_root(true)
.build()
.unwrap();
let store = Arc::new(RwLock::new(InMemoryWamiStore::default()));
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(allowed.is_allowed(), "Root user must bypass all checks");
}
#[tokio::test]
async fn test_multiple_groups_policies_combined() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
let g1 = group_builder::build_group("readers".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(g1).await.unwrap();
s.add_user_to_group("readers", "alice").await.unwrap();
s.put_group_policy(
"readers",
"ReadPolicy",
allow_policy_json(&["iam:GetUser"], &["*"]),
)
.await
.unwrap();
}
let g2 = group_builder::build_group("creators".to_string(), None, &ctx).unwrap();
{
let mut s = store.write().await;
s.create_group(g2).await.unwrap();
s.add_user_to_group("creators", "alice").await.unwrap();
s.put_group_policy(
"creators",
"CreatePolicy",
allow_policy_json(&["iam:CreateUser"], &["*"]),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
assert!(authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap()
.is_allowed());
assert!(authz
.authorize(&ctx, "iam:CreateUser", &resource)
.await
.unwrap()
.is_allowed());
assert!(!authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap()
.is_allowed());
}
fn policy_with_condition(
effect: &str,
actions: &[&str],
resources: &[&str],
condition: &str,
) -> String {
let actions_json: Vec<String> = actions.iter().map(|a| format!("\"{}\"", a)).collect();
let resources_json: Vec<String> = resources.iter().map(|r| format!("\"{}\"", r)).collect();
format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"{}","Action":[{}],"Resource":[{}],"Condition":{}}}]}}"#,
effect,
actions_json.join(","),
resources_json.join(","),
condition
)
}
#[test]
fn test_condition_ip_restriction_allows_matching_ip() {
let policy_json = policy_with_condition(
"Allow",
&["iam:GetUser"],
&["*"],
r#"{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}"#,
);
let doc: PolicyDocument = serde_json::from_str(&policy_json).unwrap();
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.source_ip("10.1.2.3")
.build()
.unwrap();
let effect =
policy_evaluator::evaluate_policy_document(&doc, "iam:GetUser", &resource, &ctx);
assert_eq!(
effect.map(|hit| hit.effect),
Some(PolicyEffect::Allow),
"IP 10.1.2.3 should match 10.0.0.0/8"
);
}
#[test]
fn test_condition_ip_restriction_denies_non_matching_ip() {
let policy_json = policy_with_condition(
"Allow",
&["iam:GetUser"],
&["*"],
r#"{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}"#,
);
let doc: PolicyDocument = serde_json::from_str(&policy_json).unwrap();
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.source_ip("192.168.1.1")
.build()
.unwrap();
let effect =
policy_evaluator::evaluate_policy_document(&doc, "iam:GetUser", &resource, &ctx);
assert!(
effect.is_none(),
"IP 192.168.1.1 should NOT match 10.0.0.0/8 → NoMatch"
);
}
#[test]
fn test_condition_mfa_required_allows_with_mfa() {
let policy_json = policy_with_condition(
"Allow",
&["iam:DeleteUser"],
&["*"],
r#"{"Bool":{"aws:MultiFactorAuthPresent":"true"}}"#,
);
let doc: PolicyDocument = serde_json::from_str(&policy_json).unwrap();
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.mfa_present(true)
.build()
.unwrap();
let effect =
policy_evaluator::evaluate_policy_document(&doc, "iam:DeleteUser", &resource, &ctx);
assert_eq!(
effect.map(|hit| hit.effect),
Some(PolicyEffect::Allow),
"MFA present → condition matches → Allow"
);
}
#[test]
fn test_condition_mfa_required_nomatch_without_mfa() {
let policy_json = policy_with_condition(
"Allow",
&["iam:DeleteUser"],
&["*"],
r#"{"Bool":{"aws:MultiFactorAuthPresent":"true"}}"#,
);
let doc: PolicyDocument = serde_json::from_str(&policy_json).unwrap();
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(arn)
.is_root(false)
.mfa_present(false)
.build()
.unwrap();
let effect =
policy_evaluator::evaluate_policy_document(&doc, "iam:DeleteUser", &resource, &ctx);
assert!(effect.is_none(), "MFA absent → condition fails → NoMatch");
}
#[test]
fn test_condition_no_condition_still_works() {
let doc = PolicyDocument {
version: "2012-10-17".to_string(),
statement: vec![PolicyStatement {
sid: None,
effect: "Allow".to_string(),
action: vec!["iam:GetUser".to_string()],
resource: vec!["*".to_string()],
condition: None,
}],
};
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let ctx = dummy_context();
let effect =
policy_evaluator::evaluate_policy_document(&doc, "iam:GetUser", &resource, &ctx);
assert_eq!(
effect.map(|hit| hit.effect),
Some(PolicyEffect::Allow),
"No condition → Allow (backward compat)"
);
}
#[tokio::test]
async fn test_condition_deny_with_ip_restriction_full_pipeline() {
let ctx_arn: WamiArn = "arn:wami:iam:12345678:wami:999:user/alice".parse().unwrap();
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(ctx_arn)
.is_root(false)
.source_ip("203.0.113.50")
.build()
.unwrap();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
{
let mut s = store.write().await;
s.put_user_policy(
"alice",
"DenyDeleteFromExternal",
policy_with_condition(
"Deny",
&["iam:DeleteUser"],
&["*"],
r#"{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}"#,
),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let denied = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"Deny condition should fire for external IP"
);
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(allowed.is_allowed(), "iam:GetUser should still be allowed");
}
#[tokio::test]
async fn test_boundary_restricts_effective_permissions() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
let boundary = policy_builder::build_policy(
"ReadOnlyBoundary".to_string(),
allow_policy_json(&["iam:Get*", "iam:List*"], &["*"]),
None,
None,
None,
&ctx,
)
.unwrap();
let boundary_arn = boundary.arn.clone();
{
let mut s = store.write().await;
s.create_policy(boundary).await.unwrap();
}
{
let mut s = store.write().await;
let mut alice = s.get_user("alice").await.unwrap().unwrap();
alice.permissions_boundary = Some(boundary_arn);
s.update_user(alice).await.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"iam:GetUser should be allowed (within boundary)"
);
let allowed = authz
.authorize(&ctx, "iam:ListUsers", &resource)
.await
.unwrap();
assert!(
allowed.is_allowed(),
"iam:ListUsers should be allowed (within boundary)"
);
let denied = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"iam:DeleteUser should be denied (outside boundary)"
);
let denied = authz
.authorize(&ctx, "iam:CreateUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"iam:CreateUser should be denied (outside boundary)"
);
}
#[tokio::test]
async fn test_no_boundary_means_normal_evaluation() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let allowed = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(allowed.is_allowed(), "No boundary → full access per policy");
}
#[tokio::test]
async fn test_boundary_missing_policy_fails_closed() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
{
let mut s = store.write().await;
let mut alice = s.get_user("alice").await.unwrap().unwrap();
alice.permissions_boundary =
Some("arn:wami:iam:12345678:wami:999:policy/nonexistent".to_string());
s.update_user(alice).await.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let denied = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"Missing boundary policy → deny (fail closed)"
);
}
#[tokio::test]
async fn test_boundary_with_deny_policy_interaction() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
{
let mut s = store.write().await;
s.put_user_policy(
"alice",
"DenyDelete",
deny_policy_json(&["iam:DeleteUser"], &["*"]),
)
.await
.unwrap();
}
let boundary = policy_builder::build_policy(
"FullBoundary".to_string(),
allow_policy_json(&["*"], &["*"]),
None,
None,
None,
&ctx,
)
.unwrap();
let boundary_arn = boundary.arn.clone();
{
let mut s = store.write().await;
s.create_policy(boundary).await.unwrap();
let mut alice = s.get_user("alice").await.unwrap().unwrap();
alice.permissions_boundary = Some(boundary_arn);
s.update_user(alice).await.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let denied = authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap();
assert!(
!denied.is_allowed(),
"Explicit deny must win over boundary + allow"
);
let allowed = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
assert!(allowed.is_allowed(), "iam:GetUser should be allowed");
}
#[tokio::test]
async fn an_allow_names_the_statement_that_granted_it() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
store
.write()
.await
.put_user_policy(
"alice",
"AllowReads",
r#"{"Version":"2012-10-17","Statement":[{"Sid":"ReadAnything","Effect":"Allow","Action":["iam:GetUser"],"Resource":["*"]}]}"#
.to_string(),
)
.await
.unwrap();
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let decision = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
let Decision::Allow(AllowReason::Statements(refs)) = &decision else {
panic!("expected a statement-backed allow, got {decision:?}");
};
assert_eq!(refs.first().sid.as_deref(), Some("ReadAnything"));
assert_eq!(
refs.first().policy,
PolicySource::UserInline {
name: "AllowReads".to_string()
}
);
assert!(decision.to_string().contains("ReadAnything"));
}
#[tokio::test]
async fn a_deny_reports_every_source_that_denied_not_just_one() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
let group = group_builder::build_group("blocked".to_string(), None, &ctx).unwrap();
s.create_group(group).await.unwrap();
s.add_user_to_group("blocked", "alice").await.unwrap();
s.put_group_policy("blocked", "GroupDeny", deny_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
s.put_user_policy("alice", "UserDeny", deny_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let decision = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
let Decision::Deny(DenyReason::Statements(refs)) = &decision else {
panic!("expected statement-backed deny, got {decision:?}");
};
assert_eq!(refs.len(), 2, "both denials must be reported");
assert!(matches!(refs[0].policy, PolicySource::UserInline { .. }));
assert!(matches!(refs[1].policy, PolicySource::GroupInline { .. }));
}
#[tokio::test]
async fn nothing_matching_is_distinguishable_from_an_explicit_deny() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
assert_eq!(
authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap(),
Decision::Deny(DenyReason::NoMatch)
);
}
#[tokio::test]
async fn root_is_allowed_without_consulting_any_policy() {
let ctx = WamiContext::builder()
.instance_id("999")
.tenant_path(TenantPath::single(12345678))
.caller_arn(
"arn:wami:iam:12345678:wami:999:user/root"
.parse::<Arn>()
.unwrap(),
)
.is_root(true)
.build()
.unwrap();
let store = Arc::new(RwLock::new(InMemoryWamiStore::default()));
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
assert_eq!(
authz
.authorize(&ctx, "iam:DeleteUser", &resource)
.await
.unwrap(),
Decision::Allow(AllowReason::RootBypass)
);
}
async fn alice_with_boundary(
ctx: &WamiContext,
boundary: &str,
) -> Arc<RwLock<InMemoryWamiStore>> {
let store = setup_store_with_user(ctx).await;
let mut s = store.write().await;
s.put_user_policy("alice", "AllowAll", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
let policy = policy_builder::build_policy(
"Boundary".to_string(),
boundary.to_string(),
None,
None,
None,
ctx,
)
.unwrap();
let arn = policy.arn.clone();
s.create_policy(policy).await.unwrap();
let mut alice = s.get_user("alice").await.unwrap().unwrap();
alice.permissions_boundary = Some(arn);
s.update_user(alice).await.unwrap();
drop(s);
store
}
#[tokio::test]
async fn a_boundary_that_does_not_cover_the_action_says_so() {
let ctx = test_context();
let store = alice_with_boundary(&ctx, &allow_policy_json(&["s3:*"], &["*"])).await;
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let decision = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
let Decision::Deny(DenyReason::BoundaryRestricted {
allowed_by,
denied_by,
..
}) = &decision
else {
panic!("expected boundary restriction, got {decision:?}");
};
assert!(
denied_by.is_empty(),
"no explicit deny — the fix is to widen the boundary"
);
assert!(!allowed_by.is_empty(), "identity policy did allow it");
assert!(decision.to_string().contains("does not cover"));
}
#[tokio::test]
async fn a_boundary_that_explicitly_denies_names_the_statement() {
let ctx = test_context();
let store = alice_with_boundary(&ctx, &deny_policy_json(&["iam:*"], &["*"])).await;
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let decision = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
let Decision::Deny(DenyReason::BoundaryRestricted { denied_by, .. }) = &decision else {
panic!("expected boundary restriction, got {decision:?}");
};
assert_eq!(denied_by.len(), 1);
assert!(matches!(
denied_by[0].policy,
PolicySource::PermissionsBoundary { .. }
));
assert!(decision.to_string().contains("refuses this action"));
}
#[tokio::test]
async fn a_malformed_deny_no_longer_disappears() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
s.put_user_policy("alice", "Allow", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
s.put_user_policy(
"alice",
"BrokenDeny",
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Actions":["iam:*"],"Resource":["*"]}]}"#
.to_string(),
)
.await
.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let err = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.expect_err("a policy that cannot be read must not yield a decision");
assert!(matches!(err, AmiError::UnreadablePolicy { .. }), "{err:?}");
assert!(err.to_string().contains("BrokenDeny"));
}
#[tokio::test]
async fn an_unreadable_boundary_is_reported_as_the_boundary() {
let ctx = test_context();
let store = alice_with_boundary(&ctx, r#"{"Version":"2012-10-17"}"#).await;
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let err = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.expect_err("an unreadable boundary must not be treated as absent");
assert!(matches!(err, AmiError::UnreadablePolicy { .. }), "{err:?}");
assert!(err.to_string().contains("permissions boundary"));
}
#[tokio::test]
async fn a_malformed_policy_is_refused_on_write() {
use crate::service::policies::inline::InlinePolicyService;
use crate::wami::policies::inline::PutUserPolicyRequest;
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
let service = InlinePolicyService::new(store);
let err = service
.put_user_policy(
&ctx,
PutUserPolicyRequest {
user_name: "alice".to_string(),
policy_name: "BrokenDeny".to_string(),
policy_document: r#"{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Actions":["iam:*"],"Resource":["*"]}]}"#.to_string(),
},
)
.await
.expect_err("valid JSON that is not a valid policy must be refused");
assert!(matches!(err, AmiError::InvalidParameter { .. }), "{err:?}");
}
#[tokio::test]
async fn check_or_deny_quotes_the_reason_it_refused() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
store
.write()
.await
.put_user_policy("alice", "Blocked", deny_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let err = authz
.check_or_deny(&ctx, "iam:GetUser", &resource)
.await
.expect_err("an explicit deny must refuse");
let AmiError::AccessDenied { message } = &err else {
panic!("expected AccessDenied, got {err:?}");
};
assert!(message.contains("denied by"), "{message}");
assert!(message.contains("Blocked"), "{message}");
}
#[tokio::test]
async fn check_or_deny_passes_when_allowed() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
store
.write()
.await
.put_user_policy("alice", "Allow", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
authz
.check_or_deny(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
}
#[tokio::test]
async fn a_managed_policy_attached_to_the_user_is_evaluated() {
let ctx = test_context();
let store = setup_store_with_user(&ctx).await;
{
let mut s = store.write().await;
let policy = policy_builder::build_policy(
"ReadOnly".to_string(),
allow_policy_json(&["iam:GetUser"], &["*"]),
None,
None,
None,
&ctx,
)
.unwrap();
let arn = policy.arn.clone();
s.create_policy(policy).await.unwrap();
s.attach_user_policy("alice", &arn).await.unwrap();
}
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
let decision = authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap();
let Decision::Allow(AllowReason::Statements(refs)) = &decision else {
panic!("expected an allow from the managed policy, got {decision:?}");
};
assert!(matches!(
refs.first().policy,
PolicySource::UserManaged { .. }
));
}
#[tokio::test]
async fn a_missing_user_cannot_have_a_boundary_to_apply() {
let ctx = test_context();
let store = Arc::new(RwLock::new(InMemoryWamiStore::default()));
store
.write()
.await
.put_user_policy("alice", "Allow", allow_policy_json(&["iam:*"], &["*"]))
.await
.unwrap();
let authz = AuthorizationService::new(store);
let resource: WamiArn = "arn:wami:iam:12345678:wami:999:user/bob".parse().unwrap();
assert!(authz
.authorize(&ctx, "iam:GetUser", &resource)
.await
.unwrap()
.is_allowed());
}
}