Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Pim Eligible Role Assignment Terraform Module

Grants a principal eligible (activate-on-demand) rights to a role at a scope — a just-in-time, least-privilege alternative to a standing active assignment. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Version Type Resources


🧩 Overview

This module manages exactly one thing, well:

  • 🎯 A PIM eligible role assignment — a principal becomes eligible to activate a role at a scope, rather than holding it as a standing, always-active grant.
  • 🧑‍🤝‍🧑 Works with any principal — a user, an Entra group (preferred), a service principal, or a managed identity — referenced by object ID.
  • 🧱 Works at any scope — management group, subscription, resource group, or an individual resource.
  • ⏳ Supports a bounded eligibility window — a start time and an expiration expressed in days, hours, or an explicit end date.
  • 🧾 Records governance metadata — a business justification, an optional ABAC condition, and an optional change-management ticket, so activation history and access reviews are meaningful.

💡 Why it matters: Standing (active) role assignments are permanent attack surface — the rights exist whether or not anyone is using them. An eligible assignment inverts that: the principal holds nothing by default and must activate the role just-in-time, leaving an audit trail each time. Time-boxing the eligibility and attaching a justification turns privileged access into a reviewed, expiring, accountable event instead of a forgotten grant.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

This module pairs with the identity family: it consumes a principal, a role definition, and a scope, and it is the just-in-time counterpart to the standing role-assignments module.

flowchart LR
  rg["terraform-azurerm-resource-group"]
  uai["terraform-azurerm-user-assigned-identity: NOT eligible-capable"]
  rd["terraform-azurerm-role-definition"]
  ra["terraform-azurerm-role-assignments"]
  active["terraform-azurerm-pim-active-role-assignment"]
  this["terraform-azurerm-pim-eligible-role-assignment"]
  key["azurerm_pim_eligible_role_assignment.this"]

  uai -->|"principal_id -- ACTIVE assignments only"| active
  rd -->|"role_definition_id"| this
  rg -->|"scope"| this
  this -->|"manages"| key
  this -.->|"standing active counterpart"| active
  ra -.->|"non-PIM permanent alternative"| this

  classDef me fill:#0078D4,color:#fff,stroke:#004578;
  classDef keystone fill:#004578,color:#fff,stroke:#002B4D;
  classDef sib fill:#EEF2F6,color:#1A1A1A,stroke:#B8C2CC;
  class this me;
  class key keystone;
  class rg,uai,rd,ra,active sib;
Loading

🧬 What this module builds

One keystone resource, fed by three identifiers, producing eligibility that is activated on demand rather than held continuously.

flowchart LR
  principal["Principal: user / group / service principal"]
  scope["Target scope: MG / subscription / RG / resource"]
  role["Role definition"]
  this["azurerm_pim_eligible_role_assignment.this"]
  jit["Just-in-time activation: bounded, on demand"]

  principal -->|"principal_id"| this
  role -->|"role_definition_id"| this
  scope -->|"scope"| this
  this -->|"eligible, not standing active"| jit

  classDef me fill:#0078D4,color:#fff,stroke:#004578;
  classDef keystone fill:#004578,color:#fff,stroke:#002B4D;
  classDef sib fill:#EEF2F6,color:#1A1A1A,stroke:#B8C2CC;
  class this keystone;
  class principal,scope,role,jit sib;
Loading

Resource inventory

Resource Count Role
azurerm_pim_eligible_role_assignment.this 1 The eligible role assignment (keystone).
↳ ticket block 0–1 Change-management ticket recorded with the request.
↳ schedule block (+ nested expiration) 0–1 The eligibility window (start and bounded end).
↳ timeouts block 0–1 Create / read / delete operation timeouts.

✅ Provider / Versions

Requirement Value
Terraform floor >= 1.12.0
Provider hashicorp/azurerm, pinned ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, authentication, and subscription.

Schema notes that bite (verified against the live provider schema):

  • 🔁 scope, role_definition_id, and principal_id are force-new — changing any one replaces the eligible assignment.
  • 🚫 This resource type supports neither tags nor location — those variables are intentionally absent.
  • ⏱️ The timeouts block supports create / read / delete only — there is no update timeout.
  • 🧮 principal_type is computed by the platform, not an input.
  • ♾️ Omitting schedule (or its expiration) yields permanent eligibility — supply a bounded expiration to time-box it.
  • ⚖️ schedule.expiration accepts at most one of duration_days, duration_hours, or end_date_time; the module rejects more than one at parse time.
  • 🔗 condition_version is required by the provider only when condition is set.

🔑 Required Azure RBAC Roles / Permissions

  • User Access Administrator or Owner at the target scope — or a custom role carrying Microsoft.Authorization/roleEligibilityScheduleRequests/write plus the corresponding read actions.
  • Grant the caller's identity this permission at the smallest scope that satisfies the assignment (a resource group or resource rather than the whole subscription wherever possible).

Azure Prerequisites

  • Microsoft Entra ID P2 (or a Microsoft Entra ID Governance license) on the tenant — PIM eligible assignments require it.
  • The target scope, the principal_id, and the role_definition_id already exist and are resolvable by the caller's identity.
  • The caller configures the provider "azurerm" { features {} } block, authentication, and subscription — the module declares none of these.

📁 Module Structure

terraform-azurerm-pim-eligible-role-assignment/
├── providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf     # deeply-typed object() schemas; ticket + schedule/expiration; timeouts tail
├── main.tf          # keystone azurerm_pim_eligible_role_assignment.this; dynamic blocks + try()
├── outputs.tf       # id first, then principal_type and the resolved identifiers
├── README.md        # this document
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT, Copyright (c) 2026 Casey Wood
└── .gitignore       # the canonical library ignore set

⚙️ Quick Start

The smallest real call — make a principal eligible for a role at a scope. The caller configures the provider, authentication, and the mandatory features {} block.

provider "azurerm" {
  features {}
}

module "eligible_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform"
  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"

  justification = "Standing read access replaced with just-in-time eligibility."
}

🔒 Pin ?ref=v1.0.0 — never a branch. This library is plan-only during authoring; a human applies from CI.

🔌 Cross-Module Contract

Consumes

Input Type Typical source
scope string terraform-azurerm-resource-group (id), a subscription / management-group ID, or any resource id
role_definition_id string terraform-azurerm-role-definition or a built-in role ID
principal_id string A user or group object ID from Entra ID. Not a managed identity or service principal — those cannot hold an eligible assignment; use terraform-azurerm-pim-active-role-assignment for them.

Emits

Output Description
id The eligible role assignment Resource ID (emitted first).
principal_type Platform-resolved principal type (User / Group / ServicePrincipal).
scope The scope at which eligibility is granted.
role_definition_id The role the principal is eligible to activate.
principal_id The principal made eligible.
justification The recorded justification (provider-computed when not supplied).

📚 Example Library

1 · Minimal call — eligible Reader at a resource group
module "eligible_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = "11111111-1111-1111-1111-111111111111"
}

ℹ️ With no schedule, eligibility is permanent. Fine for a read-only role, but prefer a bounded window for anything that can change state.

2 · Contributor eligibility bounded by days
module "eligible_contributor" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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" # Contributor
  principal_id       = "22222222-2222-2222-2222-222222222222"

  justification = "Quarter-bounded contributor eligibility for the platform team."

  schedule = {
    expiration = {
      duration_days = 90
    }
  }
}

💡 A bounded expiration forces the grant to be re-affirmed on renewal — eligibility is reviewed, not forgotten.

3 · Short-lived eligibility bounded by hours
module "eligible_break_glass" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-prod"
  role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" # Contributor
  principal_id       = "33333333-3333-3333-3333-333333333333"

  justification = "Time-boxed incident-response eligibility."

  schedule = {
    expiration = {
      duration_hours = 8
    }
  }
}

🔒 Hour-scoped eligibility suits break-glass and on-call windows: the eligibility itself lapses quickly, not just the activation.

4 · Eligibility bounded by an explicit end date
module "eligible_until" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-migration"
  role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" # Contributor
  principal_id       = "44444444-4444-4444-4444-444444444444"

  schedule = {
    expiration = {
      end_date_time = "2026-12-31T23:59:59Z"
    }
  }
}

⚠️ Supply only one of duration_days, duration_hours, or end_date_time — the module rejects more than one at parse time.

5 · Scheduled future start
module "eligible_onboarding" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = "55555555-5555-5555-5555-555555555555"

  schedule = {
    start_date_time = "2026-08-01T00:00:00Z"
    expiration = {
      duration_days = 180
    }
  }
}

ℹ️ A future start_date_time pre-provisions eligibility for a known onboarding date without granting it early.

6 · Permanent eligibility (documented exception)
module "eligible_permanent" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-shared"
  role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = "66666666-6666-6666-6666-666666666666"

  justification = "Permanent read eligibility for the audit team; reviewed annually."
}

⚠️ Omitting schedule yields permanent eligibility. Prefer a bounded expiration; when you must go permanent, record why in justification so access reviews have context.

7 · Ticketed justification
module "eligible_ticketed" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-prod"
  role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" # Contributor
  principal_id       = "77777777-7777-7777-7777-777777777777"

  justification = "Change-controlled contributor eligibility."

  ticket = {
    number = "CHG0012345"
    system = "ServiceNow"
  }

  schedule = {
    expiration = {
      duration_days = 30
    }
  }
}

🔒 Tying the grant to an approved change ticket strengthens the audit trail and satisfies change-management controls.

8 · ABAC-conditioned eligibility
module "eligible_conditioned" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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       = "88888888-8888-8888-8888-888888888888"

  condition         = "((!(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write'})) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringEquals 'landing'))"
  condition_version = "2.0"

  schedule = {
    expiration = {
      duration_days = 60
    }
  }
}

💡 An ABAC condition narrows what the activated role can touch — an additional least-privilege lever. condition_version is required whenever condition is set.

9 · Group principal (preferred over individual users)
module "eligible_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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" # Contributor
  principal_id       = "99999999-9999-9999-9999-999999999999" # Entra group object ID

  justification = "Team-level eligibility; membership governs who can activate."

  schedule = {
    expiration = {
      duration_days = 90
    }
  }
}

💡 Assign eligibility to a group and manage membership separately — activation history and reviews stay auditable, and joiners/leavers need no Terraform change.

10 · Management-group scope
module "eligible_mg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/providers/Microsoft.Management/managementGroups/mg-landing-zones"
  role_definition_id = "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

  justification = "Cross-subscription read eligibility at the management-group tier."
}

⚠️ Management-group eligibility is broad by nature. Keep the role narrow (Reader here) and time-box it.

11 · Subscription scope
module "eligible_subscription" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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       = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"

  schedule = {
    expiration = {
      duration_days = 30
    }
  }
}

🔒 Prefer the smallest scope that works — reach for a subscription-wide role only when a resource-group scope genuinely cannot serve.

12 · Custom role definition
module "eligible_custom_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-ops"
  role_definition_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/cccccccc-cccc-cccc-cccc-cccccccccccc" # custom role
  principal_id       = "dddddddd-dddd-dddd-dddd-dddddddddddd"

  justification = "Eligibility for a purpose-built least-privilege operations role."

  schedule = {
    expiration = {
      duration_days = 90
    }
  }
}

💡 A purpose-built custom role plus eligibility is the tightest combination — narrow permissions, held only on demand.

13 · Many eligible assignments with for_each
locals {
  sub = "/subscriptions/00000000-0000-0000-0000-000000000000"

  eligibilities = {
    reader_app = {
      scope        = "${local.sub}/resourceGroups/rg-app"
      role         = "acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
      principal_id = "11111111-1111-1111-1111-111111111111"
    }
    contributor_ops = {
      scope        = "${local.sub}/resourceGroups/rg-ops"
      role         = "b24988ac-6180-42a0-ab88-20f7382dd24c" # Contributor
      principal_id = "22222222-2222-2222-2222-222222222222"
    }
  }
}

module "eligible" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"
  for_each = local.eligibilities

  scope              = each.value.scope
  role_definition_id = "${local.sub}/providers/Microsoft.Authorization/roleDefinitions/${each.value.role}"
  principal_id       = each.value.principal_id

  schedule = {
    expiration = {
      duration_days = 90
    }
  }
}

ℹ️ A stable map key per assignment keeps state addressing stable — adding or removing one entry never re-indexes the rest.

14 · Custom operation timeouts
module "eligible_timeouts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-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/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"

  timeouts = {
    create = "30m"
    read   = "5m"
    delete = "30m"
  }
}

⚠️ This resource has no update timeout — only create, read, and delete are accepted.

15 · 🏗️ End-to-end composition

Create a resource group and a user-assigned identity, then make that identity eligible for the built-in Reader role on the group — a complete, wired example.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-platform"
  location = "eastus"
}

# A managed identity CANNOT hold an eligible assignment -- see the callout below. The principal
# here is a human, supplied as an object ID because this library is azurerm-only and does not
# manage Entra users.
variable "platform_oncall_group_object_id" {
  description = "Object ID of the Entra GROUP whose members carry the on-call rotation."
  type        = string
}

data "azurerm_subscription" "current" {}

module "eligible_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-pim-eligible-role-assignment.git?ref=v1.0.0"

  scope              = module.rg.id
  role_definition_id = "${data.azurerm_subscription.current.id}/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" # Reader
  principal_id       = var.platform_oncall_group_object_id

  justification = "Just-in-time read eligibility for the platform on-call rotation."

  ticket = {
    number = "CHG0042000"
    system = "ServiceNow"
  }

  schedule = {
    expiration = {
      duration_days = 90
    }
  }
}

output "eligible_assignment_id" {
  value = module.eligible_reader.id
}

💡 The role is referenced by the built-in role's stable GUID under the subscription — no standing active assignment is created anywhere in this composition.

📥 Inputs

Primary identity (all required, all force-new)

Name Type Description
scope string The scope at which the principal becomes eligible (MG / subscription / RG / resource ID).
role_definition_id string The role the principal may activate (built-in or custom role ID).
principal_id string The Entra object ID of the principal. A user, or a group whose members are users. Azure accepts a service-principal or managed-identity object ID here and creates the eligibility, but it can never be activated.

Governance metadata (optional)

Name Type Default Description
justification string null Business justification recorded with the assignment.
condition string null Optional ABAC condition constraining the activated role.
condition_version string null ABAC condition-language version; required when condition is set.
ticket object null Change-management ticket (number, system).
schedule object null Eligibility window (start_date_time, expiration). Omit for permanent eligibility.
timeouts object null Create / read / delete timeouts (no update timeout for this resource).
Full object() schemas
variable "scope" {
  type = string # force-new
}

variable "role_definition_id" {
  type = string # force-new
}

variable "principal_id" {
  type = string # force-new
}

variable "justification" {
  type    = string
  default = null
}

variable "condition" {
  type    = string
  default = null
}

variable "condition_version" {
  type    = string
  default = null
}

variable "ticket" {
  type = object({
    number = optional(string)
    system = optional(string)
  })
  default = null
}

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: schedule.expiration accepts at most one of
  # duration_days, duration_hours, end_date_time.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
}

ℹ️ There is no tags variable and no location variable — this resource type supports neither.

🧾 Outputs

Output Description Notes
id The eligible role assignment Resource ID. Emitted first.
principal_type Platform-resolved principal type (User / Group / ServicePrincipal). Computed.
scope The scope at which eligibility is granted.
role_definition_id The role the principal is eligible to activate.
principal_id The principal made eligible.
justification The recorded justification. Provider-computed when not supplied.
scope_kind managementGroup, subscription, resourceGroup or resource, derived from scope. Derived. Blast-radius signal.
grants_no_standing_access Constant true. The principal holds nothing until it activates. Constant.
eligibility_is_permanent Whether the eligibility itself carries no expiry. Derived.
activation_controls_live_elsewhere Constant true. MFA, approval, justification and duration are all on the role management policy. Constant.
requires_per_user_pim_license Constant true. Entra ID P2 / Governance, counted per user. Constant.
cannot_be_used_for_a_service_principal_or_managed_identity Constant true. ⚠️ A service principal's object ID is a GUID too, so this cannot be validated. Constant.
eligibility_at_resource_scope_is_narrower_than_it_looks Constant true. The provider accepts resource scope; the portal's assignment path does not. Constant.

🧠 Architecture Notes

  • Eligible, not active. The whole point of this resource is that the principal holds nothing until it activates the role just-in-time. It is the least-privilege counterpart to a standing active assignment.
  • Three force-new keys. scope, role_definition_id, and principal_id are immutable — Terraform replaces the assignment if any changes. Treat these as the resource's identity.
  • No tags, no location. Neither is part of this resource's schema, so neither is a module variable. This is one of the rare azurerm resources without the near-universal tags field.
  • timeouts is create/read/delete only. The resource has no update lifecycle, so there is no update timeout; the variable mirrors the schema exactly.
  • Permanent by omission. Leave schedule unset and eligibility is permanent. The module keeps this possible but steers you toward a bounded expiration; the single-mechanism rule on expiration is enforced at parse time so a conflicting window fails before any API call.
  • Computed fields render cleanly. justification, start_date_time, and the expiration fields are optional-and-computed; when omitted, the dynamic blocks and try(...) guards render them as absent and the platform computes the effective value.
  • features {} dependence. The provider will not initialize without a caller-side provider "azurerm" { features {} } block. That belongs to the root module — this module never declares a provider block.

🧱 Design Principles

The empty call already produces the least-privilege posture; every relaxation is something the caller must type.

Concern Secure default (empty call) Opt-out (caller must type it)
Standing vs. just-in-time access Eligible assignment (activate on demand) use terraform-azurerm-role-assignments for a standing active grant
Eligibility window bounded expiration recommended and enforced single-mechanism omit schedule for permanent eligibility
Accountability justification and ticket available for the audit trail omit them
Blast radius narrow scope + narrow role encouraged broaden the scope or role
Fine-grained limits ABAC condition available omit the condition
Secret handling none accepted or emitted —

🚀 Runbook

# From the module folder — offline, no backend, no cloud calls:
terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin ?ref=v1.0.0 in the source — never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.

🧪 Testing

The offline proof gate is what this module guarantees:

  • terraform init -backend=false resolves the pinned azurerm ~> 4.0 provider with no backend.
  • terraform validate proves the configuration is type-correct against the provider schema — the deeply-typed object() inputs surface a malformed schedule or ticket as a parse-time error, and the expiration single-mechanism rule is checked here.
  • terraform fmt -check enforces canonical formatting.

What the gate does not do: it never calls Azure. Only terraform plan (run by a human from CI) resolves the real scope, principal_id, and role_definition_id and exercises the PIM API.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

eligible_assignment_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/9f3b2c1a-..."
principal_type         = "ServicePrincipal"

🔍 Troubleshooting

Symptom Cause Fix
Error: Insufficient privileges on create Caller identity lacks User Access Administrator / Owner at the scope Grant the required role at the target scope (smallest that works).
Plan wants to replace the assignment You changed scope, role_definition_id, or principal_id These are force-new; a change means a new eligible assignment by design.
Error: Unsupported argument for tags or location This resource supports neither Remove them — they are intentionally not module variables.
Error: Unsupported argument for a timeouts.update This resource has no update timeout Use only create, read, delete.
Validation error on schedule.expiration More than one of duration_days / duration_hours / end_date_time set Supply exactly one expiration mechanism.
Eligibility never expires schedule omitted → permanent eligibility Add a bounded schedule.expiration.
condition_version required condition set without a version Set condition_version = "2.0".
Provider fails to initialize Missing caller-side features {} block Add provider "azurerm" { features {} } to the root module.
PIM operation rejected at tenant level Microsoft Entra ID P2 / Governance license not present Enable the required license before creating eligible assignments.

🔗 Related Docs

💙 "Infrastructure as Code should be standardized, consistent, and secure."