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 AzureGrantPlan {
42 pub custom_roles: Vec<AzureCustomRole>,
44 pub bindings: Vec<AzureRoleBinding>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub struct AzureCustomRole {
52 pub key: String,
54 pub role_definition: AzureRoleDefinition,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub struct AzureRoleBinding {
62 pub permission_set_id: String,
64 pub role_name: String,
66 pub role_definition: AzureRoleDefinitionRef,
68 pub scope: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "camelCase")]
75pub enum AzureRoleDefinitionRef {
76 Predefined { role_definition_id: String },
78 Custom { key: String },
80}
81
82pub 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
107pub struct AzureRuntimePermissionsGenerator;
109
110impl AzureRuntimePermissionsGenerator {
111 pub fn new() -> Self {
113 Self
114 }
115
116 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 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 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 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 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 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 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, 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 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 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 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}