Skip to main content

alien_permissions/generators/
azure_runtime.rs

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/// Azure role definition
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "PascalCase")]
16pub struct AzureRoleDefinition {
17    /// Human-readable role name
18    pub name: String,
19    /// Role ID (will be generated by Azure)
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub id: Option<String>,
22    /// Whether this is a custom role
23    pub is_custom: bool,
24    /// Description of what the role allows
25    pub description: String,
26    /// List of allowed actions
27    pub actions: Vec<String>,
28    /// List of denied actions
29    pub not_actions: Vec<String>,
30    /// List of allowed data actions
31    pub data_actions: Vec<String>,
32    /// List of denied data actions
33    pub not_data_actions: Vec<String>,
34    /// Scopes where this role can be assigned
35    pub assignable_scopes: Vec<String>,
36}
37
38/// Azure role assignment properties
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct AzureRoleAssignmentProperties {
42    /// Role definition ID
43    pub role_definition_id: String,
44    /// Principal ID (user, group, or service principal)
45    pub principal_id: String,
46    /// Scope where the role is assigned
47    pub scope: String,
48}
49
50/// Azure role assignment
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct AzureRoleAssignment {
54    /// Role assignment properties
55    pub properties: AzureRoleAssignmentProperties,
56}
57
58/// Azure generated grant plan.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub struct AzureGrantPlan {
62    /// Residual custom roles that need role definitions.
63    pub custom_roles: Vec<AzureCustomRole>,
64    /// Role bindings for both predefined and residual custom roles.
65    pub bindings: Vec<AzureRoleBinding>,
66}
67
68/// Residual Azure custom role.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "camelCase")]
71pub struct AzureCustomRole {
72    /// Stable key used by Terraform/runtime to link role definition and binding.
73    pub key: String,
74    /// Custom role definition.
75    pub role_definition: AzureRoleDefinition,
76}
77
78/// Azure role binding.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct AzureRoleBinding {
82    /// Permission set that authored the binding.
83    pub permission_set_id: String,
84    /// Azure role name.
85    pub role_name: String,
86    /// Full Azure role definition ID, or a custom role key.
87    pub role_definition: AzureRoleDefinitionRef,
88    /// Scope where the role is assigned.
89    pub scope: String,
90}
91
92/// Azure role definition reference.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "camelCase")]
95pub enum AzureRoleDefinitionRef {
96    /// Built-in Azure role definition ID.
97    Predefined { role_definition_id: String },
98    /// Residual custom role keyed by the grant plan.
99    Custom { key: String },
100}
101
102/// Deduplicate Azure bindings by the tuple Azure actually enforces:
103/// role definition and assignment scope. The caller owns the principal, so a
104/// principal-specific compiler can safely drop duplicate bindings before it
105/// creates Terraform resources or runtime role assignments.
106pub 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
127/// Azure runtime permissions generator for role definitions and role assignments
128pub struct AzureRuntimePermissionsGenerator;
129
130impl AzureRuntimePermissionsGenerator {
131    /// Create a new Azure runtime permissions generator
132    pub fn new() -> Self {
133        Self
134    }
135
136    /// Generate an Azure role definition from a permission set
137    ///
138    /// Takes a PermissionSet and produces Azure role definitions
139    /// that can be created at runtime.
140    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        // Include the stack prefix in the role name to avoid 409
156        // RoleDefinitionWithSameNameExists conflicts when multiple deployments
157        // coexist in the same subscription.
158        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        // Aggregate residual actions and data actions from all platform permissions.
165        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            // Extract actions and data actions
172            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            // Generate assignable scopes based on binding target
180            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            // Interpolate variables in the scope
204            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        // Remove duplicates and sort
220        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, // Will be generated by Azure
230            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    /// Generate Azure predefined bindings and residual custom roles from a permission set.
241    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    /// Generate an Azure role assignment
343    ///
344    /// Takes a PermissionSet and binding target, produces Azure role assignments
345    /// that can be created at runtime.
346    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        // For this example, we'll use placeholder values
361        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        // Use the first platform permission's binding for simplicity
376        // In practice, you might want to handle multiple bindings differently
377        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        // Interpolate variables in the scope
404        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    /// Generate a human-readable role name
485    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}