use crate::error::{ErrorData, Result};
use crate::permissions::{PermissionProfile, PermissionSet};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
use alien_error::AlienError;
use bon::Builder;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::borrow::Cow;
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct ServiceAccount {
#[builder(start_fn)]
pub id: String,
#[builder(field)]
pub stack_permission_sets: Vec<PermissionSet>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
#[builder(default)]
pub resource_permission_sets: IndexMap<String, Vec<PermissionSet>>,
}
impl ServiceAccount {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("service-account");
pub fn id(&self) -> &str {
&self.id
}
pub fn from_permission_profile(
id: String,
profile: &PermissionProfile,
permission_set_resolver: impl Fn(&str) -> Option<PermissionSet>,
) -> Result<Self> {
let mut stack_permission_sets = Vec::new();
let mut resource_permission_sets = IndexMap::new();
for (resource_id, references) in &profile.0 {
let sets = references
.iter()
.map(|reference| {
reference.resolve(&permission_set_resolver).ok_or_else(|| {
AlienError::new(ErrorData::GenericError {
message: format!(
"Permission set '{}' not found for service account '{}'",
reference.id(),
id
),
})
})
})
.collect::<Result<Vec<_>>>()?;
if resource_id == "*" {
stack_permission_sets = sets;
} else {
resource_permission_sets.insert(resource_id.clone(), sets);
}
}
Ok(Self {
id,
stack_permission_sets,
resource_permission_sets,
})
}
pub fn concrete_permission_sets(
&self,
legacy_profile: Option<&PermissionProfile>,
resolver: impl Fn(&str) -> Option<PermissionSet>,
) -> Result<Cow<'_, IndexMap<String, Vec<PermissionSet>>>> {
if !self.resource_permission_sets.is_empty() {
return Ok(Cow::Borrowed(&self.resource_permission_sets));
}
match legacy_profile {
Some(profile) => Ok(Cow::Owned(
Self::from_permission_profile(self.id.clone(), profile, resolver)?
.resource_permission_sets,
)),
None => Ok(Cow::Borrowed(&self.resource_permission_sets)),
}
}
}
impl ServiceAccountBuilder {
pub fn stack_permission_set(mut self, permission_set: PermissionSet) -> Self {
self.stack_permission_sets.push(permission_set);
self
}
}
impl ResourceDefinition for ServiceAccount {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
Vec::new()
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_service_account = new_config
.as_any()
.downcast_ref::<ServiceAccount>()
.ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_service_account.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<ServiceAccount>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ServiceAccountOutputs {
pub identity: String,
pub resource_id: String,
}
impl ResourceOutputsDefinition for ServiceAccountOutputs {
fn get_resource_type(&self) -> ResourceType {
ServiceAccount::RESOURCE_TYPE.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<ServiceAccountOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn signing(scope: &str) -> PermissionSet {
serde_json::from_value(json!({
"id": "storage/sign", "description": "Sign object URLs",
"platforms": {"gcp": [{"label": "sign", "description": "Sign URLs",
"grant": {"permissions": ["iam.serviceAccounts.signBlob"]},
"binding": {"resource": {"scope": scope}, "stack": {"scope": scope}}
}]}
}))
.unwrap()
}
#[test]
fn concrete_definitions_are_captured_separately_and_override_legacy_lookup() {
let old = signing("projects/${projectName}");
let new = signing("projects/${projectName}/serviceAccounts/${serviceAccountName}@${projectName}.iam.gserviceaccount.com");
let profile = PermissionProfile::new()
.resource("*", ["storage/sign"])
.resource("objects", ["storage/sign"]);
let before =
ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
Some(old.clone())
})
.unwrap();
let after =
ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| {
Some(new.clone())
})
.unwrap();
assert_eq!(before.stack_permission_sets, vec![old.clone()]);
assert_eq!(before.resource_permission_sets["objects"], vec![old]);
assert_eq!(after.resource_permission_sets["objects"], vec![new.clone()]);
assert!(!before.resource_eq(&after));
assert_eq!(
after
.concrete_permission_sets(Some(&profile), |_| panic!(
"captured definitions must be used"
))
.unwrap()["objects"],
vec![new]
);
assert!(
ServiceAccount::from_permission_profile("reader-sa".to_string(), &profile, |_| None)
.is_err()
);
}
#[test]
fn legacy_account_deserializes_without_implicit_grants_and_rejects_malformed_map() {
let account: ServiceAccount =
serde_json::from_value(json!({"id": "reader-sa", "stackPermissionSets": []})).unwrap();
assert!(account.resource_permission_sets.is_empty());
assert!(account
.concrete_permission_sets(None, |_| None)
.unwrap()
.is_empty());
assert!(serde_json::to_value(&account)
.unwrap()
.get("resourcePermissionSets")
.is_none());
let set = signing("projects/${projectName}");
let profile = PermissionProfile::new().resource("objects", ["storage/sign"]);
assert_eq!(
account
.concrete_permission_sets(Some(&profile), |_| Some(set.clone()))
.unwrap()["objects"],
vec![set]
);
assert!(account
.concrete_permission_sets(Some(&profile), |_| None)
.is_err());
assert!(serde_json::from_value::<ServiceAccount>(json!({"id": "reader-sa", "stackPermissionSets": [], "resourcePermissionSets": {"objects": ["storage/sign"]}})).is_err());
}
}