use crate::{
error::{ErrorData, Result},
generators::labels::{
entry_description, entry_snake_label, entry_title_label, has_explicit_label,
},
variables::VariableInterpolator,
BindingTarget, PermissionContext,
};
use alien_core::{PermissionGrant, PermissionSet};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct AzureRoleDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub is_custom: bool,
pub description: String,
pub actions: Vec<String>,
pub not_actions: Vec<String>,
pub data_actions: Vec<String>,
pub not_data_actions: Vec<String>,
pub assignable_scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AzureRoleAssignmentProperties {
pub role_definition_id: String,
pub principal_id: String,
pub scope: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AzureRoleAssignment {
pub properties: AzureRoleAssignmentProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AzureGrantPlan {
pub custom_roles: Vec<AzureCustomRole>,
pub bindings: Vec<AzureRoleBinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AzureCustomRole {
pub key: String,
pub role_definition: AzureRoleDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AzureRoleBinding {
pub permission_set_id: String,
pub role_name: String,
pub role_definition: AzureRoleDefinitionRef,
pub scope: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AzureRoleDefinitionRef {
Predefined { role_definition_id: String },
Custom { key: String },
}
pub fn dedupe_azure_role_bindings(bindings: Vec<AzureRoleBinding>) -> Vec<AzureRoleBinding> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for binding in bindings {
let role_key = match &binding.role_definition {
AzureRoleDefinitionRef::Predefined { role_definition_id } => {
format!("predefined:{role_definition_id}")
}
AzureRoleDefinitionRef::Custom { key } => format!("custom:{key}"),
};
let dedupe_key = (binding.scope.clone(), role_key);
if seen.insert(dedupe_key) {
deduped.push(binding);
}
}
deduped
}
pub struct AzureRuntimePermissionsGenerator;
impl AzureRuntimePermissionsGenerator {
pub fn new() -> Self {
Self
}
pub fn generate_role_definition(
&self,
permission_set: &PermissionSet,
binding_target: BindingTarget,
context: &PermissionContext,
) -> Result<AzureRoleDefinition> {
let azure_platform_permissions =
permission_set.platforms.azure.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::PlatformNotSupported {
platform: "azure".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?;
let base_role_name = self.generate_role_name(&permission_set.id);
let role_name = if let Some(ref prefix) = context.stack_prefix {
format!("{} ({})", base_role_name, prefix)
} else {
base_role_name
};
let mut all_actions = Vec::new();
let mut all_data_actions = Vec::new();
let mut assignable_scopes = Vec::new();
for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
if let Some(actions) = &platform_permission.grant.actions {
all_actions.extend(actions.clone());
}
if let Some(data_actions) = &platform_permission.grant.data_actions {
all_data_actions.extend(data_actions.clone());
}
let binding_spec = match binding_target {
BindingTarget::Stack => {
platform_permission.binding.stack.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "stack".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?
}
BindingTarget::Resource => platform_permission
.binding
.resource
.as_ref()
.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "resource".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?,
};
let interpolated_scope =
VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
assignable_scopes.push(interpolated_scope);
}
if all_actions.is_empty() && all_data_actions.is_empty() {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' has no residual Azure actions for a custom role",
permission_set.id
),
}));
}
all_actions.sort();
all_actions.dedup();
all_data_actions.sort();
all_data_actions.dedup();
assignable_scopes.sort();
assignable_scopes.dedup();
Ok(AzureRoleDefinition {
name: role_name,
id: None, is_custom: true,
description: role_description(context, &permission_set.description),
actions: all_actions,
not_actions: vec![],
data_actions: all_data_actions,
not_data_actions: vec![],
assignable_scopes,
})
}
pub fn generate_grant_plan(
&self,
permission_set: &PermissionSet,
binding_target: BindingTarget,
context: &PermissionContext,
) -> Result<AzureGrantPlan> {
let azure_platform_permissions =
permission_set.platforms.azure.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::PlatformNotSupported {
platform: "azure".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?;
let mut custom_roles = Vec::new();
let mut bindings = Vec::new();
for (index, platform_permission) in azure_platform_permissions.iter().enumerate() {
self.validate_azure_grant(&platform_permission.grant, permission_set, index)?;
let binding_spec = match binding_target {
BindingTarget::Stack => {
platform_permission.binding.stack.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "stack".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?
}
BindingTarget::Resource => platform_permission
.binding
.resource
.as_ref()
.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "resource".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?,
};
let scope = VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
self.validate_azure_scope(&scope, permission_set, index)?;
if let Some(predefined_roles) = &platform_permission.grant.predefined_roles {
for role_name in predefined_roles {
let role_definition_id =
self.predefined_role_definition_id(role_name, context)?;
bindings.push(AzureRoleBinding {
permission_set_id: permission_set.id.clone(),
role_name: role_name.clone(),
role_definition: AzureRoleDefinitionRef::Predefined { role_definition_id },
scope: scope.clone(),
});
}
}
if has_residual_azure_permissions(&platform_permission.grant) {
let entry_label = entry_snake_label(
platform_permission.label.as_deref(),
&platform_permission.grant,
);
let key = if has_explicit_label(platform_permission.label.as_deref()) {
entry_label
} else {
format!("{}:{}", permission_set.id, entry_label)
};
let mut role_definition =
self.generate_entry_role_definition(permission_set, index, &scope, context)?;
role_definition.assignable_scopes = vec![scope.clone()];
custom_roles.push(AzureCustomRole {
key: key.clone(),
role_definition,
});
bindings.push(AzureRoleBinding {
permission_set_id: permission_set.id.clone(),
role_name: self.generate_scoped_role_name(permission_set, index),
role_definition: AzureRoleDefinitionRef::Custom { key },
scope,
});
}
}
if custom_roles.is_empty() && bindings.is_empty() {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' produced no Azure bindings",
permission_set.id
),
}));
}
Ok(AzureGrantPlan {
custom_roles,
bindings: dedupe_azure_role_bindings(bindings),
})
}
pub fn generate_role_assignment(
&self,
permission_set: &PermissionSet,
binding_target: BindingTarget,
context: &PermissionContext,
) -> Result<AzureRoleAssignment> {
let azure_platform_permissions =
permission_set.platforms.azure.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::PlatformNotSupported {
platform: "azure".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?;
let role_definition_id = format!(
"/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/${{roleDefinitionGuid}}",
context
.subscription_id
.as_deref()
.unwrap_or("SUBSCRIPTION_ID")
);
let principal_id = context
.principal_id
.as_deref()
.unwrap_or("PRINCIPAL_ID")
.to_string();
let first_platform_permission = &azure_platform_permissions[0];
let binding_spec = match binding_target {
BindingTarget::Stack => first_platform_permission
.binding
.stack
.as_ref()
.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "stack".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?,
BindingTarget::Resource => first_platform_permission
.binding
.resource
.as_ref()
.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::BindingTargetNotSupported {
platform: "azure".to_string(),
binding_target: "resource".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?,
};
let interpolated_scope =
VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?;
Ok(AzureRoleAssignment {
properties: AzureRoleAssignmentProperties {
role_definition_id,
principal_id,
scope: interpolated_scope,
},
})
}
fn generate_entry_role_definition(
&self,
permission_set: &PermissionSet,
entry_index: usize,
scope: &str,
context: &PermissionContext,
) -> Result<AzureRoleDefinition> {
let azure_platform_permissions =
permission_set.platforms.azure.as_ref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::PlatformNotSupported {
platform: "azure".to_string(),
permission_set_id: permission_set.id.clone(),
})
})?;
let platform_permission = azure_platform_permissions.get(entry_index).ok_or_else(|| {
alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' missing Azure entry {}",
permission_set.id, entry_index
),
})
})?;
let mut actions = platform_permission
.grant
.actions
.clone()
.unwrap_or_default();
let mut data_actions = platform_permission
.grant
.data_actions
.clone()
.unwrap_or_default();
actions.sort();
actions.dedup();
data_actions.sort();
data_actions.dedup();
if actions.is_empty() && data_actions.is_empty() {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' Azure entry {} has no residual actions",
permission_set.id, entry_index
),
}));
}
Ok(AzureRoleDefinition {
name: self.generate_scoped_role_name(permission_set, entry_index),
id: None,
is_custom: true,
description: role_description(
context,
&entry_description(
platform_permission.description.as_deref(),
&permission_set.description,
),
),
actions,
not_actions: vec![],
data_actions,
not_data_actions: vec![],
assignable_scopes: vec![scope.to_string()],
})
}
fn generate_role_name(&self, permission_set_id: &str) -> String {
permission_set_id
.split('/')
.map(|part| {
part.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + chars.as_str()
}
}
})
.collect::<Vec<String>>()
.join(" ")
})
.collect::<Vec<String>>()
.join(" ")
}
fn generate_scoped_role_name(
&self,
permission_set: &PermissionSet,
entry_index: usize,
) -> String {
let (has_explicit_label, entry_label) = permission_set
.platforms
.azure
.as_ref()
.and_then(|entries| entries.get(entry_index))
.map(|entry| {
(
has_explicit_label(entry.label.as_deref()),
entry_title_label(entry.label.as_deref(), &entry.grant),
)
})
.unwrap_or_else(|| (false, "Custom".to_string()));
if has_explicit_label {
return entry_label;
}
format!(
"{} {}",
self.generate_role_name(&permission_set.id),
entry_label
)
}
fn predefined_role_definition_id(
&self,
role_name: &str,
context: &PermissionContext,
) -> Result<String> {
let role_id = azure_predefined_role_id(role_name).ok_or_else(|| {
alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!("unknown Azure predefined role '{}'", role_name),
})
})?;
let subscription_id = context.subscription_id.as_deref().ok_or_else(|| {
alien_error::AlienError::new(ErrorData::VariableNotFound {
variable: "subscriptionId".to_string(),
})
})?;
Ok(format!(
"/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}",
subscription_id, role_id
))
}
fn validate_azure_grant(
&self,
grant: &PermissionGrant,
permission_set: &PermissionSet,
entry_index: usize,
) -> Result<()> {
let has_predefined = grant
.predefined_roles
.as_ref()
.is_some_and(|roles| !roles.is_empty());
if let Some(predefined_roles) = &grant.predefined_roles {
if predefined_roles.is_empty() {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' Azure entry {} has empty predefinedRoles",
permission_set.id, entry_index
),
}));
}
for role in predefined_roles {
if azure_predefined_role_id(role).is_none() {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' Azure entry {} references unknown predefined role '{}'",
permission_set.id, entry_index, role
),
}));
}
}
}
if !has_predefined && !has_residual_azure_permissions(grant) {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' Azure entry {} has no predefined role or residual actions",
permission_set.id, entry_index
),
}));
}
Ok(())
}
fn validate_azure_scope(
&self,
scope: &str,
permission_set: &PermissionSet,
entry_index: usize,
) -> Result<()> {
if scope.contains('*') {
return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
platform: "azure".to_string(),
message: format!(
"permission set '{}' Azure entry {} uses wildcard scope '{}'",
permission_set.id, entry_index, scope
),
}));
}
Ok(())
}
}
impl Default for AzureRuntimePermissionsGenerator {
fn default() -> Self {
Self::new()
}
}
fn has_residual_azure_permissions(grant: &PermissionGrant) -> bool {
grant
.actions
.as_ref()
.is_some_and(|actions| !actions.is_empty())
|| grant
.data_actions
.as_ref()
.is_some_and(|actions| !actions.is_empty())
}
fn role_description(context: &PermissionContext, description: &str) -> String {
let description = description.trim_end_matches('.');
match context.deployment_name.as_deref() {
Some(deployment_name) if !deployment_name.trim().is_empty() => {
let stack_prefix = context.stack_prefix.as_deref().unwrap_or("unknown");
format!("Used by {deployment_name}. {description}. Resource prefix: {stack_prefix}.")
}
_ => description.to_string(),
}
}
pub fn azure_predefined_role_id(role_name: &str) -> Option<&'static str> {
match role_name {
"AcrPull" => Some("7f951dda-4ed3-4680-a7ca-43fe172d538d"),
"AcrPush" => Some("8311e382-0749-4cb8-b61a-304f252e45ec"),
"Azure Service Bus Data Receiver" => Some("4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"),
"Azure Service Bus Data Sender" => Some("69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"),
"Key Vault Contributor" => Some("f25e0fa2-a7c8-4377-a976-54943a77a395"),
"Key Vault Secrets User" => Some("4633458b-17de-408a-b874-0445c86b69e6"),
"Managed Identity Contributor" => Some("e40ec5ca-96e0-45a2-b4ff-59039f2c2b59"),
"Network Contributor" => Some("4d97b98b-1d4f-4787-a291-c67834d212e7"),
"Reader" => Some("acdd72a7-3385-48ef-bd42-f606fba81ae7"),
"Storage Blob Data Contributor" => Some("ba92f5b4-2d11-453d-a403-e96b0029c9fe"),
"Storage Blob Data Reader" => Some("2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"),
"Storage Table Data Contributor" => Some("0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3"),
"Storage Table Data Reader" => Some("76199698-9eea-4c19-bc75-cec21354c6b6"),
_ => None,
}
}