Skip to main content

alien_permissions/
initial_setup.rs

1//! Auto-generates minimal IAM/RBAC permissions for initial setup.
2//!
3//! Initial setup creates setup-owned Frozen resources. Alien-owned Live
4//! resources are created later by the deployment loop with management
5//! credentials. This module generates the setup permission set for that first
6//! Frozen-resource phase.
7
8use 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
16/// Normalizes a resource type string to match permission set ID conventions.
17/// Resource types use mixed conventions (`service_activation`, `azure_storage_account`)
18/// while permission set IDs consistently use kebab-case (`service-activation`, `azure-storage-account`).
19fn normalize_resource_type(resource_type: &str) -> String {
20    resource_type.replace('_', "-")
21}
22
23/// Collects all provision permission set IDs needed for a stack's initial setup.
24///
25/// Walks resources emitted during setup and includes their `<type>/provision`
26/// permission set if one exists in the registry. Live resources are excluded:
27/// their provision permissions belong to the ongoing management profile.
28/// Also adds cross-cutting permission sets (e.g. `service-account/provision`)
29/// that any initial setup requires regardless of the resources declared.
30pub 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
60/// Generate a merged AWS IAM policy document containing setup provision
61/// permissions for the given platform.
62///
63/// This generates a complete setup policy covering every Frozen/setup resource
64/// type that setup can create. It intentionally excludes Live-only resources,
65/// which are created by Alien after setup.
66///
67/// Customer-facing output: "here's the IAM policy you need to attach to
68/// your admin role before running `alien deploy up`."
69pub 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                    // Permission set has no AWS platform definition — skip
97                }
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}