alien-permissions 1.10.5

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
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};

/// AWS IAM policy statement
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct AwsIamStatement {
    /// Statement ID
    pub sid: String,
    /// Effect (Allow/Deny)
    pub effect: String,
    /// List of IAM actions
    pub action: Vec<String>,
    /// List of resource ARNs
    pub resource: Vec<String>,
    /// Optional conditions
    #[serde(skip_serializing_if = "Option::is_none")]
    pub condition: Option<IndexMap<String, IndexMap<String, String>>>,
}

/// AWS IAM policy document
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct AwsIamPolicy {
    /// Policy version
    pub version: String,
    /// List of policy statements
    pub statement: Vec<AwsIamStatement>,
}

/// Ensures every statement in a single AWS IAM policy document has a unique
/// Sid. Permission sets may intentionally split the same logical permission
/// across several IAM statements when AWS requires different resource or
/// condition blocks.
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}")
}

/// AWS runtime permissions generator for IAM policy documents
pub struct AwsRuntimePermissionsGenerator;

impl AwsRuntimePermissionsGenerator {
    /// Create a new AWS runtime permissions generator
    pub fn new() -> Self {
        Self
    }

    /// Generate an IAM policy document from a permission set and binding target
    ///
    /// Takes a PermissionSet and where to bind it, produces AWS IAM policy documents
    /// that can be created at runtime.
    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();

        // Process each AWS platform permission in the permission set
        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,
        })
    }

    /// Extract AWS conditions from binding spec
    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())
        }
    }

    /// Generate a valid IAM statement ID from a permission set ID
    fn generate_statement_id(&self, permission_set_id: &str) -> String {
        // Convert to PascalCase and remove special characters for valid AWS Sid
        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
            );
        }
    }
}