Provisions a Microsoft Entra Privileged Identity Management (PIM) active role assignment — standing (or scheduled) privileged access at a supplied scope — using
hashicorp/azurerm ~> 4.0.
- Creates a single
azurerm_pim_active_role_assignment— the keystonethis. - Grants a principal (user, group, or service principal) an active role at a subscription, resource group, resource, or management-group scope.
- Optionally time-bounds the assignment with a
schedule(a start time plus an expiration by days, hours, or an explicit end time). - Optionally records a
justificationand a changeticket(number + system) for auditability. - Emits the composite resource
idand the platform-resolvedprincipal_type.
💡 Why it matters: An active PIM assignment gives a principal the role now — standing access, or access bounded to a scheduled window — while capturing the justification and change-ticket metadata that a regulated audit trail expects. It is the standing-access counterpart to the eligible (just-in-time) assignment.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 💼 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
scope["scope: subscription / resource group / resource / management group"]
rd["terraform-azurerm-role-definition"]
uai["terraform-azurerm-user-assigned-identity"]
eligible["terraform-azurerm-pim-eligible-role-assignment"]
me["terraform-azurerm-pim-active-role-assignment"]
v["azurerm_pim_active_role_assignment"]
scope -->|"scope"| me
rd -->|"role_definition_id"| me
uai -->|"principal_id"| me
me -->|"creates"| v
me -.->|"just-in-time counterpart"| eligible
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
class me me;
class v target;
class scope,rd,uai,eligible ext;
This module consumes a scope, a role_definition_id, and a principal_id from upstream modules (or built-in role IDs and caller-supplied principals), and it creates a single active assignment. Its just-in-time counterpart, terraform-azurerm-pim-eligible-role-assignment, grants a principal the eligibility to activate a role on demand rather than the standing role itself.
flowchart LR
in_id["scope / role_definition_id / principal_id"]
in_sched["schedule (start + expiration) / justification"]
in_tkt["ticket (number / system)"]
res["azurerm_pim_active_role_assignment.this"]
out["id / principal_type"]
in_id -->|"input"| res
in_sched -->|"input"| res
in_tkt -->|"input"| res
res -->|"output"| out
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
class res me;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_pim_active_role_assignment.this |
single (keystone) | The active PIM role assignment at the supplied scope. |
The optional schedule, ticket, and timeouts blocks are rendered with dynamic blocks so an empty call produces a plain, permanent active assignment.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm", including the mandatory features {} block, plus auth. |
Schema notes that bite
- Every input is force-new.
scope,role_definition_id,principal_id,justification,schedule, andticketall replace the assignment on change — there is no in-place update. - The lifecycle is create / read / delete only. Because nothing updates in place, the
timeoutsobject exposescreate,read, anddeleteonly — noupdate. schedule.expirationis exclusive. Supply at most one ofduration_days,duration_hours, orend_date_time; the module rejects two or more at parse time.- The
idis a Terraform composite, not a raw ARM ID:{scope}|{roleDefinitionId}|{principalId}. - PIM prerequisites. The tenant must be PIM-enabled with Microsoft Entra ID P2 (or an equivalent license); without it the request is rejected by the platform.
Least-privilege at the target scope:
Microsoft.Authorization/roleAssignmentScheduleRequests/writeand/read.- Assigning a role requires User Access Administrator or Owner at (or above) the target scope.
- A PIM-enabled tenant (Microsoft Entra ID P2).
- A Microsoft Entra ID P2 (or equivalent) tenant with PIM enabled.
- The role definition ID (a custom
azurerm_role_definitionoutput or a built-in role ID) and the principal object ID. - The
Microsoft.Authorizationresource provider available at the target scope.
terraform-azurerm-pim-active-role-assignment/
├── providers.tf # terraform{} block: required_version + pinned azurerm (no provider block)
├── variables.tf # deeply-typed inputs: scope/role/principal, schedule, ticket, timeouts
├── main.tf # keystone azurerm_pim_active_role_assignment.this with dynamic blocks
├── outputs.tf # id (composite) first, then principal_type
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
The caller configures provider "azurerm" (including features {}) and authentication; this module never does.
provider "azurerm" {
features {}
}
module "reader_active" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
principal_id = "11111111-1111-1111-1111-111111111111"
}ℹ️ Omitting
schedulecreates a permanent active assignment. Add ascheduleto bound it in time.
Consumes
| Input | Type | Source |
|---|---|---|
scope |
string |
any resource ID / terraform-azurerm-resource-group / terraform-azurerm-management-group |
role_definition_id |
string |
terraform-azurerm-role-definition (or a built-in role ID) |
principal_id |
string |
terraform-azurerm-user-assigned-identity (principal_id) or a caller-supplied principal |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Composite resource ID {scope}|{roleDefinitionId}|{principalId} |
references |
principal_type |
Platform-resolved principal type (User / Group / ServicePrincipal) | auditing |
1 · Permanent active assignment (no schedule)
The empty-schedule call: a standing role that never expires.
module "perm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "11111111-1111-1111-1111-111111111111"
}🔒 A permanent active assignment has no expiry — reserve it for identities that genuinely need standing access.
2 · Time-bounded with duration_days
Grant the role for a fixed number of days from the start time.
module "thirty_days" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" # Contributor
principal_id = "11111111-1111-1111-1111-111111111111"
schedule = {
start_date_time = "2027-01-01T00:00:00Z"
expiration = { duration_days = 30 }
}
}💡
duration_daysis one of three mutually exclusive expiration forms — see example 8.
3 · Time-bounded with duration_hours
A short-lived window measured in hours.
module "eight_hours" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
principal_id = "11111111-1111-1111-1111-111111111111"
schedule = {
start_date_time = "2027-03-15T09:00:00Z"
expiration = { duration_hours = 8 }
}
}4 · Time-bounded with an explicit end_date_time
Pin the assignment to an absolute end instant.
module "until_quarter_end" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "11111111-1111-1111-1111-111111111111"
schedule = {
start_date_time = "2027-01-01T00:00:00Z"
expiration = { end_date_time = "2027-03-31T23:59:59Z" }
}
}5 · Scheduled future start
Provision now, but let the role become active only at a future start_date_time.
module "future_start" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
principal_id = "11111111-1111-1111-1111-111111111111"
schedule = {
start_date_time = "2027-06-01T00:00:00Z"
expiration = { duration_days = 90 }
}
}ℹ️ A
schedulewith astart_date_timebut noexpirationproduces an open-ended assignment starting at that time.
6 · With a justification
Record why the access was granted.
module "justified" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "11111111-1111-1111-1111-111111111111"
justification = "Standing Reader for the platform monitoring service principal."
}7 · With a change ticket
Attach change-management metadata to the request.
module "with_ticket" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
principal_id = "11111111-1111-1111-1111-111111111111"
justification = "Time-boxed Contributor for a planned migration."
ticket = {
number = "CHG0012345"
system = "ServiceNow"
}
schedule = {
start_date_time = "2027-02-01T00:00:00Z"
expiration = { duration_days = 14 }
}
}8 · Expiration exclusivity (validated at parse time)
schedule.expiration accepts at most one of duration_days, duration_hours, or end_date_time. Setting two fails before any API call.
# ❌ Rejected at plan time:
# schedule.expiration must set at most one of: duration_days, duration_hours, end_date_time.
schedule = {
start_date_time = "2027-01-01T00:00:00Z"
expiration = {
duration_days = 30
end_date_time = "2027-03-31T23:59:59Z" # two forms — invalid
}
}
⚠️ Choose exactly one expiration form. The type system catches the mistake atterraform validate, not in Azure.
9 · Resource-group scope
Narrow the grant to a single resource group.
module "rg_scoped" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe" # Storage Blob Data Contributor
principal_id = "11111111-1111-1111-1111-111111111111"
}10 · Management-group scope
Assign broadly across a management group. Prefer a tight expiration at this reach.
module "mg_scoped" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/providers/Microsoft.Management/managementGroups/mg-platform"
role_definition_id = "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "22222222-2222-2222-2222-222222222222"
schedule = {
start_date_time = "2027-01-01T00:00:00Z"
expiration = { duration_days = 7 }
}
}🔒 Management-group scope reaches every child subscription. Bound it with a short expiration and record a ticket.
11 · Custom timeouts (create / read / delete only)
There is no update timeout because the resource never updates in place.
module "with_timeouts" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "11111111-1111-1111-1111-111111111111"
timeouts = {
create = "30m"
read = "5m"
delete = "30m"
}
}12 · for_each over multiple principals
Assign the same role to a keyed map of principals — one module instance each, so removing one never re-indexes the rest.
locals {
operators = {
alice = "11111111-1111-1111-1111-111111111111"
bob = "22222222-2222-2222-2222-222222222222"
carol = "33333333-3333-3333-3333-333333333333"
}
}
module "operators_active" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
for_each = local.operators
scope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-ops"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
principal_id = each.value
justification = "Standing operator access for ${each.key}."
}13 · Active vs. eligible — choosing the sibling
This module grants the role now. Its sibling, terraform-azurerm-pim-eligible-role-assignment, grants only the eligibility to activate the role just-in-time.
# Standing access (this module):
module "active" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7"
principal_id = "11111111-1111-1111-1111-111111111111"
}
# Just-in-time eligibility (the sibling module):
# module "eligible" {
# source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"
# ...
# }💡 Prefer eligible (JIT) for interactive human operators; reserve active for automation identities and scheduled windows.
14 · 🏗️ End-to-end composition
Wire a resource group, a custom role definition, and a user-assigned identity into a scheduled active assignment.
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-platform"
location = "eastus2"
}
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-definition.git?ref=v1.0.0"
name = "Platform Data Operator"
scope = module.rg.id
# ... permissions ...
}
module "identity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
name = "id-platform-ops"
resource_group_name = module.rg.name
location = "eastus2"
}
module "active_assignment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-active-role-assignment.git?ref=v1.0.0"
scope = module.rg.id
role_definition_id = module.role.role_definition_resource_id
principal_id = module.identity.principal_id
justification = "Standing data-operator access for the platform managed identity."
ticket = {
number = "CHG0044556"
system = "ServiceNow"
}
schedule = {
start_date_time = "2027-01-01T00:00:00Z"
expiration = { duration_days = 90 }
}
}ℹ️ The identity's
principal_id, the role's definition ID, and the resource group'sidflow straight into this module — no hand-copied GUIDs.
Required
| Name | Type | Description |
|---|---|---|
scope |
string |
Target scope resource ID (subscription / resource group / resource / management group). Force-new. |
role_definition_id |
string |
Role definition ID being assigned. Force-new. |
principal_id |
string |
Object ID of the principal receiving the role. Force-new. |
Optional
| Name | Type | Default | Description |
|---|---|---|---|
justification |
string |
null |
Justification recorded with the assignment. Force-new. |
schedule |
object |
null |
Activation schedule; null = permanent. Force-new. |
ticket |
object |
null |
Change-ticket metadata. Force-new. |
timeouts |
object |
null |
Per-operation timeouts (create / read / delete only). |
Full nested object schemas
variable "schedule" {
type = object({
start_date_time = optional(string)
expiration = optional(object({
duration_days = optional(number)
duration_hours = optional(number)
end_date_time = optional(string)
}))
})
default = null
# validation: within expiration, at most one of duration_days / duration_hours / end_date_time.
}
variable "ticket" {
type = object({
number = optional(string)
system = optional(string)
})
default = null
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
delete = optional(string)
})
default = null
}
⚠️ Every field above is force-new — changing any of them replaces the assignment.
| Output | Description | Kind |
|---|---|---|
id |
Terraform's own composite ID for the assignment | Passthrough |
scope |
Scope the role was granted at, as recorded | Passthrough |
role_definition_id |
Fully qualified Resource ID of the role that was granted | Passthrough |
principal_id |
Entra object ID of the principal holding the role | Passthrough |
principal_type |
Principal type as Azure resolved it: User, Group, ServicePrincipal and so on | Passthrough |
justification |
Justification recorded with the request, as read back | Passthrough |
ticket_number |
Change-ticket number recorded with the request, or null | Derived |
ticket_system |
Change-ticket system name recorded with the request, or null | Derived |
role_definition_guid |
The role's bare GUID, taken as the last segment of role_definition_id | Derived |
scope_kind |
Which kind of scope the role was granted at, derived from the scope string: managementGroup, subscription, resourceGroup or resource | Derived |
subscription_id |
Subscription GUID parsed out of scope, or null when the grant is at a management group | Derived |
management_group_name |
Management group name parsed out of scope, or null when the grant is not at a management group | Derived |
schedule_start_date_time |
When the grant begins, as recorded | Derived |
schedule_end_date_time |
Absolute end of the grant, as recorded, or null when there is none | Derived |
schedule_duration_days |
Expiry expressed in days, re-parsed from the ISO 8601 duration Azure returns on every refresh | Derived |
schedule_duration_hours |
Expiry expressed in hours, re-parsed from the ISO 8601 duration Azure returns on every refresh | Derived |
is_permanent_active_assignment |
THE headline fact about this resource | Derived |
is_time_bound |
True when the grant carries an expiry in any of its three forms | Derived |
expiration_form |
How the expiry was expressed: duration_days, duration_hours, end_date_time, or none | Derived |
azure_expiration_duration |
The exact ISO 8601 duration string the provider builds and sends, such as P30D or PT8H, or null when the expiry is an absolute end date or the grant is permanent | Derived |
has_schedule |
True when the caller supplied a schedule block at all | Derived |
has_justification |
True when a justification was recorded | Derived |
has_ticket |
True when change-ticket metadata was recorded | Derived |
principal_holds_the_role_without_activating |
Constant true | Constant |
active_assignment_is_the_only_pim_option_for_non_human_principals |
Constant true | Constant |
abac_conditions_require_the_eligible_sibling |
Constant true | Constant |
changing_any_input_revokes_and_regrants_access |
Constant true | Constant |
destroy_sends_a_pim_admin_remove_request |
Constant true | Constant |
removal_is_retried_until_the_minimum_active_duration_elapses |
Constant true | Constant |
recorded_schedule_times_are_frozen_in_state |
Constant true | Constant |
durations_are_re_read_from_azure_on_every_refresh |
Constant true, and the exception to the field above | Constant |
disappearance_of_the_assignment_is_detected_as_a_recreate |
Constant true, and the answer to what happens when a schedule lapses | Constant |
only_direct_member_assignments_are_managed |
Constant true | Constant |
request_history_retention_days |
Constant 45 | Passthrough |
pim_policy_can_reject_a_request_this_module_accepts |
Constant true | Constant |
requires_entra_id_p2_licensing |
Constant true | Constant |
this_module_ships_no_tags_variable |
Constant true | Constant |
- Everything is immutable.
scope,role_definition_id,principal_id,justification,schedule, andticketare all force-new. Editing any of them destroys and recreates the assignment — plan changes to privileged access deliberately. - Active vs. eligible. This module grants the role now; the sibling
terraform-azurerm-pim-eligible-role-assignmentgrants only the eligibility to activate it just-in-time. Choose active for standing or scheduled access, eligible for on-demand human access. - Expiration is exclusive. Within
schedule.expiration, at most one ofduration_days,duration_hours, orend_date_timemay be set — enforced by avalidation {}block, so the error surfaces atterraform plan, offline. - Create / read / delete lifecycle. With no in-place update path, the
timeoutsobject omitsupdateentirely; the resource is created, read, or deleted, never updated. - PIM / Entra ID P2 prerequisite. The assignment only succeeds in a PIM-enabled tenant with Microsoft Entra ID P2 (or equivalent). Without it the platform rejects the schedule request.
features {}dependence. Like every module in this suite, it carries noprovider {}block; the caller suppliesprovider "azurerm" { features {} }and authentication.
| Principle | Behavior |
|---|---|
| Fail fast | schedule.expiration exclusivity is validated at parse time — a malformed schedule never reaches Azure. |
| Minimal lifecycle | timeouts omits update because the resource has no in-place update. |
| No tag surface | azurerm_pim_active_role_assignment does not support tags, so the universal tail is timeouts only. |
| Deeply typed | schedule and ticket are object() schemas, not loose maps — a typo is a type error. |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source at a tag:
?ref=v1.0.0— never a branch. - Everything here is plan-only static analysis. A human applies from CI after review.
terraform init -backend=falseresolves the pinned provider without configuring a backend.terraform validateproves the type contract — including theschedule.expirationexclusivity rule — with no cloud calls.terraform fmt -checkproves canonical formatting.- What only
terraform plan(against a real, PIM-enabled tenant) exercises: the actual scope/role/principal resolution and the platform's acceptance of the schedule request.
$ terraform output
id = "/subscriptions/00000000-0000-0000-0000-000000000000|/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7|11111111-1111-1111-1111-111111111111"
principal_type = "ServicePrincipal"| Symptom | Cause | Fix |
|---|---|---|
| Request fails with a PIM / licensing error | The tenant is not PIM-enabled or lacks Microsoft Entra ID P2. | Enable PIM and assign the required license before applying. |
schedule.expiration must set at most one of… at plan |
Two expiration forms set (e.g. duration_days + end_date_time). |
Keep exactly one of duration_days, duration_hours, end_date_time. |
AuthorizationFailed on apply |
The caller's identity lacks User Access Administrator / Owner at the scope. | Grant the least-privilege role at (or above) the target scope. |
| Assignment recreated on a trivial edit | A force-new field (any input) changed. | Expected — every input is immutable; plan privileged-access changes accordingly. |
- Provider resource:
azurerm_pim_active_role_assignment - Sibling:
terraform-azurerm-pim-eligible-role-assignment(the just-in-time counterpart) - Sibling:
terraform-azurerm-role-definition(custom role definitions) - Sibling:
terraform-azurerm-role-assignments(standard, non-PIM role assignments) - This module's
SCOPE.md— the cross-module contract.
💙 "Infrastructure as Code should be standardized, consistent, and secure."