alien_permissions/
initial_setup.rs1use std::collections::HashSet;
9
10use alien_core::{ownership_policy_for_resource_type, Stack};
11
12use crate::generators::{AwsIamPolicy, AwsRuntimePermissionsGenerator};
13use crate::registry::get_permission_set;
14use crate::{BindingTarget, PermissionContext};
15
16fn normalize_resource_type(resource_type: &str) -> String {
20 resource_type.replace('_', "-")
21}
22
23pub fn initial_setup_permission_set_ids(stack: &Stack) -> Vec<String> {
31 let mut set_ids = Vec::new();
32
33 for (_, resource_entry) in stack.resources() {
34 let raw_resource_type = resource_entry.config.resource_type();
35 let raw_resource_type = raw_resource_type.as_ref();
36 let policy = ownership_policy_for_resource_type(raw_resource_type);
37 if !policy.should_emit_in_setup(resource_entry.lifecycle) {
38 continue;
39 }
40
41 let resource_type = normalize_resource_type(raw_resource_type);
42 let provision_id = format!("{resource_type}/provision");
43
44 if get_permission_set(&provision_id).is_some() && !set_ids.contains(&provision_id) {
45 set_ids.push(provision_id);
46 }
47 }
48
49 let cross_cutting = ["service-account/provision"];
50
51 for id in cross_cutting {
52 if get_permission_set(id).is_some() && !set_ids.contains(&id.to_string()) {
53 set_ids.push(id.to_string());
54 }
55 }
56
57 set_ids
58}
59
60pub fn generate_aws_initial_setup_policy(
70 context: &PermissionContext,
71) -> crate::error::Result<AwsIamPolicy> {
72 let generator = AwsRuntimePermissionsGenerator::new();
73 let all_provision_ids = crate::registry::list_permission_set_ids()
74 .into_iter()
75 .filter(|id| {
76 let Some((resource_type, operation)) = id.split_once('/') else {
77 return false;
78 };
79 if operation != "provision" {
80 return false;
81 }
82 ownership_policy_for_resource_type(resource_type)
83 .should_emit_in_setup(alien_core::ResourceLifecycle::Frozen)
84 })
85 .collect::<Vec<_>>();
86
87 let mut all_statements = Vec::new();
88
89 for perm_id in &all_provision_ids {
90 if let Some(perm_set) = get_permission_set(perm_id) {
91 match generator.generate_policy(perm_set, BindingTarget::Stack, context) {
92 Ok(policy) => {
93 all_statements.extend(policy.statement);
94 }
95 Err(_) => {
96 }
98 }
99 }
100 }
101
102 ensure_unique_statement_sids(&mut all_statements);
103
104 Ok(AwsIamPolicy {
105 version: "2012-10-17".to_string(),
106 statement: all_statements,
107 })
108}
109
110fn ensure_unique_statement_sids(statements: &mut [crate::generators::AwsIamStatement]) {
111 let mut used = HashSet::new();
112
113 for statement in statements {
114 if used.insert(statement.sid.clone()) {
115 continue;
116 }
117
118 let base = statement.sid.clone();
119 let mut suffix = 2usize;
120 loop {
121 let candidate = suffixed_statement_sid(&base, suffix);
122 if used.insert(candidate.clone()) {
123 statement.sid = candidate;
124 break;
125 }
126 suffix += 1;
127 }
128 }
129}
130
131fn suffixed_statement_sid(base: &str, suffix: usize) -> String {
132 let suffix = suffix.to_string();
133 let max_base_len = 128usize.saturating_sub(suffix.len());
134 let trimmed = base.chars().take(max_base_len).collect::<String>();
135 format!("{trimmed}{suffix}")
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use alien_core::{ResourceLifecycle, Storage, Worker, WorkerCode};
142
143 fn test_function(name: &str) -> Worker {
144 Worker::new(name.to_string())
145 .code(WorkerCode::Image {
146 image: "rust:latest".to_string(),
147 })
148 .permissions("execution".to_string())
149 .build()
150 }
151
152 #[test]
153 fn live_function_stack_excludes_function_provision() {
154 let worker = test_function("my-fn");
155
156 let stack = Stack::new("test-stack".to_string())
157 .add(worker, ResourceLifecycle::Live)
158 .build();
159
160 let ids = initial_setup_permission_set_ids(&stack);
161 assert!(
162 !ids.contains(&"worker/provision".to_string()),
163 "worker/provision belongs to management permissions, got {ids:?}"
164 );
165 }
166
167 #[test]
168 fn storage_stack_includes_storage_provision() {
169 let storage = Storage::new("my-bucket".to_string()).build();
170
171 let stack = Stack::new("test-stack".to_string())
172 .add(storage, ResourceLifecycle::Frozen)
173 .build();
174
175 let ids = initial_setup_permission_set_ids(&stack);
176 assert!(
177 ids.contains(&"storage/provision".to_string()),
178 "Expected storage/provision in {ids:?}"
179 );
180 }
181
182 #[test]
183 fn cross_cutting_service_account_always_included() {
184 let stack = Stack::new("empty-stack".to_string()).build();
185
186 let ids = initial_setup_permission_set_ids(&stack);
187 assert!(
188 ids.contains(&"service-account/provision".to_string()),
189 "Expected service-account/provision in {ids:?}"
190 );
191 }
192
193 #[test]
194 fn no_duplicates() {
195 let s1 = Storage::new("bucket-a".to_string()).build();
196 let s2 = Storage::new("bucket-b".to_string()).build();
197
198 let stack = Stack::new("test-stack".to_string())
199 .add(s1, ResourceLifecycle::Frozen)
200 .add(s2, ResourceLifecycle::Frozen)
201 .build();
202
203 let ids = initial_setup_permission_set_ids(&stack);
204 let storage_count = ids.iter().filter(|id| *id == "storage/provision").count();
205 assert_eq!(
206 storage_count, 1,
207 "storage/provision should appear exactly once"
208 );
209 }
210
211 #[test]
212 fn combined_stack_includes_all_resource_types() {
213 let worker = test_function("my-fn");
214 let storage = Storage::new("my-bucket".to_string()).build();
215
216 let stack = Stack::new("test-stack".to_string())
217 .add(worker, ResourceLifecycle::Live)
218 .add(storage, ResourceLifecycle::Frozen)
219 .build();
220
221 let ids = initial_setup_permission_set_ids(&stack);
222 assert!(!ids.contains(&"worker/provision".to_string()));
223 assert!(ids.contains(&"storage/provision".to_string()));
224 assert!(ids.contains(&"service-account/provision".to_string()));
225 }
226
227 #[test]
228 fn complete_aws_initial_setup_policy_excludes_live_only_provision_sets() {
229 let context = PermissionContext::new()
230 .with_aws_region("us-east-1")
231 .with_aws_account_id("123456789012")
232 .with_stack_prefix("test-stack")
233 .with_resource_name("test");
234
235 let policy = generate_aws_initial_setup_policy(&context).unwrap();
236 let actions = policy
237 .statement
238 .iter()
239 .flat_map(|statement| statement.action.iter())
240 .collect::<Vec<_>>();
241
242 assert!(
243 !actions.contains(&&"lambda:CreateFunction".to_string()),
244 "setup policy must not include live worker provision actions"
245 );
246 assert!(
247 actions.iter().any(|action| action.starts_with("s3:")),
248 "setup policy should still include frozen-capable resource actions"
249 );
250 }
251
252 #[test]
253 fn complete_aws_initial_setup_policy_has_unique_statement_sids() {
254 let context = PermissionContext::new()
255 .with_aws_region("us-east-1")
256 .with_aws_account_id("123456789012")
257 .with_stack_prefix("test-stack")
258 .with_resource_name("test");
259
260 let policy = generate_aws_initial_setup_policy(&context).unwrap();
261 let mut seen = HashSet::new();
262
263 for statement in policy.statement {
264 assert!(
265 seen.insert(statement.sid.clone()),
266 "duplicate AWS IAM statement Sid: {}",
267 statement.sid
268 );
269 }
270 }
271
272 #[test]
273 fn complete_aws_initial_setup_policy_can_create_remote_management_policies() {
274 let context = PermissionContext::new()
275 .with_aws_region("us-east-1")
276 .with_aws_account_id("123456789012")
277 .with_stack_prefix("test-stack")
278 .with_resource_name("test");
279
280 let policy = generate_aws_initial_setup_policy(&context).unwrap();
281 let statements = policy
282 .statement
283 .iter()
284 .filter(|statement| statement.action.contains(&"iam:CreatePolicy".to_string()))
285 .collect::<Vec<_>>();
286
287 assert!(
288 statements.iter().any(|statement| statement.resource.contains(
289 &"arn:aws:iam::123456789012:policy/test-stack-deployment-management-*"
290 .to_string()
291 )),
292 "initial setup policy must be able to create remote-stack-management managed policies, got {statements:?}"
293 );
294 }
295}