use std::collections::BTreeMap;
use std::sync::RwLock;
use anyhow::{Context, Result};
use arc_swap::ArcSwap;
use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrincipalContext {
pub principal: String,
pub access_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthorizationContext {
pub principal: PrincipalContext,
pub action: String,
pub resource: String,
pub attributes: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompiledPolicySet {
pub source: String,
pub statements: Vec<CompiledStatement>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompiledStatement {
pub effect: StatementEffect,
pub actions: Vec<String>,
pub resources: Vec<String>,
pub principals: Vec<String>,
pub conditions: BTreeMap<String, BTreeMap<String, String>>,
}
#[derive(Debug, Clone)]
struct CompiledMatcher {
effect: StatementEffect,
action_matchers: Vec<GlobMatcher>,
resource_matchers: Vec<GlobMatcher>,
principal_matchers: Vec<GlobMatcher>,
conditions: BTreeMap<String, BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum StatementEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IamUser {
pub user_name: String,
pub policies: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IamRole {
pub role_name: String,
pub assume_role_policy: String,
pub policies: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OidcProvider {
pub arn: String,
pub url: String,
pub client_ids: Vec<String>,
pub thumbprints: Vec<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IamStoreSnapshot {
pub users: BTreeMap<String, IamUser>,
pub roles: BTreeMap<String, IamRole>,
pub oidc_providers: BTreeMap<String, OidcProvider>,
}
#[derive(Debug, Default)]
pub struct IamState {
inner: RwLock<IamStoreSnapshot>,
compiled: ArcSwap<BTreeMap<String, Vec<CompiledMatcher>>>,
}
impl IamState {
pub fn new() -> Self {
Self {
inner: RwLock::new(IamStoreSnapshot::default()),
compiled: ArcSwap::from_pointee(BTreeMap::new()),
}
}
pub fn snapshot(&self) -> IamStoreSnapshot {
self.inner.read().map(|g| g.clone()).unwrap_or_default()
}
pub fn create_user(&self, user_name: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
inner.users.entry(user_name.to_string()).or_insert(IamUser {
user_name: user_name.to_string(),
policies: BTreeMap::new(),
});
self.rebuild_compiled_locked(&inner)
}
pub fn delete_user(&self, user_name: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
inner.users.remove(user_name);
self.rebuild_compiled_locked(&inner)
}
pub fn put_user_policy(
&self,
user_name: &str,
policy_name: &str,
policy_doc: &str,
) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
let user = inner
.users
.get_mut(user_name)
.with_context(|| format!("No such user: {user_name}"))?;
user.policies
.insert(policy_name.to_string(), policy_doc.to_string());
self.rebuild_compiled_locked(&inner)
}
pub fn get_user_policy(&self, user_name: &str, policy_name: &str) -> Option<String> {
self.inner
.read()
.ok()
.and_then(|inner| inner.users.get(user_name).cloned())
.and_then(|user| user.policies.get(policy_name).cloned())
}
pub fn list_user_policies(&self, user_name: &str) -> Option<Vec<String>> {
self.inner
.read()
.ok()
.and_then(|inner| inner.users.get(user_name).cloned())
.map(|user| user.policies.keys().cloned().collect())
}
pub fn delete_user_policy(&self, user_name: &str, policy_name: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
let user = inner
.users
.get_mut(user_name)
.with_context(|| format!("No such user: {user_name}"))?;
if user.policies.remove(policy_name).is_none() {
anyhow::bail!("No such policy");
}
self.rebuild_compiled_locked(&inner)
}
pub fn create_role(&self, role_name: &str, assume_role_policy: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
inner.roles.entry(role_name.to_string()).or_insert(IamRole {
role_name: role_name.to_string(),
assume_role_policy: assume_role_policy.to_string(),
policies: BTreeMap::new(),
});
self.rebuild_compiled_locked(&inner)
}
pub fn put_role_policy(
&self,
role_name: &str,
policy_name: &str,
policy_doc: &str,
) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
let role = inner
.roles
.get_mut(role_name)
.with_context(|| format!("No such role: {role_name}"))?;
role.policies
.insert(policy_name.to_string(), policy_doc.to_string());
self.rebuild_compiled_locked(&inner)
}
pub fn get_role_policy(&self, role_name: &str, policy_name: &str) -> Option<String> {
self.inner
.read()
.ok()
.and_then(|inner| inner.roles.get(role_name).cloned())
.and_then(|role| role.policies.get(policy_name).cloned())
}
pub fn list_role_policies(&self, role_name: &str) -> Option<Vec<String>> {
self.inner
.read()
.ok()
.and_then(|inner| inner.roles.get(role_name).cloned())
.map(|role| role.policies.keys().cloned().collect())
}
pub fn delete_role_policy(&self, role_name: &str, policy_name: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
let role = inner
.roles
.get_mut(role_name)
.with_context(|| format!("No such role: {role_name}"))?;
if role.policies.remove(policy_name).is_none() {
anyhow::bail!("No such policy");
}
self.rebuild_compiled_locked(&inner)
}
pub fn create_oidc_provider(
&self,
arn: String,
url: String,
client_ids: Vec<String>,
thumbprints: Vec<String>,
) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
inner.oidc_providers.insert(
arn.clone(),
OidcProvider {
arn,
url,
client_ids,
thumbprints,
},
);
Ok(())
}
pub fn get_oidc_provider(&self, arn: &str) -> Option<OidcProvider> {
self.inner
.read()
.ok()
.and_then(|inner| inner.oidc_providers.get(arn).cloned())
}
pub fn delete_oidc_provider(&self, arn: &str) -> Result<()> {
let mut inner = self
.inner
.write()
.map_err(|_| anyhow::anyhow!("iam store poisoned"))?;
if inner.oidc_providers.remove(arn).is_none() {
anyhow::bail!("No such oidc provider");
}
Ok(())
}
pub fn evaluate(&self, ctx: &AuthorizationContext) -> bool {
if ctx.principal.principal == "arn:aws:iam:::root" {
return true;
}
let snapshot = self.compiled.load();
let Some(entries) = snapshot.get(&ctx.principal.principal) else {
return false;
};
let mut allowed = false;
for stmt in entries {
if !matches_any(&stmt.action_matchers, ctx.action.as_str()) {
continue;
}
if !matches_any(&stmt.resource_matchers, ctx.resource.as_str()) {
continue;
}
if !stmt.principal_matchers.is_empty()
&& !matches_any(&stmt.principal_matchers, ctx.principal.principal.as_str())
{
continue;
}
if !ConditionEvaluator::evaluate(&stmt.conditions, &ctx.attributes) {
continue;
}
match stmt.effect {
StatementEffect::Deny => return false,
StatementEffect::Allow => allowed = true,
}
}
allowed
}
fn rebuild_compiled_locked(&self, snapshot: &IamStoreSnapshot) -> Result<()> {
let mut compiled = BTreeMap::<String, Vec<CompiledMatcher>>::new();
for user in snapshot.users.values() {
let mut statements = Vec::new();
for (policy_name, doc) in &user.policies {
let set = compile_policy_set(policy_name, doc)?;
for statement in set.statements {
statements.push(compile_matcher(statement)?);
}
}
compiled.insert(format!("arn:aws:iam:::user/{}", user.user_name), statements);
}
for role in snapshot.roles.values() {
let mut statements = Vec::new();
for (policy_name, doc) in &role.policies {
let set = compile_policy_set(policy_name, doc)?;
for statement in set.statements {
statements.push(compile_matcher(statement)?);
}
}
compiled.insert(format!("arn:aws:iam:::role/{}", role.role_name), statements);
}
self.compiled.store(std::sync::Arc::new(compiled));
Ok(())
}
}
pub struct ConditionEvaluator;
impl ConditionEvaluator {
pub fn evaluate(
conditions: &BTreeMap<String, BTreeMap<String, String>>,
attributes: &BTreeMap<String, String>,
) -> bool {
for (operator, values) in conditions {
match operator.as_str() {
"StringEquals" => {
for (key, expected) in values {
if attributes.get(key).map(String::as_str) != Some(expected.as_str()) {
return false;
}
}
}
"BoolIfExists" => {
for (key, expected) in values {
if let Some(actual) = attributes.get(key) {
if actual != expected {
return false;
}
}
}
}
_ => {}
}
}
true
}
}
#[derive(Debug, Deserialize)]
struct PolicyDocument {
#[serde(rename = "Version")]
version: Option<String>,
#[serde(rename = "Statement")]
statement: StatementOrVec,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum StatementOrVec {
One(PolicyStatement),
Many(Vec<PolicyStatement>),
}
#[derive(Debug, Deserialize)]
struct PolicyStatement {
#[serde(rename = "Sid")]
sid: Option<String>,
#[serde(rename = "Effect")]
effect: String,
#[serde(rename = "Action")]
action: Value,
#[serde(rename = "Resource")]
resource: Value,
#[serde(rename = "Principal")]
principal: Option<Value>,
#[serde(rename = "Condition")]
condition: Option<BTreeMap<String, BTreeMap<String, String>>>,
}
pub fn compile_policy_set(source: &str, policy_document: &str) -> Result<CompiledPolicySet> {
let doc: PolicyDocument =
serde_json::from_str(policy_document).context("failed to parse IAM policy document")?;
if let Some(version) = doc.version.as_deref() {
if version != "2012-10-17" {
anyhow::bail!("invalid IAM policy version");
}
}
let mut statements = Vec::new();
let source_name = source.to_string();
let mut seen_sids = std::collections::BTreeSet::new();
let rows = match doc.statement {
StatementOrVec::One(one) => vec![one],
StatementOrVec::Many(many) => many,
};
for row in rows {
if let Some(sid) = row.sid.as_ref() {
if !seen_sids.insert(sid.clone()) {
anyhow::bail!("duplicate statement sid");
}
}
let effect = match row.effect.as_str() {
"Allow" => StatementEffect::Allow,
"Deny" => StatementEffect::Deny,
other => anyhow::bail!("invalid IAM effect: {other}"),
};
statements.push(CompiledStatement {
effect,
actions: parse_policy_values(&row.action),
resources: parse_policy_values(&row.resource),
principals: row
.principal
.as_ref()
.map(parse_principals)
.unwrap_or_default(),
conditions: row.condition.unwrap_or_default(),
});
}
Ok(CompiledPolicySet {
source: source_name,
statements,
})
}
fn compile_matcher(statement: CompiledStatement) -> Result<CompiledMatcher> {
let action_matchers = compile_globs(&statement.actions)?;
let resource_matchers = compile_globs(&statement.resources)?;
let principal_matchers = compile_globs(&statement.principals)?;
Ok(CompiledMatcher {
effect: statement.effect,
action_matchers,
resource_matchers,
principal_matchers,
conditions: statement.conditions,
})
}
fn compile_globs(inputs: &[String]) -> Result<Vec<GlobMatcher>> {
let mut out = Vec::with_capacity(inputs.len());
for pattern in inputs {
let glob =
Glob::new(pattern).with_context(|| format!("invalid IAM glob pattern: {pattern}"))?;
out.push(glob.compile_matcher());
}
Ok(out)
}
fn matches_any(matchers: &[GlobMatcher], candidate: &str) -> bool {
if matchers.is_empty() {
return true;
}
matchers.iter().any(|matcher| matcher.is_match(candidate))
}
fn parse_principals(value: &Value) -> Vec<String> {
match value {
Value::String(one) => vec![one.clone()],
Value::Array(rows) => rows
.iter()
.filter_map(|row| row.as_str().map(ToString::to_string))
.collect(),
Value::Object(map) => {
let Some(raw) = map.get("AWS") else {
return vec!["*".to_string()];
};
parse_policy_values(raw)
}
_ => vec!["*".to_string()],
}
}
fn parse_policy_values(value: &Value) -> Vec<String> {
match value {
Value::String(one) => vec![one.clone()],
Value::Array(rows) => rows
.iter()
.filter_map(|row| row.as_str().map(ToString::to_string))
.collect(),
Value::Object(_) => vec!["*".to_string()],
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_allows_matching_action_and_resource() {
let iam = IamState::new();
iam.create_user("alice").expect("create user");
iam.put_user_policy(
"alice",
"allow-all",
r#"{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::*"}}"#,
)
.expect("policy update");
let allowed = iam.evaluate(&AuthorizationContext {
principal: PrincipalContext {
principal: "arn:aws:iam:::user/alice".to_string(),
access_key: "AKIAALICE".to_string(),
},
action: "s3:PutObject".to_string(),
resource: "arn:aws:s3:::nexus/key".to_string(),
attributes: BTreeMap::new(),
});
assert!(allowed);
}
#[test]
fn explicit_deny_wins() {
let iam = IamState::new();
iam.create_user("alice").expect("create user");
iam.put_user_policy(
"alice",
"mixed",
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"arn:aws:s3:::*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::*"}]}"#,
)
.expect("policy update");
let denied = iam.evaluate(&AuthorizationContext {
principal: PrincipalContext {
principal: "arn:aws:iam:::user/alice".to_string(),
access_key: "AKIAALICE".to_string(),
},
action: "s3:DeleteObject".to_string(),
resource: "arn:aws:s3:::nexus/key".to_string(),
attributes: BTreeMap::new(),
});
assert!(!denied);
}
#[test]
fn condition_bool_if_exists_is_respected() {
let policy = compile_policy_set(
"cond",
r#"{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"BoolIfExists":{"sts:authentication":"false"}}}}"#,
)
.expect("compile policy");
assert_eq!(policy.statements.len(), 1);
let stmt = &policy.statements[0];
let mut attrs = BTreeMap::new();
attrs.insert("sts:authentication".to_string(), "true".to_string());
assert!(!ConditionEvaluator::evaluate(&stmt.conditions, &attrs));
attrs.insert("sts:authentication".to_string(), "false".to_string());
assert!(ConditionEvaluator::evaluate(&stmt.conditions, &attrs));
}
}