feat: Implement AWS policy variables support (#1131)

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
yxrxy
2025-12-16 13:32:01 +08:00
committed by GitHub
parent fe4fabb195
commit 352035a06f
18 changed files with 2169 additions and 50 deletions
+1
View File
@@ -24,6 +24,7 @@ mod principal;
pub mod resource;
pub mod statement;
pub(crate) mod utils;
pub mod variables;
pub use action::ActionSet;
pub use doc::PolicyDoc;
+12 -3
View File
@@ -13,6 +13,7 @@
// limitations under the License.
use crate::policy::function::condition::Condition;
use crate::policy::variables::PolicyVariableResolver;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize, Serializer, de};
use std::collections::HashMap;
@@ -38,20 +39,28 @@ pub struct Functions {
impl Functions {
pub fn evaluate(&self, values: &HashMap<String, Vec<String>>) -> bool {
self.evaluate_with_resolver(values, None)
}
pub fn evaluate_with_resolver(
&self,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for c in self.for_any_value.iter() {
if !c.evaluate(false, values) {
if !c.evaluate_with_resolver(false, values, resolver) {
return false;
}
}
for c in self.for_all_values.iter() {
if !c.evaluate(true, values) {
if !c.evaluate_with_resolver(true, values, resolver) {
return false;
}
}
for c in self.for_normal.iter() {
if !c.evaluate(false, values) {
if !c.evaluate_with_resolver(false, values, resolver) {
return false;
}
}
+13 -7
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::policy::variables::PolicyVariableResolver;
use serde::Deserialize;
use serde::de::{Error, MapAccess};
use serde::ser::SerializeMap;
@@ -106,16 +107,21 @@ impl Condition {
}
}
pub fn evaluate(&self, for_all: bool, values: &HashMap<String, Vec<String>>) -> bool {
pub fn evaluate_with_resolver(
&self,
for_all: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
use Condition::*;
let r = match self {
StringEquals(s) => s.evaluate(for_all, false, false, false, values),
StringNotEquals(s) => s.evaluate(for_all, false, false, true, values),
StringEqualsIgnoreCase(s) => s.evaluate(for_all, true, false, false, values),
StringNotEqualsIgnoreCase(s) => s.evaluate(for_all, true, false, true, values),
StringLike(s) => s.evaluate(for_all, false, true, false, values),
StringNotLike(s) => s.evaluate(for_all, false, true, true, values),
StringEquals(s) => s.evaluate_with_resolver(for_all, false, false, false, values, resolver),
StringNotEquals(s) => s.evaluate_with_resolver(for_all, false, false, true, values, resolver),
StringEqualsIgnoreCase(s) => s.evaluate_with_resolver(for_all, true, false, false, values, resolver),
StringNotEqualsIgnoreCase(s) => s.evaluate_with_resolver(for_all, true, false, true, values, resolver),
StringLike(s) => s.evaluate_with_resolver(for_all, false, true, false, values, resolver),
StringNotLike(s) => s.evaluate_with_resolver(for_all, false, true, true, values, resolver),
BinaryEquals(s) => s.evaluate(values),
IpAddress(s) => s.evaluate(values),
NotIpAddress(s) => s.evaluate(values),
+39 -9
View File
@@ -24,23 +24,26 @@ use crate::policy::utils::wildcard;
use serde::{Deserialize, Deserializer, Serialize, de, ser::SerializeSeq};
use super::{func::InnerFunc, key_name::KeyName};
use crate::policy::variables::{PolicyVariableResolver, resolve_aws_variables};
pub type StringFunc = InnerFunc<StringFuncValue>;
impl StringFunc {
pub(crate) fn evaluate(
#[allow(clippy::too_many_arguments)]
pub(crate) fn evaluate_with_resolver(
&self,
for_all: bool,
ignore_case: bool,
like: bool,
negate: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for inner in self.0.iter() {
let result = if like {
inner.eval_like(for_all, values) ^ negate
inner.eval_like(for_all, values, resolver) ^ negate
} else {
inner.eval(for_all, ignore_case, values) ^ negate
inner.eval(for_all, ignore_case, values, resolver) ^ negate
};
if !result {
@@ -53,7 +56,13 @@ impl StringFunc {
}
impl FuncKeyValue<StringFuncValue> {
fn eval(&self, for_all: bool, ignore_case: bool, values: &HashMap<String, Vec<String>>) -> bool {
fn eval(
&self,
for_all: bool,
ignore_case: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
let rvalues = values
// http.CanonicalHeaderKey ?
.get(self.key.name().as_str())
@@ -74,8 +83,15 @@ impl FuncKeyValue<StringFuncValue> {
.values
.0
.iter()
.map(|c| {
let mut c = Cow::from(c);
.flat_map(|c| {
if let Some(res) = resolver {
resolve_aws_variables(c, res)
} else {
vec![c.to_string()]
}
})
.map(|resolved_c| {
let mut c = Cow::from(resolved_c);
for key in KeyName::COMMON_KEYS {
match values.get(key.name()).and_then(|x| x.first()) {
Some(v) if !v.is_empty() => return Cow::Owned(c.to_mut().replace(&key.var_name(), v)),
@@ -97,15 +113,27 @@ impl FuncKeyValue<StringFuncValue> {
}
}
fn eval_like(&self, for_all: bool, values: &HashMap<String, Vec<String>>) -> bool {
fn eval_like(
&self,
for_all: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
if let Some(rvalues) = values.get(self.key.name().as_str()) {
for v in rvalues.iter() {
let matched = self
.values
.0
.iter()
.map(|c| {
let mut c = Cow::from(c);
.flat_map(|c| {
if let Some(res) = resolver {
resolve_aws_variables(c, res)
} else {
vec![c.to_string()]
}
})
.map(|resolved_c| {
let mut c = Cow::from(resolved_c);
for key in KeyName::COMMON_KEYS {
match values.get(key.name()).and_then(|x| x.first()) {
Some(v) if !v.is_empty() => return Cow::Owned(c.to_mut().replace(&key.var_name(), v)),
@@ -282,6 +310,7 @@ mod tests {
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(ToOwned::to_owned).collect::<Vec<String>>()))
.collect(),
None,
);
result ^ negate
@@ -386,6 +415,7 @@ mod tests {
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(ToOwned::to_owned).collect::<Vec<String>>()))
.collect(),
None,
);
result ^ negate
+277
View File
@@ -525,4 +525,281 @@ mod test {
// assert_eq!(p, p2);
Ok(())
}
#[tokio::test]
async fn test_aws_username_policy_variable() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-*"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let conditions = HashMap::new();
// Test allowed case - user testuser accessing testuser-bucket
let mut claims1 = HashMap::new();
claims1.insert("username".to_string(), Value::String("testuser".to_string()));
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims1,
deny_only: false,
};
// Test denied case - user otheruser accessing testuser-bucket
let mut claims2 = HashMap::new();
claims2.insert("username".to_string(), Value::String("otheruser".to_string()));
let args2 = Args {
account: "otheruser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims2,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_userid_policy_variable() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:userid}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "OTHERUSER-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_concatenation() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-${aws:userid}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("username".to_string(), Value::String("testuser".to_string()));
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "otheruser-AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_nested() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${${aws:PrincipalType}-${aws:userid}}"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
// For PrincipalType, it will default to "User" when not explicitly set
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "User-AIDACKCEVSQ6C2EXAMPLE",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "User-OTHERUSER",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_multi_value() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
// Test with array value for username
claims.insert(
"username".to_string(),
Value::Array(vec![Value::String("user1".to_string()), Value::String("user2".to_string())]),
);
let conditions = HashMap::new();
let args1 = Args {
account: "user1",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "user1-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
let args2 = Args {
account: "user2",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "user2-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Either user1 or user2 should be allowed
assert!(policy.is_allowed(&args1) || policy.is_allowed(&args2));
Ok(())
}
}
+48 -13
View File
@@ -24,6 +24,7 @@ use super::{
Error as IamError, Validator,
function::key_name::KeyName,
utils::{path, wildcard},
variables::{PolicyVariableResolver, resolve_aws_variables},
};
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
@@ -31,8 +32,17 @@ pub struct ResourceSet(pub HashSet<Resource>);
impl ResourceSet {
pub fn is_match(&self, resource: &str, conditions: &HashMap<String, Vec<String>>) -> bool {
self.is_match_with_resolver(resource, conditions, None)
}
pub fn is_match_with_resolver(
&self,
resource: &str,
conditions: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for re in self.0.iter() {
if re.is_match(resource, conditions) {
if re.is_match_with_resolver(resource, conditions, resolver) {
return true;
}
}
@@ -86,26 +96,51 @@ impl Resource {
pub const S3_PREFIX: &'static str = "arn:aws:s3:::";
pub fn is_match(&self, resource: &str, conditions: &HashMap<String, Vec<String>>) -> bool {
let mut pattern = match self {
self.is_match_with_resolver(resource, conditions, None)
}
pub fn is_match_with_resolver(
&self,
resource: &str,
conditions: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
let pattern = match self {
Resource::S3(s) => s.to_owned(),
Resource::Kms(s) => s.to_owned(),
};
if !conditions.is_empty() {
for key in KeyName::COMMON_KEYS {
if let Some(rvalue) = conditions.get(key.name()) {
if matches!(rvalue.first().map(|c| !c.is_empty()), Some(true)) {
pattern = pattern.replace(&key.var_name(), &rvalue[0]);
let patterns = if let Some(res) = resolver {
resolve_aws_variables(&pattern, res)
} else {
vec![pattern.clone()]
};
for pattern in patterns {
let mut resolved_pattern = pattern;
// Apply condition substitutions
if !conditions.is_empty() {
for key in KeyName::COMMON_KEYS {
if let Some(rvalue) = conditions.get(key.name()) {
if matches!(rvalue.first().map(|c| !c.is_empty()), Some(true)) {
resolved_pattern = resolved_pattern.replace(&key.var_name(), &rvalue[0]);
}
}
}
}
let cp = path::clean(resource);
if cp != "." && cp == resolved_pattern.as_str() {
return true;
}
if wildcard::is_match(resolved_pattern, resource) {
return true;
}
}
let cp = path::clean(resource);
if cp != "." && cp == pattern.as_str() {
return true;
}
wildcard::is_match(pattern, resource)
false
}
pub fn match_resource(&self, resource: &str) -> bool {
+26 -3
View File
@@ -15,6 +15,7 @@
use super::{
ActionSet, Args, BucketPolicyArgs, Effect, Error as IamError, Functions, ID, Principal, ResourceSet, Validator,
action::Action,
variables::{VariableContext, VariableResolver},
};
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
@@ -69,6 +70,23 @@ impl Statement {
}
pub fn is_allowed(&self, args: &Args) -> bool {
let mut context = VariableContext::new();
context.claims = Some(args.claims.clone());
context.conditions = args.conditions.clone();
context.account_id = Some(args.account.to_string());
let username = if let Some(parent) = args.claims.get("parent").and_then(|v| v.as_str()) {
// For temp credentials or service account credentials, username is parent_user
parent.to_string()
} else {
// For regular user credentials, username is access_key
args.account.to_string()
};
context.username = Some(username);
let resolver = VariableResolver::new(context);
let check = 'c: {
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
break 'c false;
@@ -86,14 +104,19 @@ impl Statement {
}
if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
break 'c self.conditions.evaluate(args.conditions);
break 'c self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver));
}
if !self.resources.is_match(&resource, args.conditions) && !self.is_admin() && !self.is_sts() {
if !self
.resources
.is_match_with_resolver(&resource, args.conditions, Some(&resolver))
&& !self.is_admin()
&& !self.is_sts()
{
break 'c false;
}
self.conditions.evaluate(args.conditions)
self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver))
};
self.effect.is_allowed(check)
+491
View File
@@ -0,0 +1,491 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use lru::LruCache;
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::time::{Duration, Instant};
use time::OffsetDateTime;
/// Context information for variable resolution
#[derive(Debug, Clone)]
pub struct VariableContext {
pub is_https: bool,
pub source_ip: Option<String>,
pub account_id: Option<String>,
pub region: Option<String>,
pub username: Option<String>,
pub claims: Option<HashMap<String, Value>>,
pub conditions: HashMap<String, Vec<String>>,
pub custom_variables: HashMap<String, String>,
}
impl VariableContext {
pub fn new() -> Self {
Self {
is_https: false,
source_ip: None,
account_id: None,
region: None,
username: None,
claims: None,
conditions: HashMap::new(),
custom_variables: HashMap::new(),
}
}
}
impl Default for VariableContext {
fn default() -> Self {
Self::new()
}
}
/// Variable resolution cache
struct CachedVariable {
value: String,
timestamp: Instant,
is_dynamic: bool,
}
pub struct VariableResolverCache {
/// LRU cache storing resolved results
cache: LruCache<String, CachedVariable>,
/// Cache expiration time
ttl: Duration,
}
impl VariableResolverCache {
pub fn new(capacity: usize, ttl_seconds: u64) -> Self {
Self {
cache: LruCache::new(usize::from(NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(100).unwrap()))),
ttl: Duration::from_secs(ttl_seconds),
}
}
pub fn get(&mut self, key: &str) -> Option<String> {
if let Some(cached) = self.cache.get(key) {
// Check if expired
if !cached.is_dynamic && cached.timestamp.elapsed() < self.ttl {
return Some(cached.value.clone());
}
}
None
}
pub fn put(&mut self, key: String, value: String, is_dynamic: bool) {
let cached = CachedVariable {
value,
timestamp: Instant::now(),
is_dynamic,
};
self.cache.put(key, cached);
}
pub fn clear(&mut self) {
self.cache.clear();
}
}
/// Cached dynamic AWS variable resolver
pub struct CachedAwsVariableResolver {
inner: VariableResolver,
cache: RefCell<VariableResolverCache>,
}
impl CachedAwsVariableResolver {
pub fn new(context: VariableContext) -> Self {
Self {
inner: VariableResolver::new(context),
cache: RefCell::new(VariableResolverCache::new(100, 300)), // 100 entries, 5 minutes expiration
}
}
}
impl PolicyVariableResolver for CachedAwsVariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String> {
if self.is_dynamic(variable_name) {
return self.inner.resolve(variable_name);
}
if let Some(cached) = self.cache.borrow_mut().get(variable_name) {
return Some(cached);
}
let value = self.inner.resolve(variable_name)?;
self.cache.borrow_mut().put(variable_name.to_string(), value.clone(), false);
Some(value)
}
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
if self.is_dynamic(variable_name) {
return self.inner.resolve_multiple(variable_name);
}
self.inner.resolve_multiple(variable_name)
}
fn is_dynamic(&self, variable_name: &str) -> bool {
self.inner.is_dynamic(variable_name)
}
}
/// Policy variable resolver trait
pub trait PolicyVariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String>;
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
self.resolve(variable_name).map(|s| vec![s])
}
fn is_dynamic(&self, variable_name: &str) -> bool;
}
/// AWS variable resolver
pub struct VariableResolver {
context: VariableContext,
}
impl VariableResolver {
pub fn new(context: VariableContext) -> Self {
Self { context }
}
fn get_claim_as_strings(&self, claim_name: &str) -> Option<Vec<String>> {
self.context
.claims
.as_ref()
.and_then(|claims| claims.get(claim_name))
.and_then(|value| match value {
Value::String(s) => Some(vec![s.clone()]),
Value::Array(arr) => Some(
arr.iter()
.filter_map(|item| match item {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.collect(),
),
Value::Number(n) => Some(vec![n.to_string()]),
Value::Bool(b) => Some(vec![b.to_string()]),
_ => None,
})
}
fn resolve_username(&self) -> Option<String> {
self.context.username.clone()
}
fn resolve_userid(&self) -> Option<String> {
// Check claims for sub or parent
if let Some(claims) = &self.context.claims {
if let Some(sub) = claims.get("sub").and_then(|v| v.as_str()) {
return Some(sub.to_string());
}
if let Some(parent) = claims.get("parent").and_then(|v| v.as_str()) {
return Some(parent.to_string());
}
}
None
}
fn resolve_principal_type(&self) -> String {
if let Some(claims) = &self.context.claims {
if claims.contains_key("roleArn") {
return "AssumedRole".to_string();
}
if claims.contains_key("parent") && claims.contains_key("sa-policy") {
return "ServiceAccount".to_string();
}
}
"User".to_string()
}
fn resolve_secure_transport(&self) -> String {
if self.context.is_https { "true" } else { "false" }.to_string()
}
fn resolve_current_time(&self) -> String {
let now = OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| now.to_string())
}
fn resolve_epoch_time(&self) -> String {
OffsetDateTime::now_utc().unix_timestamp().to_string()
}
fn resolve_account_id(&self) -> Option<String> {
self.context.account_id.clone()
}
fn resolve_region(&self) -> Option<String> {
self.context.region.clone()
}
fn resolve_source_ip(&self) -> Option<String> {
self.context.source_ip.clone()
}
fn resolve_custom_variable(&self, variable_name: &str) -> Option<String> {
let custom_key = variable_name.strip_prefix("custom:")?;
self.context.custom_variables.get(custom_key).cloned()
}
}
impl PolicyVariableResolver for VariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String> {
match variable_name {
"aws:username" => self.resolve_username(),
"aws:userid" => self.resolve_userid(),
"aws:PrincipalType" => Some(self.resolve_principal_type()),
"aws:SecureTransport" => Some(self.resolve_secure_transport()),
"aws:CurrentTime" => Some(self.resolve_current_time()),
"aws:EpochTime" => Some(self.resolve_epoch_time()),
"aws:AccountId" => self.resolve_account_id(),
"aws:Region" => self.resolve_region(),
"aws:SourceIp" => self.resolve_source_ip(),
_ => {
// Handle custom:* variables
if variable_name.starts_with("custom:") {
self.resolve_custom_variable(variable_name)
} else {
None
}
}
}
}
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
match variable_name {
"aws:username" => {
// Check context.username
if let Some(ref username) = self.context.username {
Some(vec![username.clone()])
} else {
None
}
}
"aws:userid" => {
// Check claims for sub or parent
self.get_claim_as_strings("sub")
.or_else(|| self.get_claim_as_strings("parent"))
}
_ => self.resolve(variable_name).map(|s| vec![s]),
}
}
fn is_dynamic(&self, variable_name: &str) -> bool {
matches!(variable_name, "aws:CurrentTime" | "aws:EpochTime")
}
}
/// Dynamically resolve AWS variables
pub fn resolve_aws_variables(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
let mut results = vec![pattern.to_string()];
let mut changed = true;
let max_iterations = 10; // Prevent infinite loops
let mut iteration = 0;
while changed && iteration < max_iterations {
changed = false;
iteration += 1;
let mut new_results = Vec::new();
for result in &results {
let resolved = resolve_single_pass(result, resolver);
if resolved.len() > 1 || (resolved.len() == 1 && &resolved[0] != result) {
changed = true;
}
new_results.extend(resolved);
}
// Remove duplicates while preserving order
results.clear();
let mut seen = std::collections::HashSet::new();
for result in new_results {
if seen.insert(result.clone()) {
results.push(result);
}
}
}
results
}
/// Single pass resolution of variables in a string
fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
// Find all ${...} format variables
let mut results = vec![pattern.to_string()];
// Process each result string
let mut i = 0;
while i < results.len() {
let mut start = 0;
let mut modified = false;
// Find variables in current string
while let Some(pos) = results[i][start..].find("${") {
let actual_pos = start + pos;
// Find the matching closing brace, taking into account nested braces
let mut brace_count = 1;
let mut end_pos = actual_pos + 2; // Start after "${"
while end_pos < results[i].len() && brace_count > 0 {
match results[i].chars().nth(end_pos).unwrap() {
'{' => brace_count += 1,
'}' => brace_count -= 1,
_ => {}
}
if brace_count > 0 {
end_pos += 1;
}
}
if brace_count == 0 {
let var_name = &results[i][actual_pos + 2..end_pos];
// Check if this is a nested variable (contains ${...} inside)
if var_name.contains("${") {
// For nested variables like ${${a}-${b}}, we need to resolve the inner variables first
// Then use the resolved result as a new variable to resolve
let resolved_inner = resolve_aws_variables(var_name, resolver);
let mut new_results = Vec::new();
for resolved_var_name in resolved_inner {
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{resolved_var_name}{suffix}"));
}
if !new_results.is_empty() {
// Update result set
results.splice(i..i + 1, new_results);
modified = true;
break;
} else {
// If we couldn't resolve the nested variable, keep the original
start = end_pos + 1;
}
} else {
// Regular variable resolution
if let Some(values) = resolver.resolve_multiple(var_name) {
if !values.is_empty() {
// If there are multiple values, create a new result for each value
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
for value in values {
new_results.push(format!("{prefix}{value}{suffix}"));
}
results.splice(i..i + 1, new_results);
modified = true;
break;
} else {
// Variable resolved to empty, just remove the variable placeholder
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{suffix}"));
results.splice(i..i + 1, new_results);
modified = true;
break;
}
} else {
// Variable not found, skip
start = end_pos + 1;
}
}
} else {
// No matching closing brace found, break loop
break;
}
}
if !modified {
i += 1;
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::collections::HashMap;
#[test]
fn test_resolve_aws_variables_with_username() {
let mut context = VariableContext::new();
context.username = Some("testuser".to_string());
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:username}-bucket", &resolver);
assert_eq!(result, vec!["testuser-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_with_userid() {
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let mut context = VariableContext::new();
context.claims = Some(claims);
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:userid}-bucket", &resolver);
assert_eq!(result, vec!["AIDACKCEVSQ6C2EXAMPLE-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_with_multiple_variables() {
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let mut context = VariableContext::new();
context.claims = Some(claims);
context.username = Some("testuser".to_string());
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:username}-${aws:userid}-bucket", &resolver);
assert_eq!(result, vec!["testuser-AIDACKCEVSQ6C2EXAMPLE-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_no_variables() {
let context = VariableContext::new();
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("test-bucket", &resolver);
assert_eq!(result, vec!["test-bucket".to_string()]);
}
}