Skip to main content

alien_permissions/
initial_setup.rs

1//! Auto-generates minimal IAM/RBAC permissions for initial setup.
2//!
3//! Initial setup (admin runs `alien deploy up`) creates ALL cloud resources
4//! (frozen AND live). This module generates the minimal permission set needed,
5//! given a stack definition and target platform.
6
7use alien_core::Stack;
8
9use crate::generators::{AwsIamPolicy, AwsRuntimePermissionsGenerator};
10use crate::registry::get_permission_set;
11use crate::{BindingTarget, PermissionContext};
12
13/// Normalizes a resource type string to match permission set ID conventions.
14/// Resource types use mixed conventions (`service_activation`, `azure_storage_account`)
15/// while permission set IDs consistently use kebab-case (`service-activation`, `azure-storage-account`).
16fn normalize_resource_type(resource_type: &str) -> String {
17    resource_type.replace('_', "-")
18}
19
20/// Collects all provision permission set IDs needed for a stack's initial setup.
21///
22/// Walks every resource in the stack and includes its `<type>/provision` permission
23/// set if one exists in the registry. Also adds cross-cutting permission sets
24/// (e.g. `service-account/provision`) that any initial setup requires regardless
25/// of the resources declared.
26pub fn initial_setup_permission_set_ids(stack: &Stack) -> Vec<String> {
27    let mut set_ids = Vec::new();
28
29    for (_, resource_entry) in stack.resources() {
30        let resource_type = normalize_resource_type(resource_entry.config.resource_type().as_ref());
31        let provision_id = format!("{resource_type}/provision");
32
33        if get_permission_set(&provision_id).is_some() && !set_ids.contains(&provision_id) {
34            set_ids.push(provision_id);
35        }
36    }
37
38    let cross_cutting = ["service-account/provision"];
39
40    for id in cross_cutting {
41        if get_permission_set(id).is_some() && !set_ids.contains(&id.to_string()) {
42            set_ids.push(id.to_string());
43        }
44    }
45
46    set_ids
47}
48
49/// Generate a merged AWS IAM policy document containing ALL provision
50/// permissions for the given platform.
51///
52/// This generates the COMPLETE initial setup policy covering every resource
53/// type that Alien can provision. This is intentionally broad — it includes
54/// permissions for resources that preflights may add (RSM, ServiceAccount,
55/// SecretsVault, etc.) which aren't in the raw stack definition.
56///
57/// Customer-facing output: "here's the IAM policy you need to attach to
58/// your admin role before running `alien deploy up`."
59pub fn generate_aws_initial_setup_policy(
60    context: &PermissionContext,
61) -> crate::error::Result<AwsIamPolicy> {
62    let generator = AwsRuntimePermissionsGenerator::new();
63    let all_provision_ids = crate::registry::list_permission_set_ids()
64        .into_iter()
65        .filter(|id| id.ends_with("/provision"))
66        .collect::<Vec<_>>();
67
68    let mut all_statements = Vec::new();
69
70    for perm_id in &all_provision_ids {
71        if let Some(perm_set) = get_permission_set(perm_id) {
72            match generator.generate_policy(perm_set, BindingTarget::Stack, context) {
73                Ok(policy) => {
74                    all_statements.extend(policy.statement);
75                }
76                Err(_) => {
77                    // Permission set has no AWS platform definition — skip
78                }
79            }
80        }
81    }
82
83    Ok(AwsIamPolicy {
84        version: "2012-10-17".to_string(),
85        statement: all_statements,
86    })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use alien_core::{Function, FunctionCode, ResourceLifecycle, Storage};
93
94    fn test_function(name: &str) -> Function {
95        Function::new(name.to_string())
96            .code(FunctionCode::Image {
97                image: "rust:latest".to_string(),
98            })
99            .permissions("execution".to_string())
100            .build()
101    }
102
103    #[test]
104    fn function_stack_includes_function_provision() {
105        let function = test_function("my-fn");
106
107        let stack = Stack::new("test-stack".to_string())
108            .add(function, ResourceLifecycle::Live)
109            .build();
110
111        let ids = initial_setup_permission_set_ids(&stack);
112        assert!(
113            ids.contains(&"function/provision".to_string()),
114            "Expected function/provision in {ids:?}"
115        );
116    }
117
118    #[test]
119    fn storage_stack_includes_storage_provision() {
120        let storage = Storage::new("my-bucket".to_string()).build();
121
122        let stack = Stack::new("test-stack".to_string())
123            .add(storage, ResourceLifecycle::Frozen)
124            .build();
125
126        let ids = initial_setup_permission_set_ids(&stack);
127        assert!(
128            ids.contains(&"storage/provision".to_string()),
129            "Expected storage/provision in {ids:?}"
130        );
131    }
132
133    #[test]
134    fn cross_cutting_service_account_always_included() {
135        let stack = Stack::new("empty-stack".to_string()).build();
136
137        let ids = initial_setup_permission_set_ids(&stack);
138        assert!(
139            ids.contains(&"service-account/provision".to_string()),
140            "Expected service-account/provision in {ids:?}"
141        );
142    }
143
144    #[test]
145    fn no_duplicates() {
146        let s1 = Storage::new("bucket-a".to_string()).build();
147        let s2 = Storage::new("bucket-b".to_string()).build();
148
149        let stack = Stack::new("test-stack".to_string())
150            .add(s1, ResourceLifecycle::Frozen)
151            .add(s2, ResourceLifecycle::Frozen)
152            .build();
153
154        let ids = initial_setup_permission_set_ids(&stack);
155        let storage_count = ids.iter().filter(|id| *id == "storage/provision").count();
156        assert_eq!(
157            storage_count, 1,
158            "storage/provision should appear exactly once"
159        );
160    }
161
162    #[test]
163    fn combined_stack_includes_all_resource_types() {
164        let function = test_function("my-fn");
165        let storage = Storage::new("my-bucket".to_string()).build();
166
167        let stack = Stack::new("test-stack".to_string())
168            .add(function, ResourceLifecycle::Live)
169            .add(storage, ResourceLifecycle::Frozen)
170            .build();
171
172        let ids = initial_setup_permission_set_ids(&stack);
173        assert!(ids.contains(&"function/provision".to_string()));
174        assert!(ids.contains(&"storage/provision".to_string()));
175        assert!(ids.contains(&"service-account/provision".to_string()));
176    }
177}