diff --git a/README.md b/README.md index a27edd0..ce6d7f7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The constrained RBAC Administrator assignment is created only when `delegable_ro module "iam" { source = "../modules/simple-iam" - scope = data.azurerm_subscription.current.id + scopes = [data.azurerm_subscription.current.id] principal_id = azuread_service_principal.sp.object_id roles = [ @@ -32,14 +32,15 @@ module "iam" { ## Inputs -- `scope` (string): Scope ID at which to assign roles. +- `scopes` (list(string)): Scope IDs at which to assign roles. - `principal_id` (string): Object ID of the principal. -- `roles` (list(string)): Unconditional role definition names to assign. +- `roles` (list(string)): Unconditional role definition names to assign at each scope in `scopes`. - `delegable_roles` (list(string)): Role definition names allowed by the constrained RBAC Admin condition. When empty, RBAC Admin is not assigned. - `principal_type` (string): Passed to `azurerm_role_assignment.principal_type`. +- `delegable_roles_to_sp_only` (bool): When true, RBAC Admin delegation can only assign/delete roles to principals of type ServicePrincipal. ## Outputs - `role_assignment_ids` (map(string)) -- `rbac_admin_role_assignment_id` (string|null) +- `rbac_admin_role_assignment_id` (map(string)) - `rbac_admin_condition` (string|null) diff --git a/main.tf b/main.tf index 8c935f2..faa363f 100644 --- a/main.tf +++ b/main.tf @@ -1,9 +1,45 @@ locals { + lookup_scope = var.scopes[0] + allowed_role_definition_ids_list = join(", ", [ for name in var.delegable_roles : basename(data.azurerm_role_definition.allowed_for_rbac_admin_condition[name].id) ]) + role_assignments = { + for entry in flatten([ + for scope in var.scopes : [ + for role in var.roles : { + key = "${scope}:${role}" + scope = scope + role = role + } + ] + ]) : + entry.key => { + scope = entry.scope + role = entry.role + } + } + + rbac_admin_write_constraint_role_definition_ids = "@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.allowed_role_definition_ids_list}}" + rbac_admin_delete_constraint_role_definition_ids = "@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.allowed_role_definition_ids_list}}" + + rbac_admin_write_constraint_principal_type = "@Request[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEquals {'ServicePrincipal'}" + rbac_admin_delete_constraint_principal_type = "@Resource[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEquals {'ServicePrincipal'}" + + rbac_admin_write_constraint = ( + var.delegable_roles_to_sp_only ? + "(${local.rbac_admin_write_constraint_role_definition_ids} AND ${local.rbac_admin_write_constraint_principal_type})" : + "(${local.rbac_admin_write_constraint_role_definition_ids})" + ) + + rbac_admin_delete_constraint = ( + var.delegable_roles_to_sp_only ? + "(${local.rbac_admin_delete_constraint_role_definition_ids} AND ${local.rbac_admin_delete_constraint_principal_type})" : + "(${local.rbac_admin_delete_constraint_role_definition_ids})" + ) + rbac_admin_condition = <<-EOT ( ( @@ -11,7 +47,7 @@ locals { ) OR ( - @Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.allowed_role_definition_ids_list}} + ${local.rbac_admin_write_constraint} ) ) AND @@ -21,17 +57,17 @@ locals { ) OR ( - @Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.allowed_role_definition_ids_list}} + ${local.rbac_admin_delete_constraint} ) ) EOT } data "azurerm_role_definition" "rbac_admin" { - count = length(var.delegable_roles) > 0 ? 1 : 0 + for_each = length(var.delegable_roles) > 0 ? { this = true } : {} name = "Role Based Access Control Administrator" - scope = var.scope + scope = local.lookup_scope } data "azurerm_role_definition" "allowed_for_rbac_admin_condition" { @@ -39,15 +75,15 @@ data "azurerm_role_definition" "allowed_for_rbac_admin_condition" { for_each = toset(var.delegable_roles) name = each.value - scope = var.scope + scope = local.lookup_scope } resource "azurerm_role_assignment" "role" { - for_each = toset(var.roles) + for_each = local.role_assignments - scope = var.scope - role_definition_name = each.value + scope = each.value.scope + role_definition_name = each.value.role principal_id = var.principal_id principal_type = var.principal_type skip_service_principal_aad_check = true @@ -55,10 +91,10 @@ resource "azurerm_role_assignment" "role" { resource "azurerm_role_assignment" "rbac_admin" { - count = length(var.delegable_roles) > 0 ? 1 : 0 + for_each = length(var.delegable_roles) > 0 ? toset(var.scopes) : toset([]) - scope = var.scope - role_definition_id = data.azurerm_role_definition.rbac_admin[0].id # Role Based Access Control Administrator + scope = each.value + role_definition_id = data.azurerm_role_definition.rbac_admin["this"].id # Role Based Access Control Administrator principal_id = var.principal_id principal_type = var.principal_type skip_service_principal_aad_check = true diff --git a/outputs.tf b/outputs.tf index 1bce009..68178b6 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,14 +1,14 @@ output "role_assignment_ids" { - value = { for role, ra in azurerm_role_assignment.role : role => ra.id } - description = "IDs of unconditional role assignments, keyed by role definition name." + value = { for key, ra in azurerm_role_assignment.role : key => ra.id } + description = "IDs of unconditional role assignments, keyed by '${scope}:${role_definition_name}'." } output "rbac_admin_role_assignment_id" { - value = length(azurerm_role_assignment.rbac_admin) > 0 ? azurerm_role_assignment.rbac_admin[0].id : null - description = "ID of the constrained RBAC Administrator role assignment, or null when delegable_roles is empty." + value = { for scope, ra in azurerm_role_assignment.rbac_admin : scope => ra.id } + description = "IDs of constrained RBAC Administrator role assignments, keyed by scope. Empty when delegable_roles is empty." } output "rbac_admin_condition" { - value = length(azurerm_role_assignment.rbac_admin) > 0 ? local.rbac_admin_condition : null - description = "Rendered condition used for the constrained RBAC Administrator assignment, or null when not created." + value = length(var.delegable_roles) > 0 ? local.rbac_admin_condition : null + description = "Rendered condition used for constrained RBAC Administrator assignments, or null when delegable_roles is empty." } diff --git a/variables.tf b/variables.tf index 3f46c49..bcd0810 100644 --- a/variables.tf +++ b/variables.tf @@ -1,6 +1,15 @@ -variable "scope" { - type = string - description = "Scope ID at which to assign roles (subscription, resource group, resource, etc.)." +variable "scopes" { + type = list(string) + description = "Scope IDs at which to assign roles (subscription, resource group, resource, etc.)." + + validation { + condition = ( + length(var.scopes) > 0 && + alltrue([for scope in var.scopes : scope != null && trimspace(scope) != ""]) && + length(distinct(var.scopes)) == length(var.scopes) + ) + error_message = "scopes must be a non-empty list of unique, non-empty strings." + } } variable "principal_id" { @@ -11,7 +20,7 @@ variable "principal_id" { variable "roles" { type = list(string) default = [] - description = "Unconditional role definition names to assign to principal_id at scope." + description = "Unconditional role definition names to assign to principal_id at each scope in scopes." validation { condition = length(distinct(var.roles)) == length(var.roles) @@ -35,3 +44,9 @@ variable "principal_type" { default = "ServicePrincipal" description = "Value for azurerm_role_assignment.principal_type (e.g., ServicePrincipal, User, Group)." } + +variable "delegable_roles_to_sp_only" { + type = bool + default = false + description = "When true, the RBAC Admin conditional delegation allows roleAssignments write/delete only when the target principal type is ServicePrincipal." +}