1use crate::{
2 error::{ErrorData, Result},
3 generators::labels::{
4 entry_description, entry_snake_label, entry_title_label, has_explicit_label,
5 },
6 variables::VariableInterpolator,
7 BindingTarget, PermissionContext,
8};
9use alien_core::{GcpBindingSpec, PermissionGrant, PermissionSet};
10use serde::{Deserialize, Serialize};
11
12const GCP_CUSTOM_ROLE_ID_MAX_LEN: usize = 64;
13const ROLE_ID_HASH_LEN: usize = 8;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct GcpIamCondition {
19 pub title: String,
21 pub description: String,
23 pub expression: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct GcpCustomRole {
31 pub role_id: String,
33 pub name: String,
35 pub title: String,
37 pub description: String,
39 pub included_permissions: Vec<String>,
41 pub stage: String,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub enum GcpBindingTargetScope {
49 Project,
51 CurrentResource,
53}
54
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "camelCase")]
58pub enum GcpBindingResourceKind {
59 PubsubTopic,
61 PubsubSubscription,
63 ArtifactRegistryRepository,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct GcpIamBinding {
71 pub role: String,
73 pub members: Vec<String>,
75 pub target: GcpBindingTargetScope,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub resource_kind: Option<GcpBindingResourceKind>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub condition: Option<GcpIamCondition>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "camelCase")]
88pub struct GcpIamBindings {
89 pub bindings: Vec<GcpIamBinding>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct GcpGrantPlan {
97 pub bindings: Vec<GcpIamBinding>,
99 pub custom_roles: Vec<GcpCustomRole>,
101}
102
103impl GcpGrantPlan {
104 pub fn bindings_for_target(&self, target: GcpBindingTargetScope) -> Vec<GcpIamBinding> {
106 self.bindings
107 .iter()
108 .filter(|binding| binding.target == target)
109 .cloned()
110 .collect()
111 }
112
113 pub fn custom_roles_for_bindings(&self, bindings: &[GcpIamBinding]) -> Vec<GcpCustomRole> {
115 self.custom_roles
116 .iter()
117 .filter(|custom_role| {
118 bindings
119 .iter()
120 .any(|binding| binding.role == custom_role.name)
121 })
122 .cloned()
123 .collect()
124 }
125}
126
127pub struct GcpRuntimePermissionsGenerator;
129
130impl GcpRuntimePermissionsGenerator {
131 pub fn new() -> Self {
133 Self
134 }
135
136 pub fn generate_custom_role(
143 &self,
144 permission_set: &PermissionSet,
145 context: &PermissionContext,
146 ) -> Result<GcpCustomRole> {
147 let roles = self.generate_custom_roles(permission_set, context)?;
148 if roles.len() == 1 {
149 return Ok(roles.into_iter().next().expect("single role"));
150 }
151 if roles.is_empty() {
152 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
153 platform: "gcp".to_string(),
154 message: format!(
155 "GCP permission set '{}' has no residual custom role",
156 permission_set.id
157 ),
158 }));
159 }
160
161 Err(alien_error::AlienError::new(ErrorData::GeneratorError {
162 platform: "gcp".to_string(),
163 message: format!(
164 "GCP permission set '{}' generates multiple custom roles; use generate_custom_roles() to preserve binding scopes",
165 permission_set.id
166 ),
167 }))
168 }
169
170 pub fn generate_custom_roles(
178 &self,
179 permission_set: &PermissionSet,
180 context: &PermissionContext,
181 ) -> Result<Vec<GcpCustomRole>> {
182 let gcp_platform_permissions = permission_set.platforms.gcp.as_ref().ok_or_else(|| {
183 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
184 platform: "gcp".to_string(),
185 permission_set_id: permission_set.id.clone(),
186 })
187 })?;
188
189 if gcp_platform_permissions.is_empty() {
190 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
191 platform: "gcp".to_string(),
192 message: format!(
193 "GCP permission set '{}' has no platform entries",
194 permission_set.id
195 ),
196 }));
197 }
198
199 let mut roles: Vec<GcpCustomRole> = Vec::new();
200 let residual_entry_count = gcp_platform_permissions
201 .iter()
202 .filter(|entry| gcp_residual_permissions(&entry.grant).is_some_and(|p| !p.is_empty()))
203 .count();
204 let has_multiple_entries = residual_entry_count > 1;
205 for (index, platform_permission) in gcp_platform_permissions.iter().enumerate() {
206 validate_gcp_grant(permission_set, index, &platform_permission.grant)?;
207
208 let Some(permissions) = gcp_residual_permissions(&platform_permission.grant) else {
209 continue;
210 };
211 if permissions.is_empty() {
212 continue;
213 }
214 let role = self.custom_role_for_permissions(
215 permission_set,
216 permissions.clone(),
217 context,
218 role_suffix(
219 &platform_permission.grant,
220 platform_permission.label.as_deref(),
221 has_multiple_entries,
222 ),
223 platform_permission.label.as_deref(),
224 platform_permission.description.as_deref(),
225 )?;
226 if !roles
227 .iter()
228 .any(|existing| existing.role_id == role.role_id)
229 {
230 roles.push(role);
231 }
232 }
233
234 Ok(roles)
235 }
236
237 fn custom_role_for_permissions(
238 &self,
239 permission_set: &PermissionSet,
240 mut included_permissions: Vec<String>,
241 context: &PermissionContext,
242 suffix: Option<String>,
243 explicit_label: Option<&str>,
244 entry_description_override: Option<&str>,
245 ) -> Result<GcpCustomRole> {
246 included_permissions.sort();
247 included_permissions.dedup();
248
249 let project = context.project_name.as_deref().unwrap_or("PROJECT_NAME");
250 let role_id = generate_role_id(permission_set, context, suffix.as_deref(), explicit_label);
251 let role_name = format!("projects/{project}/roles/{role_id}");
252
253 Ok(GcpCustomRole {
254 role_id: role_id.clone(),
255 name: role_name,
256 title: custom_role_title(permission_set, context, suffix.as_deref(), explicit_label),
257 description: custom_role_description(
258 permission_set,
259 context,
260 entry_description_override,
261 ),
262 included_permissions,
263 stage: "GA".to_string(),
264 })
265 }
266
267 pub fn generate_bindings(
269 &self,
270 permission_set: &PermissionSet,
271 binding_target: BindingTarget,
272 context: &PermissionContext,
273 ) -> Result<GcpIamBindings> {
274 Ok(GcpIamBindings {
275 bindings: self
276 .generate_grant_plan(permission_set, binding_target, context)?
277 .bindings,
278 })
279 }
280
281 pub fn generate_grant_plan(
283 &self,
284 permission_set: &PermissionSet,
285 binding_target: BindingTarget,
286 context: &PermissionContext,
287 ) -> Result<GcpGrantPlan> {
288 let gcp_platform_permissions = permission_set.platforms.gcp.as_ref().ok_or_else(|| {
289 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
290 platform: "gcp".to_string(),
291 permission_set_id: permission_set.id.clone(),
292 })
293 })?;
294
295 if gcp_platform_permissions.is_empty() {
296 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
297 platform: "gcp".to_string(),
298 message: format!(
299 "GCP permission set '{}' has no platform entries",
300 permission_set.id
301 ),
302 }));
303 }
304
305 let project = context.project_name.as_deref().unwrap_or("PROJECT_NAME");
306 let service_account = format!(
307 "serviceAccount:{}@{}.iam.gserviceaccount.com",
308 context
309 .service_account_name
310 .as_deref()
311 .unwrap_or("SERVICE_ACCOUNT"),
312 project
313 );
314
315 let mut bindings = Vec::new();
316 let mut custom_roles = Vec::new();
317 let residual_entry_count = gcp_platform_permissions
318 .iter()
319 .filter(|entry| gcp_residual_permissions(&entry.grant).is_some_and(|p| !p.is_empty()))
320 .count();
321 let has_multiple_entries = residual_entry_count > 1;
322 for (index, platform_permission) in gcp_platform_permissions.iter().enumerate() {
323 validate_gcp_grant(permission_set, index, &platform_permission.grant)?;
324
325 let binding_spec = match binding_target {
326 BindingTarget::Stack => platform_permission.binding.stack.as_ref(),
327 BindingTarget::Resource => platform_permission.binding.resource.as_ref(),
328 };
329
330 let Some(binding_spec) = binding_spec else {
331 continue;
332 };
333
334 let target = binding_target_scope(binding_spec);
335 let resource_kind = binding_resource_kind(binding_spec);
336 let condition = self.binding_condition(binding_spec, context)?;
337
338 if let Some(predefined_roles) = &platform_permission.grant.predefined_roles {
339 for predefined_role in predefined_roles {
340 bindings.push(GcpIamBinding {
341 role: predefined_role.clone(),
342 members: vec![service_account.clone()],
343 target,
344 resource_kind,
345 condition: condition.clone(),
346 });
347 }
348 }
349
350 let Some(permissions) = gcp_residual_permissions(&platform_permission.grant) else {
351 continue;
352 };
353 if permissions.is_empty() {
354 continue;
355 }
356
357 let custom_role = self.custom_role_for_permissions(
358 permission_set,
359 permissions.clone(),
360 context,
361 role_suffix(
362 &platform_permission.grant,
363 platform_permission.label.as_deref(),
364 has_multiple_entries,
365 ),
366 platform_permission.label.as_deref(),
367 platform_permission.description.as_deref(),
368 )?;
369 bindings.push(GcpIamBinding {
370 role: custom_role.name.clone(),
371 members: vec![service_account.clone()],
372 target,
373 resource_kind,
374 condition,
375 });
376 if !custom_roles
377 .iter()
378 .any(|existing: &GcpCustomRole| existing.role_id == custom_role.role_id)
379 {
380 custom_roles.push(custom_role);
381 }
382 }
383
384 Ok(GcpGrantPlan {
385 bindings: dedupe_bindings(bindings),
386 custom_roles,
387 })
388 }
389
390 fn binding_condition(
391 &self,
392 binding_spec: &GcpBindingSpec,
393 context: &PermissionContext,
394 ) -> Result<Option<GcpIamCondition>> {
395 let Some(gcp_condition) = binding_spec.condition.as_ref() else {
396 return Ok(None);
397 };
398
399 let interpolated = self.interpolate_condition(gcp_condition, context)?;
400 Ok(Some(GcpIamCondition {
401 title: interpolated.title.clone(),
402 description: format!("Limit to {}", interpolated.title),
403 expression: interpolated.expression,
404 }))
405 }
406
407 fn interpolate_condition(
409 &self,
410 condition: &alien_core::GcpCondition,
411 context: &PermissionContext,
412 ) -> Result<alien_core::GcpCondition> {
413 let interpolated_title =
414 VariableInterpolator::interpolate_variables(&condition.title, context)?;
415 let interpolated_expression =
416 VariableInterpolator::interpolate_variables(&condition.expression, context)?;
417
418 Ok(alien_core::GcpCondition {
419 title: interpolated_title,
420 expression: interpolated_expression,
421 })
422 }
423}
424
425fn gcp_residual_permissions(grant: &PermissionGrant) -> Option<&Vec<String>> {
426 grant
427 .residual_permissions
428 .as_ref()
429 .or(grant.permissions.as_ref())
430}
431
432fn validate_gcp_grant(
433 permission_set: &PermissionSet,
434 index: usize,
435 grant: &PermissionGrant,
436) -> Result<()> {
437 if let Some(predefined_roles) = &grant.predefined_roles {
438 if predefined_roles.is_empty() {
439 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
440 platform: "gcp".to_string(),
441 message: format!(
442 "GCP permission set '{}' entry {} has an empty predefinedRoles list",
443 permission_set.id, index
444 ),
445 }));
446 }
447 for role in predefined_roles {
448 if !role.starts_with("roles/") {
449 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
450 platform: "gcp".to_string(),
451 message: format!(
452 "GCP permission set '{}' entry {} has invalid predefined role '{}'",
453 permission_set.id, index, role
454 ),
455 }));
456 }
457 }
458 }
459
460 if let Some(permissions) = gcp_residual_permissions(grant) {
461 if permissions.is_empty() {
462 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
463 platform: "gcp".to_string(),
464 message: format!(
465 "GCP permission set '{}' entry {} has an empty permissions list",
466 permission_set.id, index
467 ),
468 }));
469 }
470 }
471
472 if grant.predefined_roles.is_none() && gcp_residual_permissions(grant).is_none() {
473 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
474 platform: "gcp".to_string(),
475 message: format!(
476 "GCP permission set '{}' entry {} has no permissions",
477 permission_set.id, index
478 ),
479 }));
480 }
481
482 Ok(())
483}
484
485fn generate_role_id(
486 permission_set: &PermissionSet,
487 context: &PermissionContext,
488 suffix: Option<&str>,
489 explicit_label: Option<&str>,
490) -> String {
491 let namespace = custom_role_namespace(context);
492 if has_explicit_label(explicit_label) {
493 let suffix = suffix.unwrap_or("custom");
494 let prefix = format!("role_{namespace}_");
495 let suffix = fit_role_suffix(suffix, GCP_CUSTOM_ROLE_ID_MAX_LEN - prefix.len());
496 return format!("{prefix}{suffix}");
497 }
498
499 let permission_set_slug = sanitize_role_segment(&permission_set.id.replace('/', "_"), 28);
500 match suffix {
501 Some(suffix) => {
502 let prefix = format!("role_{namespace}_{permission_set_slug}_");
503 let suffix = fit_role_suffix(suffix, GCP_CUSTOM_ROLE_ID_MAX_LEN - prefix.len());
504 format!("{prefix}{suffix}")
505 }
506 None => format!("role_{namespace}_{permission_set_slug}"),
507 }
508}
509
510pub fn custom_role_prefix(context: &PermissionContext) -> String {
512 format!("role_{}_", custom_role_namespace(context))
513}
514
515pub fn custom_role_permission_set_prefix(
517 permission_set_id: &str,
518 context: &PermissionContext,
519) -> String {
520 let permission_set_slug = sanitize_role_segment(&permission_set_id.replace('/', "_"), 28);
521 format!("{}{permission_set_slug}", custom_role_prefix(context))
522}
523
524fn custom_role_namespace(context: &PermissionContext) -> String {
525 sanitize_role_segment(context.stack_prefix.as_deref().unwrap_or("stack"), 18)
526}
527
528fn custom_role_title(
529 permission_set: &PermissionSet,
530 context: &PermissionContext,
531 suffix: Option<&str>,
532 explicit_label: Option<&str>,
533) -> String {
534 let deployment_name = context.deployment_name.as_deref();
535 if has_explicit_label(explicit_label) {
536 let title = entry_title_label(explicit_label, &PermissionGrant::default());
537 return role_title(deployment_name, &title);
538 }
539
540 let label = permission_set_display_label(&permission_set.id);
541 let title = match suffix {
542 Some(suffix) => format!(
543 "{label} - {}",
544 entry_title_label(Some(suffix), &PermissionGrant::default())
545 ),
546 None => label,
547 };
548 role_title(deployment_name, &title)
549}
550
551fn custom_role_description(
552 permission_set: &PermissionSet,
553 context: &PermissionContext,
554 entry_description_override: Option<&str>,
555) -> String {
556 let stack_prefix = context.stack_prefix.as_deref().unwrap_or("unknown");
557 let description = entry_description(entry_description_override, &permission_set.description);
558 let description = description.trim_end_matches('.');
559 match context.deployment_name.as_deref() {
560 Some(deployment_name) if !deployment_name.trim().is_empty() => {
561 format!("Used by {deployment_name}. {description}. Resource prefix: {stack_prefix}.")
562 }
563 _ => format!("{description}. Resource prefix: {stack_prefix}."),
564 }
565}
566
567fn role_title(deployment_name: Option<&str>, title: &str) -> String {
568 match deployment_name {
569 Some(deployment_name) if !deployment_name.trim().is_empty() => {
570 format!("{deployment_name}: {title}")
571 }
572 _ => title.to_string(),
573 }
574}
575
576fn permission_set_display_label(permission_set_id: &str) -> String {
577 let mut words = Vec::new();
578 let mut current = String::new();
579
580 for ch in permission_set_id.chars() {
581 if ch.is_ascii_alphanumeric() {
582 current.push(ch.to_ascii_lowercase());
583 } else if !current.is_empty() {
584 words.push(std::mem::take(&mut current));
585 }
586 }
587 if !current.is_empty() {
588 words.push(current);
589 }
590
591 let mut label = words.join(" ");
592 if let Some(first) = label.get_mut(0..1) {
593 first.make_ascii_uppercase();
594 }
595 label
596}
597
598fn role_suffix(
599 grant: &PermissionGrant,
600 explicit_label: Option<&str>,
601 has_multiple_entries: bool,
602) -> Option<String> {
603 let explicit = has_explicit_label(explicit_label);
604 if explicit || has_multiple_entries {
605 let max_len = if explicit { 40 } else { 28 };
606 let mut label = entry_snake_label(explicit_label, grant);
607 if !explicit {
608 label.push('_');
609 label.push_str(&grant_suffix_hash(grant));
610 }
611 Some(sanitize_role_segment(&label, max_len))
612 } else {
613 None
614 }
615}
616
617fn grant_suffix_hash(grant: &PermissionGrant) -> String {
618 let mut values = Vec::new();
619 if let Some(predefined_roles) = &grant.predefined_roles {
620 values.extend(predefined_roles.iter().map(|role| format!("role:{role}")));
621 }
622 if let Some(permissions) = gcp_residual_permissions(grant) {
623 values.extend(
624 permissions
625 .iter()
626 .map(|permission| format!("permission:{permission}")),
627 );
628 }
629 values.sort();
630 stable_role_hash(&values.join("|"))
631}
632
633fn sanitize_role_segment(value: &str, max_len: usize) -> String {
634 let mut out = String::with_capacity(value.len());
635 let mut previous_underscore = false;
636 for ch in value.chars() {
637 let next = if ch.is_ascii_alphanumeric() {
638 ch.to_ascii_lowercase()
639 } else {
640 '_'
641 };
642 if next == '_' {
643 if !previous_underscore {
644 out.push(next);
645 }
646 previous_underscore = true;
647 } else {
648 out.push(next);
649 previous_underscore = false;
650 }
651 }
652
653 let trimmed = out.trim_matches('_');
654 let mut segment = if trimmed.is_empty() {
655 "x".to_string()
656 } else {
657 trimmed.to_string()
658 };
659 if segment.len() > max_len {
660 segment.truncate(max_len);
661 while segment.ends_with('_') {
662 segment.pop();
663 }
664 }
665 if segment.is_empty() {
666 "x".to_string()
667 } else {
668 segment
669 }
670}
671
672fn fit_role_suffix(value: &str, max_len: usize) -> String {
673 if value.len() <= max_len {
674 return value.to_string();
675 }
676
677 let hash = stable_role_hash(value);
678 if max_len <= ROLE_ID_HASH_LEN {
679 return hash[..max_len].to_string();
680 }
681
682 let prefix_len = max_len - ROLE_ID_HASH_LEN - 1;
683 let mut prefix = value.to_string();
684 prefix.truncate(prefix_len);
685 while prefix.ends_with('_') {
686 prefix.pop();
687 }
688
689 if prefix.is_empty() {
690 hash[..max_len].to_string()
691 } else {
692 format!("{prefix}_{hash}")
693 }
694}
695
696fn stable_role_hash(value: &str) -> String {
697 let mut hash = 0x811c9dc5_u32;
698 for byte in value.bytes() {
699 hash ^= u32::from(byte);
700 hash = hash.wrapping_mul(0x01000193);
701 }
702 format!("{hash:08x}")
703}
704
705fn binding_target_scope(binding_spec: &GcpBindingSpec) -> GcpBindingTargetScope {
706 let scope = binding_spec.scope.trim();
707 match scope.strip_prefix("projects/") {
708 Some(project_scope) if !project_scope.contains('/') => GcpBindingTargetScope::Project,
709 _ => GcpBindingTargetScope::CurrentResource,
710 }
711}
712
713fn binding_resource_kind(binding_spec: &GcpBindingSpec) -> Option<GcpBindingResourceKind> {
714 let scope = binding_spec.scope.trim();
715 if scope.contains("/topics/") {
716 return Some(GcpBindingResourceKind::PubsubTopic);
717 }
718 if scope.contains("/subscriptions/") {
719 return Some(GcpBindingResourceKind::PubsubSubscription);
720 }
721 if scope.contains("/repositories/") {
722 return Some(GcpBindingResourceKind::ArtifactRegistryRepository);
723 }
724 None
725}
726
727fn dedupe_bindings(bindings: Vec<GcpIamBinding>) -> Vec<GcpIamBinding> {
728 let mut deduped = Vec::new();
729 for binding in bindings {
730 if !deduped.contains(&binding) {
731 deduped.push(binding);
732 }
733 }
734 deduped
735}