alien_core/resources/
service_account.rs1use 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#[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 #[builder(start_fn)]
30 pub id: String,
31
32 #[builder(field)]
36 pub stack_permission_sets: Vec<PermissionSet>,
37
38 #[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 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("service-account");
47
48 pub fn id(&self) -> &str {
50 &self.id
51 }
52
53 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 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 pub fn stack_permission_set(mut self, permission_set: PermissionSet) -> Self {
114 self.stack_permission_sets.push(permission_set);
115 self
116 }
117}
118
119impl 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
181#[serde(rename_all = "camelCase")]
182pub struct ServiceAccountOutputs {
183 pub identity: String,
188
189 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}