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#[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 #[builder(start_fn)]
32 pub id: String,
33
34 #[builder(field)]
38 pub stack_permission_sets: Vec<PermissionSet>,
39
40 #[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 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("service-account");
49
50 pub fn id(&self) -> &str {
52 &self.id
53 }
54
55 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 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
112const IMPERSONATE_PERMISSION_SET: &str = "service-account/impersonate";
114
115impl ServiceAccount {
116 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 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 pub fn stack_permission_set(mut self, permission_set: PermissionSet) -> Self {
172 self.stack_permission_sets.push(permission_set);
173 self
174 }
175}
176
177impl 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239#[serde(rename_all = "camelCase")]
240pub struct ServiceAccountOutputs {
241 pub identity: String,
246
247 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 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}