Skip to main content

alien_permissions/generators/
aws_runtime.rs

1use std::collections::HashSet;
2
3use crate::{
4    error::{ErrorData, Result},
5    generators::labels::{entry_pascal_label, has_explicit_label},
6    variables::VariableInterpolator,
7    BindingTarget, PermissionContext,
8};
9use alien_core::{PermissionGrant, PermissionSet};
10use indexmap::IndexMap;
11use serde::{Deserialize, Serialize};
12
13/// AWS IAM policy statement
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "PascalCase")]
16pub struct AwsIamStatement {
17    /// Statement ID
18    pub sid: String,
19    /// Effect (Allow/Deny)
20    pub effect: String,
21    /// List of IAM actions
22    pub action: Vec<String>,
23    /// List of resource ARNs
24    pub resource: Vec<String>,
25    /// Optional conditions
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub condition: Option<IndexMap<String, IndexMap<String, String>>>,
28}
29
30/// AWS IAM policy document
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "PascalCase")]
33pub struct AwsIamPolicy {
34    /// Policy version
35    pub version: String,
36    /// List of policy statements
37    pub statement: Vec<AwsIamStatement>,
38}
39
40/// Ensures every statement in a single AWS IAM policy document has a unique
41/// Sid. Permission sets may intentionally split the same logical permission
42/// across several IAM statements when AWS requires different resource or
43/// condition blocks.
44pub fn ensure_unique_statement_sids(statements: &mut [AwsIamStatement]) {
45    let mut used = HashSet::new();
46
47    for statement in statements {
48        if used.insert(statement.sid.clone()) {
49            continue;
50        }
51
52        let base = statement.sid.clone();
53        let mut suffix = 2usize;
54        loop {
55            let candidate = suffixed_statement_sid(&base, suffix);
56            if used.insert(candidate.clone()) {
57                statement.sid = candidate;
58                break;
59            }
60            suffix += 1;
61        }
62    }
63}
64
65fn suffixed_statement_sid(base: &str, suffix: usize) -> String {
66    let suffix = suffix.to_string();
67    let max_base_len = 128usize.saturating_sub(suffix.len());
68    let trimmed = base.chars().take(max_base_len).collect::<String>();
69    format!("{trimmed}{suffix}")
70}
71
72/// AWS runtime permissions generator for IAM policy documents
73pub struct AwsRuntimePermissionsGenerator;
74
75impl AwsRuntimePermissionsGenerator {
76    /// Create a new AWS runtime permissions generator
77    pub fn new() -> Self {
78        Self
79    }
80
81    /// Generate an IAM policy document from a permission set and binding target
82    ///
83    /// Takes a PermissionSet and where to bind it, produces AWS IAM policy documents
84    /// that can be created at runtime.
85    pub fn generate_policy(
86        &self,
87        permission_set: &PermissionSet,
88        binding_target: BindingTarget,
89        context: &PermissionContext,
90    ) -> Result<AwsIamPolicy> {
91        let aws_platform_permissions = permission_set.platforms.aws.as_ref().ok_or_else(|| {
92            alien_error::AlienError::new(ErrorData::PlatformNotSupported {
93                platform: "aws".to_string(),
94                permission_set_id: permission_set.id.clone(),
95            })
96        })?;
97
98        let mut statements = Vec::new();
99
100        // Process each AWS platform permission in the permission set
101        for platform_permission in aws_platform_permissions {
102            let actions = platform_permission.grant.actions.as_ref().ok_or_else(|| {
103                alien_error::AlienError::new(ErrorData::GeneratorError {
104                    platform: "aws".to_string(),
105                    message: "AWS permission grant must have 'actions' field".to_string(),
106                })
107            })?;
108
109            let binding_spec = match binding_target {
110                BindingTarget::Stack => {
111                    platform_permission.binding.stack.as_ref().ok_or_else(|| {
112                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
113                            platform: "aws".to_string(),
114                            binding_target: "stack".to_string(),
115                            permission_set_id: permission_set.id.clone(),
116                        })
117                    })?
118                }
119                BindingTarget::Resource => platform_permission
120                    .binding
121                    .resource
122                    .as_ref()
123                    .ok_or_else(|| {
124                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
125                            platform: "aws".to_string(),
126                            binding_target: "resource".to_string(),
127                            permission_set_id: permission_set.id.clone(),
128                        })
129                    })?,
130            };
131
132            let resources =
133                VariableInterpolator::interpolate_string_list(&binding_spec.resources, context)?;
134            let conditions = self.extract_conditions(binding_spec, context)?;
135
136            let statement_id = self.statement_id(
137                permission_set,
138                &platform_permission.grant,
139                platform_permission.label.as_deref(),
140                aws_platform_permissions.len() > 1,
141            );
142
143            let statement = AwsIamStatement {
144                sid: statement_id,
145                effect: platform_permission.effect.as_str().to_string(),
146                action: actions.clone(),
147                resource: resources,
148                condition: if conditions.is_empty() {
149                    None
150                } else {
151                    Some(conditions)
152                },
153            };
154
155            statements.push(statement);
156        }
157
158        Ok(AwsIamPolicy {
159            version: "2012-10-17".to_string(),
160            statement: statements,
161        })
162    }
163
164    /// Extract AWS conditions from binding spec
165    fn extract_conditions(
166        &self,
167        binding_spec: &alien_core::AwsBindingSpec,
168        context: &PermissionContext,
169    ) -> Result<IndexMap<String, IndexMap<String, String>>> {
170        if let Some(condition_template) = &binding_spec.condition {
171            let mut interpolated_conditions = IndexMap::new();
172
173            for (condition_key, condition_values) in condition_template {
174                let condition_key =
175                    VariableInterpolator::interpolate_variables(condition_key, context)?;
176                let mut interpolated_values = IndexMap::new();
177
178                for (value_key, value_template) in condition_values {
179                    let value_key =
180                        VariableInterpolator::interpolate_variables(value_key, context)?;
181                    let interpolated_value =
182                        VariableInterpolator::interpolate_variables(value_template, context)?;
183                    interpolated_values.insert(value_key, interpolated_value);
184                }
185
186                interpolated_conditions.insert(condition_key, interpolated_values);
187            }
188
189            Ok(interpolated_conditions)
190        } else {
191            Ok(IndexMap::new())
192        }
193    }
194
195    /// Generate a valid IAM statement ID from a permission set ID
196    fn generate_statement_id(&self, permission_set_id: &str) -> String {
197        // Convert to PascalCase and remove special characters for valid AWS Sid
198        permission_set_id
199            .split('/')
200            .map(|part| {
201                part.split('-')
202                    .map(|word| {
203                        let mut chars = word.chars();
204                        match chars.next() {
205                            None => String::new(),
206                            Some(first) => {
207                                first.to_uppercase().collect::<String>()
208                                    + &chars.as_str().to_lowercase()
209                            }
210                        }
211                    })
212                    .collect::<String>()
213            })
214            .collect::<String>()
215    }
216
217    fn statement_id(
218        &self,
219        permission_set: &PermissionSet,
220        grant: &PermissionGrant,
221        explicit_label: Option<&str>,
222        include_entry_label: bool,
223    ) -> String {
224        if has_explicit_label(explicit_label) {
225            return entry_pascal_label(explicit_label, grant);
226        }
227
228        let base = self.generate_statement_id(&permission_set.id);
229        if include_entry_label {
230            format!("{base}{}", entry_pascal_label(explicit_label, grant))
231        } else {
232            base
233        }
234    }
235}
236
237impl Default for AwsRuntimePermissionsGenerator {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::registry::get_permission_set;
247
248    fn statement(sid: &str) -> AwsIamStatement {
249        AwsIamStatement {
250            sid: sid.to_string(),
251            effect: "Allow".to_string(),
252            action: vec!["ec2:DescribeInstances".to_string()],
253            resource: vec!["*".to_string()],
254            condition: None,
255        }
256    }
257
258    #[test]
259    fn duplicate_statement_sids_are_suffixed() {
260        let mut statements = vec![
261            statement("DuplicateSid"),
262            statement("DuplicateSid"),
263            statement("DuplicateSid"),
264        ];
265
266        ensure_unique_statement_sids(&mut statements);
267
268        let sids = statements
269            .iter()
270            .map(|statement| statement.sid.as_str())
271            .collect::<Vec<_>>();
272        assert_eq!(sids, vec!["DuplicateSid", "DuplicateSid2", "DuplicateSid3"]);
273    }
274
275    #[test]
276    fn compute_cluster_management_policy_can_be_normalized_to_unique_sids() {
277        let permission_set = get_permission_set("compute-cluster/management")
278            .expect("compute-cluster management permission set should exist");
279        let context = PermissionContext::new()
280            .with_aws_region("us-east-1")
281            .with_aws_account_id("123456789012")
282            .with_stack_prefix("test-stack");
283
284        let generator = AwsRuntimePermissionsGenerator::new();
285        let mut policy = generator
286            .generate_policy(permission_set, BindingTarget::Stack, &context)
287            .expect("AWS policy should generate");
288        ensure_unique_statement_sids(&mut policy.statement);
289
290        let mut seen = HashSet::new();
291        for statement in policy.statement {
292            assert!(
293                seen.insert(statement.sid.clone()),
294                "duplicate AWS IAM statement Sid: {}",
295                statement.sid
296            );
297        }
298    }
299}