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 generated grant plan.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct AzureGrantPlan {
42    /// Residual custom roles that need role definitions.
43    pub custom_roles: Vec<AzureCustomRole>,
44    /// Role bindings for both predefined and residual custom roles.
45    pub bindings: Vec<AzureRoleBinding>,
46}
47
48/// Residual Azure custom role.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub struct AzureCustomRole {
52    /// Stable key used by Terraform/runtime to link role definition and binding.
53    pub key: String,
54    /// Custom role definition.
55    pub role_definition: AzureRoleDefinition,
56}
57
58/// Azure role binding.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub struct AzureRoleBinding {
62    /// Permission set that authored the binding.
63    pub permission_set_id: String,
64    /// Azure role name.
65    pub role_name: String,
66    /// Full Azure role definition ID, or a custom role key.
67    pub role_definition: AzureRoleDefinitionRef,
68    /// Scope where the role is assigned.
69    pub scope: String,
70}
71
72/// Azure role definition reference.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "camelCase")]
75pub enum AzureRoleDefinitionRef {
76    /// Built-in Azure role definition ID.
77    Predefined { role_definition_id: String },
78    /// Residual custom role keyed by the grant plan.
79    Custom { key: String },
80}
81
82/// Deduplicate Azure bindings by the tuple Azure actually enforces:
83/// role definition and assignment scope. The caller owns the principal, so a
84/// principal-specific compiler can safely drop duplicate bindings before it
85/// creates Terraform resources or runtime role assignments.
86pub fn dedupe_azure_role_bindings(bindings: Vec<AzureRoleBinding>) -> Vec<AzureRoleBinding> {
87    let mut seen = BTreeSet::new();
88    let mut deduped = Vec::new();
89
90    for binding in bindings {
91        let role_key = match &binding.role_definition {
92            AzureRoleDefinitionRef::Predefined { role_definition_id } => {
93                format!("predefined:{role_definition_id}")
94            }
95            AzureRoleDefinitionRef::Custom { key } => format!("custom:{key}"),
96        };
97        let dedupe_key = (binding.scope.clone(), role_key);
98
99        if seen.insert(dedupe_key) {
100            deduped.push(binding);
101        }
102    }
103
104    deduped
105}
106
107/// Azure runtime permissions generator for role definitions and role assignments
108pub struct AzureRuntimePermissionsGenerator;
109
110impl AzureRuntimePermissionsGenerator {
111    /// Create a new Azure runtime permissions generator
112    pub fn new() -> Self {
113        Self
114    }
115
116    /// Generate an Azure role definition from a permission set
117    ///
118    /// Takes a PermissionSet and produces Azure role definitions
119    /// that can be created at runtime.
120    pub fn generate_role_definition(
121        &self,
122        permission_set: &PermissionSet,
123        binding_target: BindingTarget,
124        context: &PermissionContext,
125    ) -> Result<AzureRoleDefinition> {
126        let azure_platform_permissions =
127            permission_set.platforms.azure.as_ref().ok_or_else(|| {
128                alien_error::AlienError::new(ErrorData::PlatformNotSupported {
129                    platform: "azure".to_string(),
130                    permission_set_id: permission_set.id.clone(),
131                })
132            })?;
133
134        // An empty `azure` list is valid (postgres/data-access), but a single role definition has no
135        // empty form, so fail with a specific error instead of the generic "no residual actions"
136        // below. Empty-azure sets go through generate_grant_plan, never here.
137        if azure_platform_permissions.is_empty() {
138            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
139                platform: "azure".to_string(),
140                message: format!(
141                    "permission set '{}' declares Azure support but grants nothing, so it has no \
142                     custom role to generate; callers that tolerate empty Azure grants must use \
143                     generate_grant_plan",
144                    permission_set.id
145                ),
146            }));
147        }
148
149        let base_role_name = self.generate_role_name(&permission_set.id);
150        // Include the stack prefix in the role name to avoid 409
151        // RoleDefinitionWithSameNameExists conflicts when multiple deployments
152        // coexist in the same subscription.
153        let role_name = if let Some(ref prefix) = context.stack_prefix {
154            format!("{} ({})", base_role_name, prefix)
155        } else {
156            base_role_name
157        };
158
159        // Aggregate residual actions and data actions from all platform permissions.
160        let mut all_actions = Vec::new();
161        let mut all_data_actions = Vec::new();
162        let mut assignable_scopes = Vec::new();
163
164        for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
165            self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
166            if let Some(actions) = &platform_permission.grant.actions {
167                all_actions.extend(actions.clone());
168            }
169            if let Some(data_actions) = &platform_permission.grant.data_actions {
170                all_data_actions.extend(data_actions.clone());
171            }
172
173            // Generate assignable scopes based on binding target
174            let binding_spec = match binding_target {
175                BindingTarget::Stack => {
176                    platform_permission.binding.stack.as_ref().ok_or_else(|| {
177                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
178                            platform: "azure".to_string(),
179                            binding_target: "stack".to_string(),
180                            permission_set_id: permission_set.id.clone(),
181                        })
182                    })?
183                }
184                BindingTarget::Resource => platform_permission
185                    .binding
186                    .resource
187                    .as_ref()
188                    .ok_or_else(|| {
189                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
190                            platform: "azure".to_string(),
191                            binding_target: "resource".to_string(),
192                            permission_set_id: permission_set.id.clone(),
193                        })
194                    })?,
195            };
196
197            // Interpolate variables in the scope
198            let interpolated_scope =
199                VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
200            assignable_scopes.push(interpolated_scope);
201        }
202
203        if all_actions.is_empty() && all_data_actions.is_empty() {
204            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
205                platform: "azure".to_string(),
206                message: format!(
207                    "permission set '{}' has no residual Azure actions for a custom role",
208                    permission_set.id
209                ),
210            }));
211        }
212
213        // Sort + dedup so a given permission set always renders the same role definition — a stable,
214        // deterministic action list (Azure also rejects duplicate actions in a role definition).
215        all_actions.sort();
216        all_actions.dedup();
217        all_data_actions.sort();
218        all_data_actions.dedup();
219        assignable_scopes.sort();
220        assignable_scopes.dedup();
221
222        Ok(AzureRoleDefinition {
223            name: role_name,
224            id: None, // Will be generated by Azure
225            is_custom: true,
226            description: role_description(context, &permission_set.description),
227            actions: all_actions,
228            not_actions: vec![],
229            data_actions: all_data_actions,
230            not_data_actions: vec![],
231            assignable_scopes,
232        })
233    }
234
235    /// Generate Azure predefined bindings and residual custom roles from a permission set.
236    pub fn generate_grant_plan(
237        &self,
238        permission_set: &PermissionSet,
239        binding_target: BindingTarget,
240        context: &PermissionContext,
241    ) -> Result<AzureGrantPlan> {
242        let azure_platform_permissions =
243            permission_set.platforms.azure.as_ref().ok_or_else(|| {
244                alien_error::AlienError::new(ErrorData::PlatformNotSupported {
245                    platform: "azure".to_string(),
246                    permission_set_id: permission_set.id.clone(),
247                })
248            })?;
249
250        let mut custom_roles = Vec::new();
251        let mut bindings = Vec::new();
252
253        // An empty `azure` list is valid by design (postgres/data-access). Return an empty plan rather
254        // than fail-fasting below, matching the Terraform emitter's
255        // `emit_role_definition_and_assignments` skip so the push and runtime paths agree.
256        if azure_platform_permissions.is_empty() {
257            return Ok(AzureGrantPlan {
258                custom_roles,
259                bindings,
260            });
261        }
262
263        for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
264            self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
265
266            let binding_spec = match binding_target {
267                BindingTarget::Stack => {
268                    platform_permission.binding.stack.as_ref().ok_or_else(|| {
269                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
270                            platform: "azure".to_string(),
271                            binding_target: "stack".to_string(),
272                            permission_set_id: permission_set.id.clone(),
273                        })
274                    })?
275                }
276                BindingTarget::Resource => platform_permission
277                    .binding
278                    .resource
279                    .as_ref()
280                    .ok_or_else(|| {
281                        alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
282                            platform: "azure".to_string(),
283                            binding_target: "resource".to_string(),
284                            permission_set_id: permission_set.id.clone(),
285                        })
286                    })?,
287            };
288
289            let scope = VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
290            self.validate_azure_scope(&scope, permission_set, index)?;
291
292            if let Some(predefined_roles) = &platform_permission.grant.predefined_roles {
293                for role_name in predefined_roles {
294                    let role_definition_id =
295                        self.predefined_role_definition_id(role_name, context)?;
296                    bindings.push(AzureRoleBinding {
297                        permission_set_id: permission_set.id.clone(),
298                        role_name: role_name.clone(),
299                        role_definition: AzureRoleDefinitionRef::Predefined { role_definition_id },
300                        scope: scope.clone(),
301                    });
302                }
303            }
304
305            if has_residual_azure_permissions(&platform_permission.grant) {
306                let entry_label = entry_snake_label(
307                    platform_permission.label.as_deref(),
308                    &platform_permission.grant,
309                );
310                let key = if has_explicit_label(platform_permission.label.as_deref()) {
311                    entry_label
312                } else {
313                    format!("{}:{}", permission_set.id, entry_label)
314                };
315                let mut role_definition =
316                    self.generate_entry_role_definition(permission_set, index, &scope, context)?;
317                role_definition.assignable_scopes = vec![scope.clone()];
318                custom_roles.push(AzureCustomRole {
319                    key: key.clone(),
320                    role_definition,
321                });
322                bindings.push(AzureRoleBinding {
323                    permission_set_id: permission_set.id.clone(),
324                    role_name: self.generate_scoped_role_name(permission_set, index),
325                    role_definition: AzureRoleDefinitionRef::Custom { key },
326                    scope,
327                });
328            }
329        }
330
331        if custom_roles.is_empty() && bindings.is_empty() {
332            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
333                platform: "azure".to_string(),
334                message: format!(
335                    "permission set '{}' produced no Azure bindings",
336                    permission_set.id
337                ),
338            }));
339        }
340
341        Ok(AzureGrantPlan {
342            custom_roles,
343            bindings: dedupe_azure_role_bindings(bindings),
344        })
345    }
346
347    fn generate_entry_role_definition(
348        &self,
349        permission_set: &PermissionSet,
350        entry_index: usize,
351        scope: &str,
352        context: &PermissionContext,
353    ) -> Result<AzureRoleDefinition> {
354        let azure_platform_permissions =
355            permission_set.platforms.azure.as_ref().ok_or_else(|| {
356                alien_error::AlienError::new(ErrorData::PlatformNotSupported {
357                    platform: "azure".to_string(),
358                    permission_set_id: permission_set.id.clone(),
359                })
360            })?;
361        let platform_permission = azure_platform_permissions.get(entry_index).ok_or_else(|| {
362            alien_error::AlienError::new(ErrorData::GeneratorError {
363                platform: "azure".to_string(),
364                message: format!(
365                    "permission set '{}' missing Azure entry {}",
366                    permission_set.id, entry_index
367                ),
368            })
369        })?;
370
371        let mut actions = platform_permission
372            .grant
373            .actions
374            .clone()
375            .unwrap_or_default();
376        let mut data_actions = platform_permission
377            .grant
378            .data_actions
379            .clone()
380            .unwrap_or_default();
381        actions.sort();
382        actions.dedup();
383        data_actions.sort();
384        data_actions.dedup();
385
386        if actions.is_empty() && data_actions.is_empty() {
387            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
388                platform: "azure".to_string(),
389                message: format!(
390                    "permission set '{}' Azure entry {} has no residual actions",
391                    permission_set.id, entry_index
392                ),
393            }));
394        }
395
396        Ok(AzureRoleDefinition {
397            name: self.generate_scoped_role_name(permission_set, entry_index),
398            id: None,
399            is_custom: true,
400            description: role_description(
401                context,
402                &entry_description(
403                    platform_permission.description.as_deref(),
404                    &permission_set.description,
405                ),
406            ),
407            actions,
408            not_actions: vec![],
409            data_actions,
410            not_data_actions: vec![],
411            assignable_scopes: vec![scope.to_string()],
412        })
413    }
414
415    /// Generate a human-readable role name
416    fn generate_role_name(&self, permission_set_id: &str) -> String {
417        permission_set_id
418            .split('/')
419            .map(|part| {
420                part.split('-')
421                    .map(|word| {
422                        let mut chars = word.chars();
423                        match chars.next() {
424                            None => String::new(),
425                            Some(first) => {
426                                first.to_uppercase().collect::<String>() + chars.as_str()
427                            }
428                        }
429                    })
430                    .collect::<Vec<String>>()
431                    .join(" ")
432            })
433            .collect::<Vec<String>>()
434            .join(" ")
435    }
436
437    fn generate_scoped_role_name(
438        &self,
439        permission_set: &PermissionSet,
440        entry_index: usize,
441    ) -> String {
442        let (has_explicit_label, entry_label) = permission_set
443            .platforms
444            .azure
445            .as_ref()
446            .and_then(|entries| entries.get(entry_index))
447            .map(|entry| {
448                (
449                    has_explicit_label(entry.label.as_deref()),
450                    entry_title_label(entry.label.as_deref(), &entry.grant),
451                )
452            })
453            .unwrap_or_else(|| (false, "Custom".to_string()));
454        if has_explicit_label {
455            return entry_label;
456        }
457        format!(
458            "{} {}",
459            self.generate_role_name(&permission_set.id),
460            entry_label
461        )
462    }
463
464    fn predefined_role_definition_id(
465        &self,
466        role_name: &str,
467        context: &PermissionContext,
468    ) -> Result<String> {
469        let role_id = azure_predefined_role_id(role_name).ok_or_else(|| {
470            alien_error::AlienError::new(ErrorData::GeneratorError {
471                platform: "azure".to_string(),
472                message: format!("unknown Azure predefined role '{}'", role_name),
473            })
474        })?;
475        let subscription_id = context.subscription_id.as_deref().ok_or_else(|| {
476            alien_error::AlienError::new(ErrorData::VariableNotFound {
477                variable: "subscriptionId".to_string(),
478            })
479        })?;
480        Ok(format!(
481            "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}",
482            subscription_id, role_id
483        ))
484    }
485
486    fn validate_azure_grant(
487        &self,
488        grant: &PermissionGrant,
489        permission_set: &PermissionSet,
490        entry_index: usize,
491    ) -> Result<()> {
492        let has_predefined = grant
493            .predefined_roles
494            .as_ref()
495            .is_some_and(|roles| !roles.is_empty());
496        if let Some(predefined_roles) = &grant.predefined_roles {
497            if predefined_roles.is_empty() {
498                return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
499                    platform: "azure".to_string(),
500                    message: format!(
501                        "permission set '{}' Azure entry {} has empty predefinedRoles",
502                        permission_set.id, entry_index
503                    ),
504                }));
505            }
506            for role in predefined_roles {
507                if azure_predefined_role_id(role).is_none() {
508                    return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
509                        platform: "azure".to_string(),
510                        message: format!(
511                            "permission set '{}' Azure entry {} references unknown predefined role '{}'",
512                            permission_set.id, entry_index, role
513                        ),
514                    }));
515                }
516            }
517        }
518
519        if !has_predefined && !has_residual_azure_permissions(grant) {
520            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
521                platform: "azure".to_string(),
522                message: format!(
523                    "permission set '{}' Azure entry {} has no predefined role or residual actions",
524                    permission_set.id, entry_index
525                ),
526            }));
527        }
528
529        Ok(())
530    }
531
532    fn validate_azure_scope(
533        &self,
534        scope: &str,
535        permission_set: &PermissionSet,
536        entry_index: usize,
537    ) -> Result<()> {
538        if scope.contains('*') {
539            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
540                platform: "azure".to_string(),
541                message: format!(
542                    "permission set '{}' Azure entry {} uses wildcard scope '{}'",
543                    permission_set.id, entry_index, scope
544                ),
545            }));
546        }
547        Ok(())
548    }
549}
550
551impl Default for AzureRuntimePermissionsGenerator {
552    fn default() -> Self {
553        Self::new()
554    }
555}
556
557fn has_residual_azure_permissions(grant: &PermissionGrant) -> bool {
558    grant
559        .actions
560        .as_ref()
561        .is_some_and(|actions| !actions.is_empty())
562        || grant
563            .data_actions
564            .as_ref()
565            .is_some_and(|actions| !actions.is_empty())
566}
567
568fn role_description(context: &PermissionContext, description: &str) -> String {
569    let description = description.trim_end_matches('.');
570    match context.deployment_name.as_deref() {
571        Some(deployment_name) if !deployment_name.trim().is_empty() => {
572            let stack_prefix = context.stack_prefix.as_deref().unwrap_or("unknown");
573            format!("Used by {deployment_name}. {description}. Resource prefix: {stack_prefix}.")
574        }
575        _ => description.to_string(),
576    }
577}
578
579pub fn azure_predefined_role_id(role_name: &str) -> Option<&'static str> {
580    match role_name {
581        "AcrPull" => Some("7f951dda-4ed3-4680-a7ca-43fe172d538d"),
582        "AcrPush" => Some("8311e382-0749-4cb8-b61a-304f252e45ec"),
583        "Azure Service Bus Data Receiver" => Some("4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"),
584        "Azure Service Bus Data Sender" => Some("69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"),
585        "Key Vault Contributor" => Some("f25e0fa2-a7c8-4377-a976-54943a77a395"),
586        "Key Vault Secrets User" => Some("4633458b-17de-408a-b874-0445c86b69e6"),
587        "Managed Identity Contributor" => Some("e40ec5ca-96e0-45a2-b4ff-59039f2c2b59"),
588        "Network Contributor" => Some("4d97b98b-1d4f-4787-a291-c67834d212e7"),
589        "Reader" => Some("acdd72a7-3385-48ef-bd42-f606fba81ae7"),
590        "Storage Blob Data Contributor" => Some("ba92f5b4-2d11-453d-a403-e96b0029c9fe"),
591        "Storage Blob Data Reader" => Some("2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"),
592        "Storage Table Data Contributor" => Some("0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3"),
593        "Storage Table Data Reader" => Some("76199698-9eea-4c19-bc75-cec21354c6b6"),
594        _ => None,
595    }
596}