alien_permissions/generators/
aws_runtime.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "PascalCase")]
16pub struct AwsIamStatement {
17 pub sid: String,
19 pub effect: String,
21 pub action: Vec<String>,
23 pub resource: Vec<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub condition: Option<IndexMap<String, IndexMap<String, String>>>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "PascalCase")]
33pub struct AwsIamPolicy {
34 pub version: String,
36 pub statement: Vec<AwsIamStatement>,
38}
39
40pub 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
72pub struct AwsRuntimePermissionsGenerator;
74
75impl AwsRuntimePermissionsGenerator {
76 pub fn new() -> Self {
78 Self
79 }
80
81 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 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 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 fn generate_statement_id(&self, permission_set_id: &str) -> String {
197 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}