Skip to content

About

πŸ” Azure RBAC and PIM role assignments from one map, with a secure-by-default guard against re-delegating privileged roles

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Β 
Β 

Repository files navigation

Terraform Azure Role Assignment

Azure RBAC and PIM assignments from one map, with a secure-by-default guard against re-delegating the privileged roles.

CI Release Terraform Registry License


Overview

Role assignments keyed by a logical label you choose, plus custom Azure RBAC role definitions keyed by role name. Each assignment entry is expanded over the cartesian product of its principal_ids and its role_names + role_ids + role_definition_keys, so one entry can grant several roles to several principals at one scope. The module does four things a bare azurerm_role_assignment cannot:

  • Secure-by-default delegation guard. When an assignment grants a privileged role (Owner, User Access Administrator, or Role Based Access Control Administrator), the module attaches a Microsoft ABAC condition that denies the assignee the ability to create or delete role assignments for those same privileged roles. So a granted Owner cannot hand Owner to anyone else. It is on by default for privileged roles, opt out per assignment with constrain_delegation = false, and the privileged set is caller-overridable via constrained_delegation_role_ids.
  • PIM in the same interface. Set assignment_type to pim_active (active, PIM managed, typically time-bound) or pim_eligible (the principal activates the role just-in-time) and the module creates the matching PIM resource. role_names are resolved to the role definition id PIM requires (via a data source), so you keep naming roles rather than pasting GUIDs. The guard also covers privileged pim_eligible grants.
  • Stable, plan-known keys. Instance keys are index based (label|rN|pN), so assigning a role to a freshly created identity (a computed principal_id) never trips the "for_each argument must be known" error, and reordering inputs never churns unrelated assignments.
  • Define-then-assign custom roles. role_definitions creates custom role definitions (actions/not_actions/data_actions, assignable_scopes defaulting to the definition's own scope) at a management group, subscription, or resource group, and assignments (permanent or PIM) reference them by key through role_definition_keys in the same call. Creating a definition needs roleDefinitions/write on the scope (Owner; User Access Administrator is not enough), and a check block flags wildcard-* custom roles (Owner-equivalents in disguise).

Assignments carry no tags and are global to their scope, so there is no resource_group_id, location, or tags input. Pairs naturally with the user-assigned-identity, keyvault, and storage-account modules to grant a workload identity exactly the access it needs.

Usage

module "role_assignment" {
  source  = "libre-devops/role-assignment/azurerm"
  version = "~> 4.0"

  role_assignments = {
    # Reader for a workload identity on a resource group.
    app_reader = {
      scope         = module.rg.ids["rg-ldo-uks-prd-001"]
      principal_ids = [module.identity.principal_ids["id-ldo-uks-prd-001"]]
      role_names    = ["Reader"]
    }

    # Owner for the platform group: the delegation guard is applied automatically.
    platform_owners = {
      scope         = module.rg.ids["rg-ldo-uks-prd-001"]
      principal_ids = [var.platform_group_object_id]
      role_names    = ["Owner"]
    }

    # Just-in-time Contributor via PIM eligibility, activated for up to 8 hours.
    break_glass = {
      scope           = module.rg.ids["rg-ldo-uks-prd-001"]
      principal_ids   = [var.oncall_group_object_id]
      role_names      = ["Contributor"]
      assignment_type = "pim_eligible"
      justification   = "On-call break-glass access"
      schedule        = { expiration = { duration_hours = 8 } }
    }
  }
}

Examples

  • examples/minimal - grant the running principal Reader on a resource group (required inputs only).
  • examples/complete - the full permanent surface: cartesian expansion over two principals (one with a computed id), the guard on and opted out, principal_type, and a role given by definition id. PIM active and eligible are exercised in tests with a mocked provider, since PIM needs Entra ID P2 that a test tenant may not carry.

Developing

Local work needs PowerShell 7+ and just, because the recipes wrap the LibreDevOpsHelpers PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install just with brew install just, or uv tool add rust-just then uv run just <recipe>.

Run just to list recipes: just update-ldo-pwsh (install or force-update LibreDevOpsHelpers from PSGallery), just validate, just scan (Trivy only), just pwsh-analyze (PSScriptAnalyzer only), just plan, just apply, just destroy, just e2e, just test, and just docs (the plan/apply/destroy recipes mirror the action, including the storage firewall dance; just e2e applies an example then always destroys it, defaulting to minimal, so nothing is left running). Releasing is also just: just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the Terraform Registry picks up the tag.

Security scan exceptions

This module is scanned with Trivy; HIGH and CRITICAL findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a finding that should be fixed. Waivers live in .trivyignore.yaml (the machine-applied source of truth, passed to Trivy with --ignorefile) and are mirrored in a table here so the reason is auditable.

There are currently no exceptions: the module and its examples scan clean. A role assignment grants access rather than deploying infrastructure, and the module's whole purpose is to make that grant safer, so there is nothing to waive.

To add an exception: add an entry to .trivyignore.yaml (id, optional paths to scope it, and a statement recording why), then add a matching row here recording the reason. Both the file and the table are reviewed in the pull request.

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
azurerm >= 4.0.0, < 5.0.0

Providers

Name Version
azurerm >= 4.0.0, < 5.0.0

Modules

No modules.

Resources

Name Type
azurerm_pim_active_role_assignment.this resource
azurerm_pim_eligible_role_assignment.this resource
azurerm_role_assignment.this resource
azurerm_role_definition.this resource
azurerm_role_definition.pim data source
azurerm_subscription.current data source

Inputs

Name Description Type Default Required
constrained_delegation_role_ids The privileged role definition ids (name => GUID) whose re-delegation is denied by the ABAC condition
this module applies to privileged assignments. Defaults to the three roles that can grant Azure RBAC
(Owner, User Access Administrator, Role Based Access Control Administrator). Extend this to treat more
roles as privileged. The guard denies the assignee the ability to create or delete role assignments for
any of these roles, so a granted Owner cannot hand Owner (or the other listed roles) to anyone else.
map(string)
{
"Owner": "8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
"Role Based Access Control Administrator": "f58310d9-a9f6-439a-9e8d-f62e7b41a168",
"User Access Administrator": "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9"
}
no
pim_role_definition_lookup_scope The scope at which PIM role_names are resolved to role definition ids (built-in roles resolve at any
scope). Defaults to the current subscription. Set this when a PIM assignment uses a custom role defined
at a different scope, or pass the full role definition id via role_ids instead.
string null no
role_assignments The role assignments to create, keyed by a logical label you choose (the label is only for readable
plan output and stable addressing, it is not the Azure resource name). Each entry is expanded over the
cartesian product of principal_ids and the combined set of role_names + role_ids, so one entry
can assign several roles to several principals at one scope.

assignment_type selects how the role is granted:

- permanent (default): a standard azurerm_role_assignment (a persistent, always-active grant).
- pim_active: a PIM active assignment (active now, but managed by PIM and typically time-bound).
- pim_eligible: a PIM eligible assignment (the principal can activate the role just-in-time).

role_names are resolved to a role definition id automatically for the PIM types (which require the
id, not the name); role_ids (full role definition resource ids) are used as-is; and
role_definition_keys reference custom roles created in this call via role_definitions, so
define-then-assign works in one module call. The privileged delegation guard (see
constrained_delegation_role_ids) only applies to permanent and pim_eligible assignments, because
pim_active has no condition argument.
map(object({
scope = string
principal_ids = optional(list(string), [])
role_names = optional(list(string), [])
role_ids = optional(list(string), [])
role_definition_keys = optional(list(string), [])

assignment_type = optional(string, "permanent")
principal_type = optional(string)
description = optional(string)

# permanent (azurerm_role_assignment) only
name = optional(string)
condition = optional(string)
condition_version = optional(string)
delegated_managed_identity_resource_id = optional(string)
skip_service_principal_aad_check = optional(bool)

# null: apply the delegation guard automatically when the role is privileged (secure default).
# true: always apply it. false: never apply it. Ignored for pim_active (no condition support).
constrain_delegation = optional(bool)

# PIM (pim_active / pim_eligible) only
justification = optional(string)
ticket = optional(object({
number = optional(string)
system = optional(string)
}))
schedule = optional(object({
start_date_time = optional(string)
expiration = optional(object({
duration_days = optional(number)
duration_hours = optional(number)
end_date_time = optional(string)
}))
}))
}))
{} no
role_definitions Custom Azure RBAC role definitions to create, keyed by role name (custom role names are unique per
tenant, so make them distinctive). scope is where the definition is stored (a management group,
subscription, or resource group id); assignable_scopes defaults to just that scope when empty.
permissions holds the control-plane actions/not_actions and data-plane
data_actions/not_data_actions. Assignments in the same call reference a definition through
role_definition_keys. Creating a definition needs Microsoft.Authorization/roleDefinitions/write
on the scope (Owner; User Access Administrator is NOT enough). A freshly created definition can take
a short while to become assignable (Azure RBAC replication), so a first apply may need a retry when
the definition and a same-call assignment race.
map(object({
scope = string
description = optional(string)
assignable_scopes = optional(list(string), [])
role_definition_id = optional(string)
permissions = object({
actions = optional(list(string), [])
not_actions = optional(list(string), [])
data_actions = optional(list(string), [])
not_data_actions = optional(list(string), [])
})
}))
{} no

Outputs

Name Description
conditions_applied The permanent assignments that carry an ABAC condition, with their principal, role, scope, and condition version.
guarded_assignments The keys of assignments that received the privileged-delegation deny condition.
pim_active_role_assignments The PIM active assignments, keyed by "label|rN|pN". Full resource objects.
pim_eligible_role_assignments The PIM eligible assignments, keyed by "label|rN|pN". Full resource objects.
principal_ids The distinct principal ids that received an assignment.
role_assignment_ids All assignment ids across the three types, keyed by "label|rN|pN".
role_assignment_ids_zipmap key => { name, id } across all assignment types, for easy composition with other modules.
role_assignments The permanent (azurerm_role_assignment) assignments, keyed by "label|rN|pN". Full resource objects (all attributes).
role_definition_guids Map of custom role name to the role definition GUID (the name portion of the id).
role_definition_ids Map of custom role name to the full role definition resource id (feed this to role_ids in other calls or modules).
role_definition_ids_zipmap Map of custom role name to an object of its name and full resource id, for easy composition.
role_definitions Map of custom role name to its useful attributes.
scopes The distinct scopes assignments were created at.

About

πŸ” Azure RBAC and PIM role assignments from one map, with a secure-by-default guard against re-delegating privileged roles

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages