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 crate::Stack;
5use alien_error::AlienError;
6use bon::Builder;
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::borrow::Cow;
11use std::collections::BTreeSet;
12use std::fmt::Debug;
13
14/// Represents a non-human identity that can be assumed by compute services
15/// such as Lambda, Cloud Run, ECS, Container Apps, etc.
16///
17/// Maps to:
18/// - AWS: IAM Role
19/// - GCP: Service Account
20/// - Azure: User-assigned Managed Identity
21///
22/// The ServiceAccount is automatically created from permission profiles in the stack
23/// and contains the resolved permission sets for both stack-level and resource-scoped access.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27#[builder(start_fn = new)]
28pub struct ServiceAccount {
29    /// Identifier for the service account. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
30    /// Maximum 64 characters.
31    #[builder(start_fn)]
32    pub id: String,
33
34    /// Stack-level permission sets that apply to all resources in the stack.
35    /// These are derived from the "*" scope in the permission profile.
36    /// Resource-scoped permissions are handled by individual resource controllers.
37    #[builder(field)]
38    pub stack_permission_sets: Vec<PermissionSet>,
39
40    /// Resolved grants for concrete resource IDs, captured for setup comparison.
41    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
42    #[builder(default)]
43    pub resource_permission_sets: IndexMap<String, Vec<PermissionSet>>,
44}
45
46impl ServiceAccount {
47    /// The resource type identifier for ServiceAccount
48    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("service-account");
49
50    /// Returns the service account's unique identifier.
51    pub fn id(&self) -> &str {
52        &self.id
53    }
54
55    /// Creates a ServiceAccount from a permission profile by resolving permission set references.
56    /// This is used by the stack processor to convert profiles into concrete ServiceAccount resources.
57    pub fn from_permission_profile(
58        id: String,
59        profile: &PermissionProfile,
60        permission_set_resolver: impl Fn(&str) -> Option<PermissionSet>,
61    ) -> Result<Self> {
62        let mut stack_permission_sets = Vec::new();
63        let mut resource_permission_sets = IndexMap::new();
64        for (resource_id, references) in &profile.0 {
65            let sets = references
66                .iter()
67                .map(|reference| {
68                    reference.resolve(&permission_set_resolver).ok_or_else(|| {
69                        AlienError::new(ErrorData::GenericError {
70                            message: format!(
71                                "Permission set '{}' not found for service account '{}'",
72                                reference.id(),
73                                id
74                            ),
75                        })
76                    })
77                })
78                .collect::<Result<Vec<_>>>()?;
79            if resource_id == "*" {
80                stack_permission_sets = sets;
81            } else {
82                resource_permission_sets.insert(resource_id.clone(), sets);
83            }
84        }
85        Ok(Self {
86            id,
87            stack_permission_sets,
88            resource_permission_sets,
89        })
90    }
91
92    /// Uses captured grants; legacy templates may supply their existing explicit profile.
93    /// This compatibility path does not create a profile or add default grants.
94    pub fn concrete_permission_sets(
95        &self,
96        legacy_profile: Option<&PermissionProfile>,
97        resolver: impl Fn(&str) -> Option<PermissionSet>,
98    ) -> Result<Cow<'_, IndexMap<String, Vec<PermissionSet>>>> {
99        if !self.resource_permission_sets.is_empty() {
100            return Ok(Cow::Borrowed(&self.resource_permission_sets));
101        }
102        match legacy_profile {
103            Some(profile) => Ok(Cow::Owned(
104                Self::from_permission_profile(self.id.clone(), profile, resolver)?
105                    .resource_permission_sets,
106            )),
107            None => Ok(Cow::Borrowed(&self.resource_permission_sets)),
108        }
109    }
110}
111
112/// The permission set that lets one service account assume another.
113const IMPERSONATE_PERMISSION_SET: &str = "service-account/impersonate";
114
115impl ServiceAccount {
116    /// The service account a resource-scoped impersonation grant targets. A grant names either
117    /// the account itself or the profile it was created from (`{profile}` → `{profile}-sa`).
118    pub fn impersonation_target(stack: &Stack, scope: &str) -> Option<String> {
119        [scope.to_string(), format!("{scope}-sa")]
120            .into_iter()
121            .find(|id| is_service_account(stack, id))
122    }
123
124    /// Ids of the other service accounts in `stack` that may impersonate this one.
125    ///
126    /// Reads the permission profiles and each account's captured grants: a frozen account keeps
127    /// its grants after the profile that produced them is gone, and the caller policy is written
128    /// from those grants, so its trust has to be too.
129    pub fn impersonators(&self, stack: &Stack) -> BTreeSet<String> {
130        let scopes = [Some(self.id.as_str()), self.id.strip_suffix("-sa")];
131        let mut ids: BTreeSet<String> = stack
132            .permissions
133            .profiles
134            .iter()
135            .filter(|(_, profile)| {
136                scopes
137                    .iter()
138                    .flatten()
139                    .filter_map(|scope| profile.0.get(*scope))
140                    .flatten()
141                    .any(|reference| reference.id() == IMPERSONATE_PERMISSION_SET)
142            })
143            .map(|(profile, _)| format!("{profile}-sa"))
144            .collect();
145        for (id, entry) in stack.resources() {
146            let Some(caller) = entry.config.downcast_ref::<ServiceAccount>() else {
147                continue;
148            };
149            let grants_this = caller.resource_permission_sets.iter().any(|(scope, sets)| {
150                sets.iter().any(|set| set.id == IMPERSONATE_PERMISSION_SET)
151                    && Self::impersonation_target(stack, scope).as_deref() == Some(self.id.as_str())
152            });
153            if grants_this {
154                ids.insert(id.clone());
155            }
156        }
157        ids.retain(|id| id != &self.id && is_service_account(stack, id));
158        ids
159    }
160}
161
162fn is_service_account(stack: &Stack, id: &str) -> bool {
163    stack.resources().any(|(resource_id, entry)| {
164        resource_id == id && entry.config.downcast_ref::<ServiceAccount>().is_some()
165    })
166}
167
168impl ServiceAccountBuilder {
169    /// Adds a stack-level permission set to the service account.
170    /// Stack-level permissions apply to all resources in the stack.
171    pub fn stack_permission_set(mut self, permission_set: PermissionSet) -> Self {
172        self.stack_permission_sets.push(permission_set);
173        self
174    }
175}
176
177// Implementation of ResourceDefinition trait for ServiceAccount
178impl ResourceDefinition for ServiceAccount {
179    fn get_resource_type(&self) -> ResourceType {
180        Self::RESOURCE_TYPE
181    }
182
183    fn id(&self) -> &str {
184        &self.id
185    }
186
187    fn get_dependencies(&self) -> Vec<ResourceRef> {
188        // ServiceAccount doesn't depend on other resources directly
189        // Dependencies will be managed through the stack processor
190        Vec::new()
191    }
192
193    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
194        let new_service_account = new_config
195            .as_any()
196            .downcast_ref::<ServiceAccount>()
197            .ok_or_else(|| {
198                AlienError::new(ErrorData::UnexpectedResourceType {
199                    resource_id: self.id.clone(),
200                    expected: Self::RESOURCE_TYPE,
201                    actual: new_config.get_resource_type(),
202                })
203            })?;
204
205        if self.id != new_service_account.id {
206            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
207                resource_id: self.id.clone(),
208                reason: "the 'id' field is immutable".to_string(),
209            }));
210        }
211
212        Ok(())
213    }
214
215    fn as_any(&self) -> &dyn Any {
216        self
217    }
218
219    fn as_any_mut(&mut self) -> &mut dyn Any {
220        self
221    }
222
223    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
224        Box::new(self.clone())
225    }
226
227    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
228        other.as_any().downcast_ref::<ServiceAccount>() == Some(self)
229    }
230
231    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
232        serde_json::to_value(self)
233    }
234}
235
236/// Outputs generated by a successfully provisioned ServiceAccount.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239#[serde(rename_all = "camelCase")]
240pub struct ServiceAccountOutputs {
241    /// The platform-specific identifier of the service account
242    /// - AWS: Role ARN
243    /// - GCP: Service Account email
244    /// - Azure: Managed Identity client ID
245    pub identity: String,
246
247    /// The platform-specific resource name/ID
248    /// - AWS: Role name
249    /// - GCP: Service Account unique ID
250    /// - Azure: Managed Identity resource ID
251    pub resource_id: String,
252}
253
254impl ResourceOutputsDefinition for ServiceAccountOutputs {
255    fn get_resource_type(&self) -> ResourceType {
256        ServiceAccount::RESOURCE_TYPE.clone()
257    }
258
259    fn as_any(&self) -> &dyn Any {
260        self
261    }
262
263    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
264        Box::new(self.clone())
265    }
266
267    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
268        other.as_any().downcast_ref::<ServiceAccountOutputs>() == Some(self)
269    }
270
271    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
272        serde_json::to_value(self)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use serde_json::json;
280
281    fn signing(scope: &str) -> PermissionSet {
282        serde_json::from_value(json!({
283            "id": "storage/sign", "description": "Sign object URLs",
284            "platforms": {"gcp": [{"label": "sign", "description": "Sign URLs",
285                "grant": {"permissions": ["iam.serviceAccounts.signBlob"]},
286                "binding": {"resource": {"scope": scope}, "stack": {"scope": scope}}
287            }]}
288        }))
289        .unwrap()
290    }
291
292    fn impersonate() -> PermissionSet {
293        serde_json::from_value(json!({
294            "id": "service-account/impersonate", "description": "Assume a service account",
295            "platforms": {"aws": [{"grant": {"actions": ["sts:AssumeRole"]},
296                "binding": {"resource": {"resources": ["*"]}}}]}
297        }))
298        .unwrap()
299    }
300
301    #[test]
302    fn a_frozen_callers_captured_grant_makes_it_an_impersonator_without_a_profile() {
303        use crate::{permissions::PermissionsConfig, ResourceLifecycle, Stack};
304        let target = ServiceAccount::new("target-sa".to_string()).build();
305        // Captured grants name the target by profile ("target") and by id ("target-sa").
306        let by_profile = ServiceAccount::new("manager-sa".to_string())
307            .resource_permission_sets(IndexMap::from([(
308                "target".to_string(),
309                vec![impersonate()],
310            )]))
311            .build();
312        let by_id = ServiceAccount::new("operator-sa".to_string())
313            .resource_permission_sets(IndexMap::from([(
314                "target-sa".to_string(),
315                vec![impersonate()],
316            )]))
317            .build();
318        let bystander = ServiceAccount::new("reader-sa".to_string())
319            .resource_permission_sets(IndexMap::from([("target".to_string(), vec![signing("*")])]))
320            .build();
321        let profiled = ServiceAccount::new("execution-sa".to_string()).build();
322        let stack = Stack::new("acme".to_string())
323            .add(target.clone(), ResourceLifecycle::Frozen)
324            .add(by_profile, ResourceLifecycle::Frozen)
325            .add(by_id, ResourceLifecycle::Frozen)
326            .add(bystander, ResourceLifecycle::Frozen)
327            .add(profiled, ResourceLifecycle::Frozen)
328            .permissions(PermissionsConfig::new().with_profile(
329                "execution",
330                PermissionProfile::new().resource("target", ["service-account/impersonate"]),
331            ))
332            .build();
333
334        assert_eq!(
335            target.impersonators(&stack),
336            BTreeSet::from([
337                "execution-sa".to_string(),
338                "manager-sa".to_string(),
339                "operator-sa".to_string(),
340            ])
341        );
342    }
343
344    #[test]
345    fn concrete_definitions_are_captured_separately_and_override_legacy_lookup() {
346        let old = signing("projects/${projectName}");
347        let new = signing("projects/${projectName}/serviceAccounts/${serviceAccountName}@${projectName}.iam.gserviceaccount.com");
348        let profile = PermissionProfile::new()
349            .resource("*", ["storage/sign"])
350            .resource("objects", ["storage/sign"]);
351        let before =
352            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
353                Some(old.clone())
354            })
355            .unwrap();
356        let after =
357            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
358                Some(new.clone())
359            })
360            .unwrap();
361        assert_eq!(before.stack_permission_sets, vec![old.clone()]);
362        assert_eq!(before.resource_permission_sets["objects"], vec![old]);
363        assert_eq!(after.resource_permission_sets["objects"], vec![new.clone()]);
364        assert!(!before.resource_eq(&after));
365        assert_eq!(
366            after
367                .concrete_permission_sets(Some(&profile), |_| panic!(
368                    "captured definitions must be used"
369                ))
370                .unwrap()["objects"],
371            vec![new]
372        );
373        assert!(
374            ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| None)
375                .is_err()
376        );
377    }
378
379    #[test]
380    fn legacy_account_deserializes_without_implicit_grants_and_rejects_malformed_map() {
381        let account: ServiceAccount =
382            serde_json::from_value(json!({"id": "reader-sa", "stackPermissionSets": []})).unwrap();
383        assert!(account.resource_permission_sets.is_empty());
384        assert!(account
385            .concrete_permission_sets(None, |_| None)
386            .unwrap()
387            .is_empty());
388        assert!(serde_json::to_value(&account)
389            .unwrap()
390            .get("resourcePermissionSets")
391            .is_none());
392        let set = signing("projects/${projectName}");
393        let profile = PermissionProfile::new().resource("objects", ["storage/sign"]);
394        assert_eq!(
395            account
396                .concrete_permission_sets(Some(&profile), |_| Some(set.clone()))
397                .unwrap()["objects"],
398            vec![set]
399        );
400        assert!(account
401            .concrete_permission_sets(Some(&profile), |_| None)
402            .is_err());
403        assert!(serde_json::from_value::<ServiceAccount>(json!({"id": "reader-sa", "stackPermissionSets": [], "resourcePermissionSets": {"objects": ["storage/sign"]}})).is_err());
404    }
405}