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::{PermissionGrant, PermissionSet};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "PascalCase")]
16pub struct AzureRoleDefinition {
17 pub name: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub id: Option<String>,
22 pub is_custom: bool,
24 pub description: String,
26 pub actions: Vec<String>,
28 pub not_actions: Vec<String>,
30 pub data_actions: Vec<String>,
32 pub not_data_actions: Vec<String>,
34 pub assignable_scopes: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct AzureRoleAssignmentProperties {
42 pub role_definition_id: String,
44 pub principal_id: String,
46 pub scope: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct AzureRoleAssignment {
54 pub properties: AzureRoleAssignmentProperties,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub struct AzureGrantPlan {
62 pub custom_roles: Vec<AzureCustomRole>,
64 pub bindings: Vec<AzureRoleBinding>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "camelCase")]
71pub struct AzureCustomRole {
72 pub key: String,
74 pub role_definition: AzureRoleDefinition,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct AzureRoleBinding {
82 pub permission_set_id: String,
84 pub role_name: String,
86 pub role_definition: AzureRoleDefinitionRef,
88 pub scope: String,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "camelCase")]
95pub enum AzureRoleDefinitionRef {
96 Predefined { role_definition_id: String },
98 Custom { key: String },
100}
101
102pub fn dedupe_azure_role_bindings(bindings: Vec<AzureRoleBinding>) -> Vec<AzureRoleBinding> {
107 let mut seen = BTreeSet::new();
108 let mut deduped = Vec::new();
109
110 for binding in bindings {
111 let role_key = match &binding.role_definition {
112 AzureRoleDefinitionRef::Predefined { role_definition_id } => {
113 format!("predefined:{role_definition_id}")
114 }
115 AzureRoleDefinitionRef::Custom { key } => format!("custom:{key}"),
116 };
117 let dedupe_key = (binding.scope.clone(), role_key);
118
119 if seen.insert(dedupe_key) {
120 deduped.push(binding);
121 }
122 }
123
124 deduped
125}
126
127pub struct AzureRuntimePermissionsGenerator;
129
130impl AzureRuntimePermissionsGenerator {
131 pub fn new() -> Self {
133 Self
134 }
135
136 pub fn generate_role_definition(
141 &self,
142 permission_set: &PermissionSet,
143 binding_target: BindingTarget,
144 context: &PermissionContext,
145 ) -> Result<AzureRoleDefinition> {
146 let azure_platform_permissions =
147 permission_set.platforms.azure.as_ref().ok_or_else(|| {
148 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
149 platform: "azure".to_string(),
150 permission_set_id: permission_set.id.clone(),
151 })
152 })?;
153
154 let base_role_name = self.generate_role_name(&permission_set.id);
155 let role_name = if let Some(ref prefix) = context.stack_prefix {
159 format!("{} ({})", base_role_name, prefix)
160 } else {
161 base_role_name
162 };
163
164 let mut all_actions = Vec::new();
166 let mut all_data_actions = Vec::new();
167 let mut assignable_scopes = Vec::new();
168
169 for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
170 self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
171 if let Some(actions) = &platform_permission.grant.actions {
173 all_actions.extend(actions.clone());
174 }
175 if let Some(data_actions) = &platform_permission.grant.data_actions {
176 all_data_actions.extend(data_actions.clone());
177 }
178
179 let binding_spec = match binding_target {
181 BindingTarget::Stack => {
182 platform_permission.binding.stack.as_ref().ok_or_else(|| {
183 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
184 platform: "azure".to_string(),
185 binding_target: "stack".to_string(),
186 permission_set_id: permission_set.id.clone(),
187 })
188 })?
189 }
190 BindingTarget::Resource => platform_permission
191 .binding
192 .resource
193 .as_ref()
194 .ok_or_else(|| {
195 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
196 platform: "azure".to_string(),
197 binding_target: "resource".to_string(),
198 permission_set_id: permission_set.id.clone(),
199 })
200 })?,
201 };
202
203 let interpolated_scope =
205 VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
206 assignable_scopes.push(interpolated_scope);
207 }
208
209 if all_actions.is_empty() && all_data_actions.is_empty() {
210 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
211 platform: "azure".to_string(),
212 message: format!(
213 "permission set '{}' has no residual Azure actions for a custom role",
214 permission_set.id
215 ),
216 }));
217 }
218
219 all_actions.sort();
221 all_actions.dedup();
222 all_data_actions.sort();
223 all_data_actions.dedup();
224 assignable_scopes.sort();
225 assignable_scopes.dedup();
226
227 Ok(AzureRoleDefinition {
228 name: role_name,
229 id: None, is_custom: true,
231 description: role_description(context, &permission_set.description),
232 actions: all_actions,
233 not_actions: vec![],
234 data_actions: all_data_actions,
235 not_data_actions: vec![],
236 assignable_scopes,
237 })
238 }
239
240 pub fn generate_grant_plan(
242 &self,
243 permission_set: &PermissionSet,
244 binding_target: BindingTarget,
245 context: &PermissionContext,
246 ) -> Result<AzureGrantPlan> {
247 let azure_platform_permissions =
248 permission_set.platforms.azure.as_ref().ok_or_else(|| {
249 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
250 platform: "azure".to_string(),
251 permission_set_id: permission_set.id.clone(),
252 })
253 })?;
254
255 let mut custom_roles = Vec::new();
256 let mut bindings = Vec::new();
257
258 for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
259 self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
260
261 let binding_spec = match binding_target {
262 BindingTarget::Stack => {
263 platform_permission.binding.stack.as_ref().ok_or_else(|| {
264 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
265 platform: "azure".to_string(),
266 binding_target: "stack".to_string(),
267 permission_set_id: permission_set.id.clone(),
268 })
269 })?
270 }
271 BindingTarget::Resource => platform_permission
272 .binding
273 .resource
274 .as_ref()
275 .ok_or_else(|| {
276 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
277 platform: "azure".to_string(),
278 binding_target: "resource".to_string(),
279 permission_set_id: permission_set.id.clone(),
280 })
281 })?,
282 };
283
284 let scope = VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
285 self.validate_azure_scope(&scope, permission_set, index)?;
286
287 if let Some(predefined_roles) = &platform_permission.grant.predefined_roles {
288 for role_name in predefined_roles {
289 let role_definition_id =
290 self.predefined_role_definition_id(role_name, context)?;
291 bindings.push(AzureRoleBinding {
292 permission_set_id: permission_set.id.clone(),
293 role_name: role_name.clone(),
294 role_definition: AzureRoleDefinitionRef::Predefined { role_definition_id },
295 scope: scope.clone(),
296 });
297 }
298 }
299
300 if has_residual_azure_permissions(&platform_permission.grant) {
301 let entry_label = entry_snake_label(
302 platform_permission.label.as_deref(),
303 &platform_permission.grant,
304 );
305 let key = if has_explicit_label(platform_permission.label.as_deref()) {
306 entry_label
307 } else {
308 format!("{}:{}", permission_set.id, entry_label)
309 };
310 let mut role_definition =
311 self.generate_entry_role_definition(permission_set, index, &scope, context)?;
312 role_definition.assignable_scopes = vec![scope.clone()];
313 custom_roles.push(AzureCustomRole {
314 key: key.clone(),
315 role_definition,
316 });
317 bindings.push(AzureRoleBinding {
318 permission_set_id: permission_set.id.clone(),
319 role_name: self.generate_scoped_role_name(permission_set, index),
320 role_definition: AzureRoleDefinitionRef::Custom { key },
321 scope,
322 });
323 }
324 }
325
326 if custom_roles.is_empty() && bindings.is_empty() {
327 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
328 platform: "azure".to_string(),
329 message: format!(
330 "permission set '{}' produced no Azure bindings",
331 permission_set.id
332 ),
333 }));
334 }
335
336 Ok(AzureGrantPlan {
337 custom_roles,
338 bindings: dedupe_azure_role_bindings(bindings),
339 })
340 }
341
342 pub fn generate_role_assignment(
347 &self,
348 permission_set: &PermissionSet,
349 binding_target: BindingTarget,
350 context: &PermissionContext,
351 ) -> Result<AzureRoleAssignment> {
352 let azure_platform_permissions =
353 permission_set.platforms.azure.as_ref().ok_or_else(|| {
354 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
355 platform: "azure".to_string(),
356 permission_set_id: permission_set.id.clone(),
357 })
358 })?;
359
360 let role_definition_id = format!(
362 "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/${{roleDefinitionGuid}}",
363 context
364 .subscription_id
365 .as_deref()
366 .unwrap_or("SUBSCRIPTION_ID")
367 );
368
369 let principal_id = context
370 .principal_id
371 .as_deref()
372 .unwrap_or("PRINCIPAL_ID")
373 .to_string();
374
375 let first_platform_permission = &azure_platform_permissions[0];
378 let binding_spec = match binding_target {
379 BindingTarget::Stack => first_platform_permission
380 .binding
381 .stack
382 .as_ref()
383 .ok_or_else(|| {
384 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
385 platform: "azure".to_string(),
386 binding_target: "stack".to_string(),
387 permission_set_id: permission_set.id.clone(),
388 })
389 })?,
390 BindingTarget::Resource => first_platform_permission
391 .binding
392 .resource
393 .as_ref()
394 .ok_or_else(|| {
395 alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
396 platform: "azure".to_string(),
397 binding_target: "resource".to_string(),
398 permission_set_id: permission_set.id.clone(),
399 })
400 })?,
401 };
402
403 let interpolated_scope =
405 VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
406
407 Ok(AzureRoleAssignment {
408 properties: AzureRoleAssignmentProperties {
409 role_definition_id,
410 principal_id,
411 scope: interpolated_scope,
412 },
413 })
414 }
415
416 fn generate_entry_role_definition(
417 &self,
418 permission_set: &PermissionSet,
419 entry_index: usize,
420 scope: &str,
421 context: &PermissionContext,
422 ) -> Result<AzureRoleDefinition> {
423 let azure_platform_permissions =
424 permission_set.platforms.azure.as_ref().ok_or_else(|| {
425 alien_error::AlienError::new(ErrorData::PlatformNotSupported {
426 platform: "azure".to_string(),
427 permission_set_id: permission_set.id.clone(),
428 })
429 })?;
430 let platform_permission = azure_platform_permissions.get(entry_index).ok_or_else(|| {
431 alien_error::AlienError::new(ErrorData::GeneratorError {
432 platform: "azure".to_string(),
433 message: format!(
434 "permission set '{}' missing Azure entry {}",
435 permission_set.id, entry_index
436 ),
437 })
438 })?;
439
440 let mut actions = platform_permission
441 .grant
442 .actions
443 .clone()
444 .unwrap_or_default();
445 let mut data_actions = platform_permission
446 .grant
447 .data_actions
448 .clone()
449 .unwrap_or_default();
450 actions.sort();
451 actions.dedup();
452 data_actions.sort();
453 data_actions.dedup();
454
455 if actions.is_empty() && data_actions.is_empty() {
456 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
457 platform: "azure".to_string(),
458 message: format!(
459 "permission set '{}' Azure entry {} has no residual actions",
460 permission_set.id, entry_index
461 ),
462 }));
463 }
464
465 Ok(AzureRoleDefinition {
466 name: self.generate_scoped_role_name(permission_set, entry_index),
467 id: None,
468 is_custom: true,
469 description: role_description(
470 context,
471 &entry_description(
472 platform_permission.description.as_deref(),
473 &permission_set.description,
474 ),
475 ),
476 actions,
477 not_actions: vec![],
478 data_actions,
479 not_data_actions: vec![],
480 assignable_scopes: vec![scope.to_string()],
481 })
482 }
483
484 fn generate_role_name(&self, permission_set_id: &str) -> String {
486 permission_set_id
487 .split('/')
488 .map(|part| {
489 part.split('-')
490 .map(|word| {
491 let mut chars = word.chars();
492 match chars.next() {
493 None => String::new(),
494 Some(first) => {
495 first.to_uppercase().collect::<String>() + chars.as_str()
496 }
497 }
498 })
499 .collect::<Vec<String>>()
500 .join(" ")
501 })
502 .collect::<Vec<String>>()
503 .join(" ")
504 }
505
506 fn generate_scoped_role_name(
507 &self,
508 permission_set: &PermissionSet,
509 entry_index: usize,
510 ) -> String {
511 let (has_explicit_label, entry_label) = permission_set
512 .platforms
513 .azure
514 .as_ref()
515 .and_then(|entries| entries.get(entry_index))
516 .map(|entry| {
517 (
518 has_explicit_label(entry.label.as_deref()),
519 entry_title_label(entry.label.as_deref(), &entry.grant),
520 )
521 })
522 .unwrap_or_else(|| (false, "Custom".to_string()));
523 if has_explicit_label {
524 return entry_label;
525 }
526 format!(
527 "{} {}",
528 self.generate_role_name(&permission_set.id),
529 entry_label
530 )
531 }
532
533 fn predefined_role_definition_id(
534 &self,
535 role_name: &str,
536 context: &PermissionContext,
537 ) -> Result<String> {
538 let role_id = azure_predefined_role_id(role_name).ok_or_else(|| {
539 alien_error::AlienError::new(ErrorData::GeneratorError {
540 platform: "azure".to_string(),
541 message: format!("unknown Azure predefined role '{}'", role_name),
542 })
543 })?;
544 let subscription_id = context.subscription_id.as_deref().ok_or_else(|| {
545 alien_error::AlienError::new(ErrorData::VariableNotFound {
546 variable: "subscriptionId".to_string(),
547 })
548 })?;
549 Ok(format!(
550 "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}",
551 subscription_id, role_id
552 ))
553 }
554
555 fn validate_azure_grant(
556 &self,
557 grant: &PermissionGrant,
558 permission_set: &PermissionSet,
559 entry_index: usize,
560 ) -> Result<()> {
561 let has_predefined = grant
562 .predefined_roles
563 .as_ref()
564 .is_some_and(|roles| !roles.is_empty());
565 if let Some(predefined_roles) = &grant.predefined_roles {
566 if predefined_roles.is_empty() {
567 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
568 platform: "azure".to_string(),
569 message: format!(
570 "permission set '{}' Azure entry {} has empty predefinedRoles",
571 permission_set.id, entry_index
572 ),
573 }));
574 }
575 for role in predefined_roles {
576 if azure_predefined_role_id(role).is_none() {
577 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
578 platform: "azure".to_string(),
579 message: format!(
580 "permission set '{}' Azure entry {} references unknown predefined role '{}'",
581 permission_set.id, entry_index, role
582 ),
583 }));
584 }
585 }
586 }
587
588 if !has_predefined && !has_residual_azure_permissions(grant) {
589 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
590 platform: "azure".to_string(),
591 message: format!(
592 "permission set '{}' Azure entry {} has no predefined role or residual actions",
593 permission_set.id, entry_index
594 ),
595 }));
596 }
597
598 Ok(())
599 }
600
601 fn validate_azure_scope(
602 &self,
603 scope: &str,
604 permission_set: &PermissionSet,
605 entry_index: usize,
606 ) -> Result<()> {
607 if scope.contains('*') {
608 return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
609 platform: "azure".to_string(),
610 message: format!(
611 "permission set '{}' Azure entry {} uses wildcard scope '{}'",
612 permission_set.id, entry_index, scope
613 ),
614 }));
615 }
616 Ok(())
617 }
618}
619
620impl Default for AzureRuntimePermissionsGenerator {
621 fn default() -> Self {
622 Self::new()
623 }
624}
625
626fn has_residual_azure_permissions(grant: &PermissionGrant) -> bool {
627 grant
628 .actions
629 .as_ref()
630 .is_some_and(|actions| !actions.is_empty())
631 || grant
632 .data_actions
633 .as_ref()
634 .is_some_and(|actions| !actions.is_empty())
635}
636
637fn role_description(context: &PermissionContext, description: &str) -> String {
638 let description = description.trim_end_matches('.');
639 match context.deployment_name.as_deref() {
640 Some(deployment_name) if !deployment_name.trim().is_empty() => {
641 let stack_prefix = context.stack_prefix.as_deref().unwrap_or("unknown");
642 format!("Used by {deployment_name}. {description}. Resource prefix: {stack_prefix}.")
643 }
644 _ => description.to_string(),
645 }
646}
647
648pub fn azure_predefined_role_id(role_name: &str) -> Option<&'static str> {
649 match role_name {
650 "AcrPull" => Some("7f951dda-4ed3-4680-a7ca-43fe172d538d"),
651 "AcrPush" => Some("8311e382-0749-4cb8-b61a-304f252e45ec"),
652 "Azure Service Bus Data Receiver" => Some("4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"),
653 "Azure Service Bus Data Sender" => Some("69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"),
654 "Key Vault Contributor" => Some("f25e0fa2-a7c8-4377-a976-54943a77a395"),
655 "Key Vault Secrets User" => Some("4633458b-17de-408a-b874-0445c86b69e6"),
656 "Managed Identity Contributor" => Some("e40ec5ca-96e0-45a2-b4ff-59039f2c2b59"),
657 "Network Contributor" => Some("4d97b98b-1d4f-4787-a291-c67834d212e7"),
658 "Reader" => Some("acdd72a7-3385-48ef-bd42-f606fba81ae7"),
659 "Storage Blob Data Contributor" => Some("ba92f5b4-2d11-453d-a403-e96b0029c9fe"),
660 "Storage Blob Data Reader" => Some("2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"),
661 "Storage Table Data Contributor" => Some("0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3"),
662 "Storage Table Data Reader" => Some("76199698-9eea-4c19-bc75-cec21354c6b6"),
663 _ => None,
664 }
665}