use std::collections::HashSet;
use crate::{
error::{ErrorData, Result},
generators::labels::{entry_pascal_label, has_explicit_label},
variables::VariableInterpolator,
BindingTarget, PermissionContext,
};
use alien_core::{PermissionGrant, PermissionSet};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct AwsIamStatement {
pub sid: String,
pub effect: String,
pub action: Vec<String>,
pub resource: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<IndexMap<String, IndexMap<String, String>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct AwsIamPolicy {
pub version: String,
pub statement: Vec<AwsIamStatement>,
}
pub fn ensure_unique_statement_sids(statements: &mut [AwsIamStatement]) {
let mut used = HashSet::new();
for statement in statements {
if used.insert(statement.sid.clone()) {
continue;
}
let base = statement.sid.clone();
let mut suffix = 2usize;
loop {
let candidate = suffixed_statement_sid(&base, suffix);
if used.insert(candidate.clone()) {
statement.sid = candidate;
break;
}
suffix += 1;
}
}
}
fn suffixed_statement_sid(base: &str, suffix: usize) -> String {
let suffix = suffix.to_string();
let max_base_len = 128usize.saturating_sub(suffix.len());
let trimmed = base.chars().take(max_base_len).collect::<String>();
format!("{trimmed}{suffix}")
}
pub struct AwsRuntimePermissionsGenerator;
impl AwsRuntimePermissionsGenerator {
pub fn new() -> Self {
Self
}
pub fn generate_policy(
&self,
permission_set: &PermissionSet,
binding_target: BindingTarget,
context: &PermissionContext,
) -> Result<AwsIamPolicy> {
let aws_platform_permissions = permission_set.platforms.aws.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::PlatformNotSupported {
platform: "aws".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?;
let mut statements = Vec::new();
for platform_permission in aws_platform_permissions {
let actions = platform_permission.grant.actions.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "aws".to_string(),
message: "AWS permission grant must have 'actions' field".to_string(),
})
})?;
let binding_spec = match binding_target {
BindingTarget::Stack => {
platform_permission.binding.stack.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "aws".to_string(),
binding_target: "stack".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?
}
BindingTarget::Resource => platform_permission
.binding
.resource
.as_ref()
.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "aws".to_string(),
binding_target: "resource".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?,
};
let resources =
VariableInterpolator::interpolate_string_list(&binding_spec.resources, context)?;
let conditions = self.extract_conditions(binding_spec, context)?;
let statement_id = self.statement_id(
permission_set,
&platform_permission.grant,
platform_permission.label.as_deref(),
aws_platform_permissions.len() > 1,
);
let statement = AwsIamStatement {
sid: statement_id,
effect: platform_permission.effect.as_str().to_string(),
action: actions.clone(),
resource: resources,
condition: if conditions.is_empty() {
None
} else {
Some(conditions)
},
};
statements.push(statement);
}
Ok(AwsIamPolicy {
version: "2012-10-17".to_string(),
statement: statements,
})
}
fn extract_conditions(
&self,
binding_spec: &alien_core::AwsBindingSpec,
context: &PermissionContext,
) -> Result<IndexMap<String, IndexMap<String, String>>> {
if let Some(condition_template) = &binding_spec.condition {
let mut interpolated_conditions = IndexMap::new();
for (condition_key, condition_values) in condition_template {
let condition_key =
VariableInterpolator::interpolate_variables(condition_key, context)?;
let mut interpolated_values = IndexMap::new();
for (value_key, value_template) in condition_values {
let value_key =
VariableInterpolator::interpolate_variables(value_key, context)?;
let interpolated_value =
VariableInterpolator::interpolate_variables(value_template, context)?;
interpolated_values.insert(value_key, interpolated_value);
}
interpolated_conditions.insert(condition_key, interpolated_values);
}
Ok(interpolated_conditions)
} else {
Ok(IndexMap::new())
}
}
fn generate_statement_id(&self, permission_set_id: &str) -> String {
permission_set_id
.split('/')
.map(|part| {
part.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>()
+ &chars.as_str().to_lowercase()
}
}
})
.collect::<String>()
})
.collect::<String>()
}
fn statement_id(
&self,
permission_set: &PermissionSet,
grant: &PermissionGrant,
explicit_label: Option<&str>,
include_entry_label: bool,
) -> String {
if has_explicit_label(explicit_label) {
return entry_pascal_label(explicit_label, grant);
}
let base = self.generate_statement_id(&permission_set.id);
if include_entry_label {
format!("{base}{}", entry_pascal_label(explicit_label, grant))
} else {
base
}
}
}
impl Default for AwsRuntimePermissionsGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::get_permission_set;
fn statement(sid: &str) -> AwsIamStatement {
AwsIamStatement {
sid: sid.to_string(),
effect: "Allow".to_string(),
action: vec!["ec2:DescribeInstances".to_string()],
resource: vec!["*".to_string()],
condition: None,
}
}
#[test]
fn duplicate_statement_sids_are_suffixed() {
let mut statements = vec![
statement("DuplicateSid"),
statement("DuplicateSid"),
statement("DuplicateSid"),
];
ensure_unique_statement_sids(&mut statements);
let sids = statements
.iter()
.map(|statement| statement.sid.as_str())
.collect::<Vec<_>>();
assert_eq!(sids, vec!["DuplicateSid", "DuplicateSid2", "DuplicateSid3"]);
}
#[test]
fn compute_cluster_management_policy_can_be_normalized_to_unique_sids() {
let permission_set = get_permission_set("compute-cluster/management")
.expect("compute-cluster management permission set should exist");
let context = PermissionContext::new()
.with_aws_region("us-east-1")
.with_aws_account_id("123456789012")
.with_stack_prefix("test-stack");
let generator = AwsRuntimePermissionsGenerator::new();
let mut policy = generator
.generate_policy(permission_set, BindingTarget::Stack, &context)
.expect("AWS policy should generate");
ensure_unique_statement_sids(&mut policy.statement);
let mut seen = HashSet::new();
for statement in policy.statement {
assert!(
seen.insert(statement.sid.clone()),
"duplicate AWS IAM statement Sid: {}",
statement.sid
);
}
}
}