alien_permissions/
initial_setup.rs1use alien_core::{ownership_policy_for_resource_type, Stack};
9
10use crate::generators::{
11 ensure_unique_statement_sids, AwsIamPolicy, AwsRuntimePermissionsGenerator,
12};
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
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use alien_core::{ResourceLifecycle, Storage, Worker, WorkerCode};
114 use std::collections::HashSet;
115
116 fn test_function(name: &str) -> Worker {
117 Worker::new(name.to_string())
118 .code(WorkerCode::Image {
119 image: "rust:latest".to_string(),
120 })
121 .permissions("execution".to_string())
122 .build()
123 }
124
125 #[test]
126 fn live_function_stack_excludes_function_provision() {
127 let worker = test_function("my-fn");
128
129 let stack = Stack::new("test-stack".to_string())
130 .add(worker, ResourceLifecycle::Live)
131 .build();
132
133 let ids = initial_setup_permission_set_ids(&stack);
134 assert!(
135 !ids.contains(&"worker/provision".to_string()),
136 "worker/provision belongs to management permissions, got {ids:?}"
137 );
138 }
139
140 #[test]
141 fn storage_stack_includes_storage_provision() {
142 let storage = Storage::new("my-bucket".to_string()).build();
143
144 let stack = Stack::new("test-stack".to_string())
145 .add(storage, ResourceLifecycle::Frozen)
146 .build();
147
148 let ids = initial_setup_permission_set_ids(&stack);
149 assert!(
150 ids.contains(&"storage/provision".to_string()),
151 "Expected storage/provision in {ids:?}"
152 );
153 }
154
155 #[test]
156 fn cross_cutting_service_account_always_included() {
157 let stack = Stack::new("empty-stack".to_string()).build();
158
159 let ids = initial_setup_permission_set_ids(&stack);
160 assert!(
161 ids.contains(&"service-account/provision".to_string()),
162 "Expected service-account/provision in {ids:?}"
163 );
164 }
165
166 #[test]
167 fn no_duplicates() {
168 let s1 = Storage::new("bucket-a".to_string()).build();
169 let s2 = Storage::new("bucket-b".to_string()).build();
170
171 let stack = Stack::new("test-stack".to_string())
172 .add(s1, ResourceLifecycle::Frozen)
173 .add(s2, ResourceLifecycle::Frozen)
174 .build();
175
176 let ids = initial_setup_permission_set_ids(&stack);
177 let storage_count = ids.iter().filter(|id| *id == "storage/provision").count();
178 assert_eq!(
179 storage_count, 1,
180 "storage/provision should appear exactly once"
181 );
182 }
183
184 #[test]
185 fn combined_stack_includes_all_resource_types() {
186 let worker = test_function("my-fn");
187 let storage = Storage::new("my-bucket".to_string()).build();
188
189 let stack = Stack::new("test-stack".to_string())
190 .add(worker, ResourceLifecycle::Live)
191 .add(storage, ResourceLifecycle::Frozen)
192 .build();
193
194 let ids = initial_setup_permission_set_ids(&stack);
195 assert!(!ids.contains(&"worker/provision".to_string()));
196 assert!(ids.contains(&"storage/provision".to_string()));
197 assert!(ids.contains(&"service-account/provision".to_string()));
198 }
199
200 #[test]
201 fn complete_aws_initial_setup_policy_excludes_live_only_provision_sets() {
202 let context = PermissionContext::new()
203 .with_aws_region("us-east-1")
204 .with_aws_account_id("123456789012")
205 .with_stack_prefix("test-stack")
206 .with_resource_name("test");
207
208 let policy = generate_aws_initial_setup_policy(&context).unwrap();
209 let actions = policy
210 .statement
211 .iter()
212 .flat_map(|statement| statement.action.iter())
213 .collect::<Vec<_>>();
214
215 assert!(
216 !actions.contains(&&"lambda:CreateFunction".to_string()),
217 "setup policy must not include live worker provision actions"
218 );
219 assert!(
220 actions.iter().any(|action| action.starts_with("s3:")),
221 "setup policy should still include frozen-capable resource actions"
222 );
223 }
224
225 #[test]
226 fn complete_aws_initial_setup_policy_has_unique_statement_sids() {
227 let context = PermissionContext::new()
228 .with_aws_region("us-east-1")
229 .with_aws_account_id("123456789012")
230 .with_stack_prefix("test-stack")
231 .with_resource_name("test");
232
233 let policy = generate_aws_initial_setup_policy(&context).unwrap();
234 let mut seen = HashSet::new();
235
236 for statement in policy.statement {
237 assert!(
238 seen.insert(statement.sid.clone()),
239 "duplicate AWS IAM statement Sid: {}",
240 statement.sid
241 );
242 }
243 }
244
245 #[test]
246 fn complete_aws_initial_setup_policy_can_create_remote_management_policies() {
247 let context = PermissionContext::new()
248 .with_aws_region("us-east-1")
249 .with_aws_account_id("123456789012")
250 .with_stack_prefix("test-stack")
251 .with_resource_name("test");
252
253 let policy = generate_aws_initial_setup_policy(&context).unwrap();
254 let statements = policy
255 .statement
256 .iter()
257 .filter(|statement| statement.action.contains(&"iam:CreatePolicy".to_string()))
258 .collect::<Vec<_>>();
259
260 assert!(
261 statements.iter().any(|statement| statement.resource.contains(
262 &"arn:aws:iam::123456789012:policy/test-stack-deployment-management-*"
263 .to_string()
264 )),
265 "initial setup policy must be able to create remote-stack-management managed policies, got {statements:?}"
266 );
267 }
268}