Skip to main content

alien_core/resources/
service_account.rs

1use crate::error::{ErrorData, Result};
2use crate::permissions::{PermissionProfile, PermissionSet};
3use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
4use alien_error::AlienError;
5use bon::Builder;
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9use std::borrow::Cow;
10use std::fmt::Debug;
11
12/// Represents a non-human identity that can be assumed by compute services
13/// such as Lambda, Cloud Run, ECS, Container Apps, etc.
14///
15/// Maps to:
16/// - AWS: IAM Role
17/// - GCP: Service Account
18/// - Azure: User-assigned Managed Identity
19///
20/// The ServiceAccount is automatically created from permission profiles in the stack
21/// and contains the resolved permission sets for both stack-level and resource-scoped access.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25#[builder(start_fn = new)]
26pub struct ServiceAccount {
27    /// Identifier for the service account. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
28    /// Maximum 64 characters.
29    #[builder(start_fn)]
30    pub id: String,
31
32    /// Stack-level permission sets that apply to all resources in the stack.
33    /// These are derived from the "*" scope in the permission profile.
34    /// Resource-scoped permissions are handled by individual resource controllers.
35    #[builder(field)]
36    pub stack_permission_sets: Vec<PermissionSet>,
37
38    /// Resolved grants for concrete resource IDs, captured for setup comparison.
39    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
40    #[builder(default)]
41    pub resource_permission_sets: IndexMap<String, Vec<PermissionSet>>,
42}
43
44impl ServiceAccount {
45    /// The resource type identifier for ServiceAccount
46    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("service-account");
47
48    /// Returns the service account's unique identifier.
49    pub fn id(&self) -> &str {
50        &self.id
51    }
52
53    /// Creates a ServiceAccount from a permission profile by resolving permission set references.
54    /// This is used by the stack processor to convert profiles into concrete ServiceAccount resources.
55    pub fn from_permission_profile(
56        id: String,
57        profile: &PermissionProfile,
58        permission_set_resolver: impl Fn(&str) -> Option<PermissionSet>,
59    ) -> Result<Self> {
60        let mut stack_permission_sets = Vec::new();
61        let mut resource_permission_sets = IndexMap::new();
62        for (resource_id, references) in &profile.0 {
63            let sets = references
64                .iter()
65                .map(|reference| {
66                    reference.resolve(&permission_set_resolver).ok_or_else(|| {
67                        AlienError::new(ErrorData::GenericError {
68                            message: format!(
69                                "Permission set '{}' not found for service account '{}'",
70                                reference.id(),
71                                id
72                            ),
73                        })
74                    })
75                })
76                .collect::<Result<Vec<_>>>()?;
77            if resource_id == "*" {
78                stack_permission_sets = sets;
79            } else {
80                resource_permission_sets.insert(resource_id.clone(), sets);
81            }
82        }
83        Ok(Self {
84            id,
85            stack_permission_sets,
86            resource_permission_sets,
87        })
88    }
89
90    /// Uses captured grants; legacy templates may supply their existing explicit profile.
91    /// This compatibility path does not create a profile or add default grants.
92    pub fn concrete_permission_sets(
93        &self,
94        legacy_profile: Option<&PermissionProfile>,
95        resolver: impl Fn(&str) -> Option<PermissionSet>,
96    ) -> Result<Cow<'_, IndexMap<String, Vec<PermissionSet>>>> {
97        if !self.resource_permission_sets.is_empty() {
98            return Ok(Cow::Borrowed(&self.resource_permission_sets));
99        }
100        match legacy_profile {
101            Some(profile) => Ok(Cow::Owned(
102                Self::from_permission_profile(self.id.clone(), profile, resolver)?
103                    .resource_permission_sets,
104            )),
105            None => Ok(Cow::Borrowed(&self.resource_permission_sets)),
106        }
107    }
108}
109
110impl ServiceAccountBuilder {
111    /// Adds a stack-level permission set to the service account.
112    /// Stack-level permissions apply to all resources in the stack.
113    pub fn stack_permission_set(mut self, permission_set: PermissionSet) -> Self {
114        self.stack_permission_sets.push(permission_set);
115        self
116    }
117}
118
119// Implementation of ResourceDefinition trait for ServiceAccount
120impl ResourceDefinition for ServiceAccount {
121    fn get_resource_type(&self) -> ResourceType {
122        Self::RESOURCE_TYPE
123    }
124
125    fn id(&self) -> &str {
126        &self.id
127    }
128
129    fn get_dependencies(&self) -> Vec<ResourceRef> {
130        // ServiceAccount doesn't depend on other resources directly
131        // Dependencies will be managed through the stack processor
132        Vec::new()
133    }
134
135    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
136        let new_service_account = new_config
137            .as_any()
138            .downcast_ref::<ServiceAccount>()
139            .ok_or_else(|| {
140                AlienError::new(ErrorData::UnexpectedResourceType {
141                    resource_id: self.id.clone(),
142                    expected: Self::RESOURCE_TYPE,
143                    actual: new_config.get_resource_type(),
144                })
145            })?;
146
147        if self.id != new_service_account.id {
148            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
149                resource_id: self.id.clone(),
150                reason: "the 'id' field is immutable".to_string(),
151            }));
152        }
153
154        Ok(())
155    }
156
157    fn as_any(&self) -> &dyn Any {
158        self
159    }
160
161    fn as_any_mut(&mut self) -> &mut dyn Any {
162        self
163    }
164
165    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
166        Box::new(self.clone())
167    }
168
169    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
170        other.as_any().downcast_ref::<ServiceAccount>() == Some(self)
171    }
172
173    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
174        serde_json::to_value(self)
175    }
176}
177
178/// Outputs generated by a successfully provisioned ServiceAccount.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
181#[serde(rename_all = "camelCase")]
182pub struct ServiceAccountOutputs {
183    /// The platform-specific identifier of the service account
184    /// - AWS: Role ARN
185    /// - GCP: Service Account email
186    /// - Azure: Managed Identity client ID
187    pub identity: String,
188
189    /// The platform-specific resource name/ID
190    /// - AWS: Role name
191    /// - GCP: Service Account unique ID
192    /// - Azure: Managed Identity resource ID
193    pub resource_id: String,
194}
195
196impl ResourceOutputsDefinition for ServiceAccountOutputs {
197    fn get_resource_type(&self) -> ResourceType {
198        ServiceAccount::RESOURCE_TYPE.clone()
199    }
200
201    fn as_any(&self) -> &dyn Any {
202        self
203    }
204
205    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
206        Box::new(self.clone())
207    }
208
209    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
210        other.as_any().downcast_ref::<ServiceAccountOutputs>() == Some(self)
211    }
212
213    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
214        serde_json::to_value(self)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use serde_json::json;
222
223    fn signing(scope: &str) -> PermissionSet {
224        serde_json::from_value(json!({
225            "id": "storage/sign", "description": "Sign object URLs",
226            "platforms": {"gcp": [{"label": "sign", "description": "Sign URLs",
227                "grant": {"permissions": ["iam.serviceAccounts.signBlob"]},
228                "binding": {"resource": {"scope": scope}, "stack": {"scope": scope}}
229            }]}
230        }))
231        .unwrap()
232    }
233
234    #[test]
235    fn concrete_definitions_are_captured_separately_and_override_legacy_lookup() {
236        let old = signing("projects/${projectName}");
237        let new = signing("projects/${projectName}/serviceAccounts/${serviceAccountName}@${projectName}.iam.gserviceaccount.com");
238        let profile = PermissionProfile::new()
239            .resource("*", ["storage/sign"])
240            .resource("objects", ["storage/sign"]);
241        let before =
242            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
243                Some(old.clone())
244            })
245            .unwrap();
246        let after =
247            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
248                Some(new.clone())
249            })
250            .unwrap();
251        assert_eq!(before.stack_permission_sets, vec![old.clone()]);
252        assert_eq!(before.resource_permission_sets["objects"], vec![old]);
253        assert_eq!(after.resource_permission_sets["objects"], vec![new.clone()]);
254        assert!(!before.resource_eq(&after));
255        assert_eq!(
256            after
257                .concrete_permission_sets(Some(&profile), |_| panic!(
258                    "captured definitions must be used"
259                ))
260                .unwrap()["objects"],
261            vec![new]
262        );
263        assert!(
264            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| None)
265                .is_err()
266        );
267    }
268
269    #[test]
270    fn legacy_account_deserializes_without_implicit_grants_and_rejects_malformed_map() {
271        let account: ServiceAccount =
272            serde_json::from_value(json!({"id": "reader-sa", "stackPermissionSets": []})).unwrap();
273        assert!(account.resource_permission_sets.is_empty());
274        assert!(account
275            .concrete_permission_sets(None, |_| None)
276            .unwrap()
277            .is_empty());
278        assert!(serde_json::to_value(&account)
279            .unwrap()
280            .get("resourcePermissionSets")
281            .is_none());
282        let set = signing("projects/${projectName}");
283        let profile = PermissionProfile::new().resource("objects", ["storage/sign"]);
284        assert_eq!(
285            account
286                .concrete_permission_sets(Some(&profile), |_| Some(set.clone()))
287                .unwrap()["objects"],
288            vec![set]
289        );
290        assert!(account
291            .concrete_permission_sets(Some(&profile), |_| None)
292            .is_err());
293        assert!(serde_json::from_value::<ServiceAccount>(json!({"id": "reader-sa", "stackPermissionSets": [], "resourcePermissionSets": {"objects": ["storage/sign"]}})).is_err());
294    }
295}