Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”· Microsoft Graph Access Package Terraform Module

Manages the create/read/update/delete lifecycle of Microsoft Graph accessPackage records (identityGovernance/entitlementManagement/accessPackages) plus their resourceRoleScopes child collection, via msgraph_resource.this, targeting microsoft/msgraph 0.3.0.

Terraform msgraph Module Type Resources


🧩 Overview

  • πŸ”— Provisions an access package β€” the catalog item end users request through Entra entitlement management β€” via POST identityGovernance/entitlementManagement/accessPackages, then grants it resource access by rendering zero or more resourceRoleScopes children.
  • 🧱 One keystone, one for_each child. msgraph_resource.this is the access package itself; msgraph_resource.resource_role_scopes, keyed by var.resource_role_scopes (a caller-chosen stable string, conventionally a resourceId_roleId composite per SCOPE.md), is the owned child collection.
  • πŸͺš This is the narrower "catalog item" module from a 3-way split of the original access-package catalog entry: catalog lifecycle belongs to terraform-msgraph-access-package-catalog; request/approval policy belongs to terraform-msgraph-access-package-assignment-policy. This module owns exactly the accessPackage entity and its resourceRoleScopes, nothing more.
  • πŸ”’ catalog_id is required on create and immutable thereafter β€” Microsoft Learn's Update reference documents only displayName/description/isHidden as updatable; moving a package to a different catalog is not supported in place.
  • ⚠️ resourceRoleScopes has no Graph Update method. Same shape of gap as terraform-msgraph-app-role-assignment: this module closes it with a terraform_data + lifecycle.replace_triggered_by pair, so a changed role/scope identifier under an existing map key is a clean plan-time replacement, not an apply-time Graph rejection.
  • 🚧 Known gap, documented, not silently swallowed: onboarding a catalog resource (a group, application, or SharePoint site) so it has roles/scopes to reference in the first place β€” accessPackageResourceRequest β€” is explicitly out of scope. resource_role_scopes accepts already-known role/scope identifiers as plain strings, looked up out of band via catalogs/{id}/resourceRoles and catalogs/{id}/resources. See 🧠 Architecture Notes.

πŸ’‘ Why it matters: an access package with no resourceRoleScopes is a real, valid Graph object β€” it can be created, described, and later referenced by an assignment policy β€” but it grants no actual resource access until at least one resourceRoleScopes entry exists. Splitting catalog lifecycle, package lifecycle, and policy lifecycle into three modules (per this suite's explicit convention against an oversized composite) means each piece can be planned, reviewed, and applied independently, at the cost of the caller needing to wire all three together deliberately β€” see the mandatory end-to-end composition in πŸ“š Example Library.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


πŸ—ΊοΈ Where this fits

terraform-msgraph-access-package-catalog has real, validated .tf files in this repository as of this module's authoring (confirmed by directory listing: main.tf, variables.tf, outputs.tf, providers.tf all present) β€” the upstream half of the composition shown below is a real, runnable reference, not an illustrative placeholder. terraform-msgraph-access-package-assignment-policy currently has only a providers.tf (confirmed by the same directory listing) β€” it is not yet authored as a consumable module; the downstream edge below reflects SCOPE.md's documented Emits contract, not a call you can make against a published module today.

flowchart LR
 Graph["Microsoft Graph<br/>Entra ID Governance tenant<br/>(POST/GET/PATCH/DELETE identityGovernance/entitlementManagement/accessPackages)"]:::external
 Catalog["terraform-msgraph-access-package-catalog<br/>(real, authored β€” upstream keystone)"]:::sibling
 AP["terraform-msgraph-access-package<br/>(this module)"]:::thisModule
 Policy["terraform-msgraph-access-package-assignment-policy<br/>(not yet authored β€” providers.tf only)"]:::sibling

 Catalog -->|"catalog.id reference"| AP
 AP -->|"manages accessPackage + resourceRoleScopes via"| Graph
 AP -->|"accessPackage.id reference"| Policy

 classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
 classDef sibling fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
 classDef external fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

(Validated via the Mermaid Chart MCP before embedding β€” valid: true.)


🧬 What this builds

One keystone (msgraph_resource.this) plus one for_each child collection (msgraph_resource.resource_role_scopes). terraform_data.resource_role_scope_replace_trigger is a built-in, non-Graph helper resource that gives each map entry a clean plan-time replacement path (see 🧠 Architecture Notes).

flowchart TD
 subgraph Inputs["Module inputs"]
 V1["display_name, description,<br/>is_hidden"]:::neutral
 V2["catalog_id (GUID,<br/>REQUIRED, force-new)"]:::neutral
 V3["resource_role_scopes map<br/>(default {}), keyed by a stable<br/>resourceId_roleId composite"]:::neutral
 end

 This["msgraph_resource.this<br/>url = identityGovernance/entitlementManagement/accessPackages<br/>keystone"]:::thisModule

 TD["terraform_data.resource_role_scope_replace_trigger[each.key]<br/>input = each.value<br/>(built-in resource β€” forces replace on any field change)"]:::neutral

 RRS["msgraph_resource.resource_role_scopes[each.key]<br/>for_each = var.resource_role_scopes<br/>url = accessPackages/id/resourceRoleScopes<br/>body = role, scope"]:::child

 subgraph Outputs["Outputs"]
 O1["id (primary)"]:::neutral
 O2["display_name"]:::neutral
 O3["resource_role_scopes map"]:::neutral
 end

 V1 --> This
 V2 --> This
 This -->|"msgraph_resource.this.id"| RRS
 V3 -->|"for_each"| TD
 V3 -->|"for_each"| RRS
 TD -->|"lifecycle.replace_triggered_by"| RRS
 This --> O1
 This --> O2
 RRS --> O3

 classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
 classDef child fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
 classDef neutral fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

(Validated via the Mermaid Chart MCP before embedding β€” valid: true.)

Resource inventory

Resource Type Role
msgraph_resource.this msgraph_resource Keystone β€” the accessPackage entity itself
terraform_data.resource_role_scope_replace_trigger terraform_data (Terraform built-in β€” no provider needed) Per-entry force-new trigger: input is the whole map entry, so any change under an existing key replaces it, and replace_triggered_by propagates that replacement to the real Graph resource
msgraph_resource.resource_role_scopes msgraph_resource The resourceRoleScope record itself β€” one per for_each entry, at url = "identityGovernance/entitlementManagement/accessPackages/{id}/resourceRoleScopes"

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
microsoft/msgraph 0.3.0, pinned exactly (pre-1.0 provider β€” no ~> constraint)
Built-in terraform_data Part of Terraform's own terraform.io/builtin/terraform provider β€” no required_providers entry, no provider {} block
Graph API version v1.0 (identityGovernance/entitlementManagement/accessPackages, .../resourceRoleScopes) β€” no beta dependency
Provider block None in this module β€” the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module

Schema notes that bite

  • catalog is required on create and immutable thereafter. Microsoft Learn's "Update accessPackage" reference documents only displayName/description/isHidden as updatable properties β€” catalog is not in that list. Changing catalog_id after creation requires destroying and recreating the access package (and, transitively, its resourceRoleScopes children and any sibling access-package-assignment-policy referencing this package's id). Not mechanically enforced via a Terraform lifecycle block β€” the same established convention as terraform-msgraph-group's mailNickname β€” so an attempted change PATCHes through and fails at apply, not at plan.
  • resourceRoleScopes has no Update method at all. Confirmed against a fresh Microsoft Learn pull of the live accessPackageResourceRoleScope resource-type reference (Methods table: List, Create, Delete only). This module forces a plan-time delete+recreate on any field change via terraform_data + lifecycle.replace_triggered_by β€” see 🧠 Architecture Notes.
  • You cannot delete an access package that has any accessPackageAssignment. Confirmed verbatim against Microsoft Learn's "Delete accessPackage" reference: "You cannot delete an access package if it has any accessPackageAssignment." Remove all assignments (via an accessPackageAssignmentRequest, outside this module's scope) before a terraform destroy of this module can succeed. See πŸ” Troubleshooting.
  • Graph's own computed id for a created resourceRoleScope record is "{role.id}_{scope.id}" β€” observed directly in Microsoft Learn's own Create examples (e.g. "cde82ecb-e461-496b-98fb-4f807c7ca640_dbeb8772-9907-4e95-a28e-a8d70dbcda69"). This is a different string from this module's for_each map key (a caller-chosen resourceId_roleId-style convention per SCOPE.md) β€” do not conflate the two. The Graph-computed id is only known after apply (surfaced via the resource_role_scopes output); the map key must be known at plan time and is therefore always caller-supplied.
  • role_origin_system / scope_origin_system are not validated against a closed enum. Microsoft Learn documents common values ("such as" AadGroup, AadApplication, SharePointOnline, AzureResources, CustomDataProvidedResource") β€” its own wording, not a closed set β€” so no validation {}` restricts these to a fixed list, unlike this library's usual practice for genuinely closed Graph enums.

πŸ”‘ Graph API Permissions Required

Operation Application permission
Create / Update / Delete access package (keystone) EntitlementManagement.ReadWrite.All (least-privileged Entra role: Access package manager or Catalog owner; higher-privileged alternative: Identity Governance Administrator)
Create / Delete resourceRoleScopes child EntitlementManagement.ReadWrite.All (same least-privileged roles)

All operations require admin consent. Confirmed against the live "Create accessPackage," "Update accessPackage," "Delete accessPackage," and "Create resourceRoleScope" Microsoft Learn permission tables β€” all four agree exactly on EntitlementManagement.ReadWrite.All as the sole application permission.

Microsoft Graph Prerequisites

  • Graph API version: v1.0 β€” no beta dependency for either the keystone or the resourceRoleScopes child (confirmed the v1.0 nav path is resourceRoleScopes, not the beta-only accessPackageResourceRoleScopes path, which additionally covers more resource kinds).
  • License/SKU: Microsoft Entra ID Governance (member users); some entitlement-management capabilities also operate under Entra ID P2 β€” confirm current SKU naming/bundling against Microsoft Learn before publishing, since bundling changes.
  • Admin consent: required for EntitlementManagement.ReadWrite.All.

πŸ“ Module Structure

terraform-msgraph-access-package/
β”œβ”€β”€ providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned exactly at 0.3.0;
β”‚ # no provider {} block; terraform_data needs no provider entry at all
β”œβ”€β”€ variables.tf # display_name, description, is_hidden, catalog_id, resource_role_scopes
β”œβ”€β”€ main.tf # msgraph_resource.this + terraform_data + msgraph_resource.resource_role_scopes
β”œβ”€β”€ outputs.tf # id (primary), display_name, resource_role_scopes
β”œβ”€β”€ README.md # this file
β”œβ”€β”€ SCOPE.md # this module's design record β€” permissions, prerequisites, provider gotchas
└── examples/
 └── basic/
 └── main.tf # smallest real call β€” display_name + catalog_id only

βš™οΈ Quick Start

terraform {
  required_version = ">= 1.12.0"

  required_providers {
    msgraph = {
      source  = "microsoft/msgraph"
      version = "0.3.0"
    }
  }
}

# Auth, tenant, and API version are configured here, by the caller β€” never inside this module.
provider "msgraph" {}

module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  description  = "Access package for outside sales representatives"
  catalog_id   = module.sales_catalog.id # a terraform-msgraph-access-package-catalog module's id output
}

output "access_package_id" {
  value = module.sales_access_package.id
}

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
catalog_id string (Guid) terraform-msgraph-access-package-catalog module id output
resource_role_scopes[*].role_id / role_origin_id / role_origin_system / role_resource_id string Pre-existing catalog resource role identifiers β€” out-of-band lookup via catalogs/{id}/resourceRoles, not sourced from any module in this catalog (see 🧠 Architecture Notes' documented gap)
resource_role_scopes[*].scope_id / scope_origin_id / scope_origin_system string Pre-existing catalog resource scope identifiers β€” out-of-band lookup via catalogs/{id}/resources, same documented gap

Emits

Output Description Consumed by
id Graph object id (GUID) of the created access package β€” primary output terraform-msgraph-access-package-assignment-policy module (accessPackage.id reference) β€” not yet authored, see πŸ—ΊοΈ Where this fits
display_name The access package's displayName, as provided to this module Informational only
resource_role_scopes map(string), keyed by the same map key as var.resource_role_scopes β€” each value is Graph's computed resourceRoleScope id ("{role.id}_{scope.id}") Informational only β€” no module in this catalog consumes this today

πŸ“š Example Library

1 Β· Minimal required call
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = "66584aae-98bb-48cc-9458-7bee5d2a6577"
}

ℹ️ display_name and catalog_id are the only two Graph-required properties. No resourceRoleScopes are rendered β€” this creates a real, valid access package that grants no resource access yet.

2 Β· Staged package, hidden from requestors
module "finance_access_package_staging" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Finance Approvers"
  description  = "Staged β€” not yet visible to requestors while resourceRoleScopes are wired up"
  catalog_id   = module.finance_catalog.id
  is_hidden    = true
}

πŸ’‘ Set is_hidden = true while resourceRoleScopes and an assignment policy are still being wired up, then flip it to false (an in-place update β€” isHidden is documented as updatable) once the package is ready to be requestable.

3 Β· Adding a resourceRoleScopes entry for an onboarded AAD group (root scope)
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id

  resource_role_scopes = {
    "9028d19c-26a9-4809-8e3f-20ff73e2d75e_member" = {
      role_origin_id      = "9028d19c-26a9-4809-8e3f-20ff73e2d75e" # the group's own object id
      role_origin_system  = "AadGroup"
      scope_origin_id     = "9028d19c-26a9-4809-8e3f-20ff73e2d75e"
      scope_origin_system = "AadGroup"
      scope_is_root_scope = true
    }
  }
}

⚠️ Out-of-scope prerequisite: this group must already be onboarded as an accessPackageResource into the same catalog (a separate accessPackageResourceRequest, not performed by this module) before this call succeeds β€” see 🧠 Architecture Notes.

4 Β· Adding a resourceRoleScopes entry for an application role (non-root scope)
module "internal_tools_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Internal Tools Standard User"
  catalog_id   = module.internal_tools_catalog.id

  resource_role_scopes = {
    "5f80c0c7-a180-4521-b585-18200048a0d8_cde82ecb-e461-496b-98fb-4f807c7ca640" = {
      role_id = "cde82ecb-e461-496b-98fb-4f807c7ca640" # accessPackageResourceRole.id, from
      # listing catalogs/{id}/resourceRoles
      role_origin_id      = "a29a7690-b3c4-4ed5-96c6-f640cde06fb8"
      role_origin_system  = "AadApplication"
      role_resource_id    = "5f80c0c7-a180-4521-b585-18200048a0d8" # the onboarded accessPackageResource's id
      scope_id            = "dbeb8772-9907-4e95-a28e-a8d70dbcda69"
      scope_origin_id     = "e81d7f57-0840-45e1-894b-f505c1bdcc1f"
      scope_origin_system = "AadApplication"
    }
  }
}

ℹ️ Compare this to Example 3 β€” different origin systems populate a different subset of fields. role_resource_id disambiguates which onboarded catalog resource a role belongs to; it is optional in this module's schema because not every documented Create example populates it the same way.

5 Β· Multiple resourceRoleScopes in one call
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id

  resource_role_scopes = {
    "sales-group_member" = {
      role_origin_id      = module.sales_group.id
      role_origin_system  = "AadGroup"
      scope_origin_id     = module.sales_group.id
      scope_origin_system = "AadGroup"
      scope_is_root_scope = true
    }
    "crm-app_standard-user" = {
      role_id             = local.crm_standard_user_role_id
      role_origin_id      = local.crm_app_id
      role_origin_system  = "AadApplication"
      role_resource_id    = local.crm_app_resource_id
      scope_id            = local.crm_app_scope_id
      scope_origin_id     = local.crm_app_id
      scope_origin_system = "AadApplication"
    }
  }
}

πŸ’‘ Keying by a stable, human-readable map key (never an array index) means adding or removing one resourceRoleScopes entry never forces Terraform to re-index β€” and therefore never re-plans β€” any other entry in the map.

6 Β· Zero-ID role (role_id omitted)
module "internal_tools_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Internal Tools Standard User"
  catalog_id   = module.internal_tools_catalog.id

  resource_role_scopes = {
    "resource-abc_zero-role" = {
      # role_id deliberately omitted (left null) β€” Microsoft Learn: "If the role returned from the list
      # of the roles has a zero ID, then don't include an id in the accessPackageResourceRole."
      role_origin_id      = local.resource_origin_id
      role_origin_system  = "SharePointOnline"
      scope_origin_id     = local.resource_origin_id
      scope_origin_system = "SharePointOnline"
      scope_is_root_scope = true
    }
  }
}

ℹ️ role_id is optional(string) in this module's schema for exactly this documented case.

7 Β· Updating displayName/description/isHidden in place
# Before:
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Reps"
  catalog_id   = module.sales_catalog.id
}

# After:
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"         # changed β€” clean in-place update
  description  = "Outside sales representatives" # added β€” clean in-place update
  catalog_id   = module.sales_catalog.id         # unchanged
}

ℹ️ displayName, description, and isHidden are the only three properties Microsoft Learn documents as updatable β€” plan shows these as a clean in-place update, not a replacement.

8 Β· Changing catalog_id (force-new gotcha)
# Before:
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id
}

# After β€” moving the package to a different catalog:
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.regional_sales_catalog.id # changed
}

⚠️ This is not mechanically caught at plan time β€” catalog_id is documented immutable, but this module does not enforce it via a Terraform lifecycle block (same convention as terraform-msgraph-group's mailNickname). plan will show an in-place update; apply will fail against Graph with a 400. The only clean path to move a package to a different catalog is terraform destroy followed by a new apply with the new catalog_id β€” see πŸ” Troubleshooting.

9 Β· Empty resourceRoleScopes β€” the secure-by-default no-op
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id

  # resource_role_scopes left at its default, {} β€” creates the access package with no resource access
  # granted yet.
}

πŸ’‘ resource_role_scopes defaults to an empty map. The empty call is a safe no-op with respect to resource access β€” consistent with this suite's secure-by-default convention β€” while still creating a real, describable access package.

10 Β· A validation failure this module catches before any Graph call
module "bad_example" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Broken Package"
  catalog_id   = "66584aae-98bb-48cc-9458-7bee5d2a6577"

  resource_role_scopes = {
    "bad-entry" = {
      role_origin_id      = "" # fails the non-empty-string validation{} block
      role_origin_system  = "AadGroup"
      scope_origin_id     = "9028d19c-26a9-4809-8e3f-20ff73e2d75e"
      scope_origin_system = "AadGroup"
    }
  }
}

⚠️ This fails terraform validate/plan, not apply β€” every error_message names the offending map key(s) directly. Compare this to πŸ§ͺ Testing, below: shape-level mistakes like this one are caught for free; whether a syntactically valid identifier actually refers to a resource/role/scope already onboarded into the catalog is not, because that requires a live Graph lookup this module cannot perform at plan time.

11 Β· Least-privilege permissions callout
# Root module β€” provider auth configured with exactly what this module needs: EntitlementManagement.ReadWrite.All,
# ideally scoped down further via the Access package manager or Catalog owner Entra role rather than the
# broader Identity Governance Administrator role.
provider "msgraph" {}

module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id
}

πŸ”’ See πŸ”‘ Graph API Permissions Required β€” do not grant the higher-privileged Identity Governance Administrator role unless the pipeline's other modules genuinely require it.

12 Β· Retargeting a resourceRoleScopes entry (forced replacement, not an update)
# Before:
resource_role_scopes = {
 "contractor-access" = {
 role_origin_id = local.old_group_id
 role_origin_system = "AadGroup"
 scope_origin_id = local.old_group_id
 scope_origin_system = "AadGroup"
 scope_is_root_scope = true
 }
}

# After β€” repointing the same conceptual grant at a different onboarded group:
resource_role_scopes = {
 "contractor-access" = {
 role_origin_id = local.new_group_id # changed
 role_origin_system = "AadGroup"
 scope_origin_id = local.new_group_id # changed
 scope_origin_system = "AadGroup"
 scope_is_root_scope = true
 }
}

⚠️ Changing any field under an existing map key is a real, expected delete+recreate β€” plan shows this entry as -/+ (replace), not a clean in-place update, because Graph has no Update method for accessPackageResourceRoleScope. See 🧠 Architecture Notes for the terraform_data mechanism that makes this a clean plan-time replacement instead of an apply-time Graph error.

13 Β· Consuming outputs for reporting
module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  catalog_id   = module.sales_catalog.id

  resource_role_scopes = {
    "sales-group_member" = {
      role_origin_id      = module.sales_group.id
      role_origin_system  = "AadGroup"
      scope_origin_id     = module.sales_group.id
      scope_origin_system = "AadGroup"
      scope_is_root_scope = true
    }
  }
}

output "sales_access_package_report" {
  value = {
    package_id           = module.sales_access_package.id
    display_name         = module.sales_access_package.display_name
    resource_role_scopes = module.sales_access_package.resource_role_scopes
  }
}

ℹ️ resource_role_scopes is a map(string) of Graph-computed resourceRoleScope ids β€” useful for audit reporting, not itself consumed by another module in this catalog today.

14 Β· πŸ—οΈ End-to-end composition β€” catalog, access package, and (planned) assignment policy

terraform-msgraph-access-package-catalog has real, validated .tf files in this repository β€” the first two modules below are a real, runnable chain. terraform-msgraph-access-package-assignment-policy is not yet authored (confirmed by directory listing: only providers.tf exists) β€” its block is shown commented out, illustrating the intended contract from SCOPE.md's Emits table, not a call you can make today.

module "sales_catalog" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package-catalog.git?ref=v1.0.0"

  display_name = "Sales Resources"
  state        = "published"
}

module "sales_access_package" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

  display_name = "Sales Representatives"
  description  = "Outside sales representatives"
  catalog_id   = module.sales_catalog.id

  resource_role_scopes = {
    "sales-group_member" = {
      role_origin_id      = module.sales_group.id # a terraform-msgraph-group module's id, onboarded into
      role_origin_system  = "AadGroup"            # the catalog as an accessPackageResource out of band
      scope_origin_id     = module.sales_group.id
      scope_origin_system = "AadGroup"
      scope_is_root_scope = true
    }
  }
}

# Illustrative only β€” terraform-msgraph-access-package-assignment-policy is a planned catalog entry, not
# yet authored. Chaining a request/approval policy after the access package is MANDATORY in real usage
# β€” a package with no assignment policy can never actually be requested by an end user.
# module "sales_access_package_default_policy" {
# source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package-assignment-policy.git?ref=v1.0.0"
#
# access_package_id = module.sales_access_package.id
# display_name = "Standard sales rep request policy"
# #... requestor/approval/review settings...
# }

πŸ’‘ Notice the ordering Terraform's own dependency graph enforces without any depends_on: catalog_id comes from sales_catalog.id, a real output reference β€” Terraform will not attempt to create the access package before the catalog exists. Once access-package-assignment-policy is authored, the same pattern applies one level further: its access_package_id input would come from sales_access_package.id.


πŸ“₯ Inputs

Grouped summary

Group Variables
Required at create display_name, catalog_id
Updatable in place description (default null), is_hidden (default false)
Immutable / force-new catalog_id
resourceRoleScopes children resource_role_scopes (default {})
Full variable reference (5 inputs)
Variable Type Default Validation Graph property
display_name string β€” (required) Must not be empty displayName
description string null β€” description
is_hidden bool false β€” isHidden
catalog_id string β€” (required) Must be a GUID catalog.id β€” force-new
resource_role_scopes map(object({ role_id = optional(string), role_origin_id = string, role_origin_system = string, role_resource_id = optional(string), scope_id = optional(string), scope_origin_id = string, scope_origin_system = string, scope_is_root_scope = optional(bool, false) })) {} role_origin_id, role_origin_system, scope_origin_id, scope_origin_system must each be non-empty for every entry (four separate validation {} blocks, each naming the offending key(s)) resourceRoleScopes[*].role, .scope

Deliberately excluded from this schema (see variables.tf's header comment and SCOPE.md's "Design decisions"):

  • id, createdDateTime, modifiedDateTime β€” read-only, computed by Graph.
  • accessPackagesIncompatibleWith, incompatibleAccessPackages, incompatibleGroups, assignmentPolicies β€” separate relationships out of scope for this module (assignmentPolicies is owned by the sibling access-package-assignment-policy module).
  • The entire accessPackageResource / accessPackageResourceRequest object graph β€” onboarding a resource into the catalog. Firm, deliberate scope boundary, not an open question β€” see 🧠 Architecture Notes.
  • role.displayName / role.description / scope.displayName / scope.description on resource_role_scopes entries β€” informational metadata Graph resolves from the origin system; this module's surface is limited to genuine identifiers.
  • No secret-bearing property exists on either the accessPackage or accessPackageResourceRoleScope resource types, so nothing was excluded on that basis.

🧾 Outputs

Output Description Sensitive / excluded?
id Graph object id (GUID) of the created access package. Primary output. No
display_name The access package's displayName, as provided to this module No
resource_role_scopes map(string), keyed by the same map key as resource_role_scopes β€” each value is the created resourceRoleScope record's Graph-computed id ("{role.id}_{scope.id}") No

No output is ever derived from a credential-bearing property β€” this entity carries none.


🧠 Architecture Notes

  • Why terraform_data + lifecycle.replace_triggered_by exists β€” the one thing to understand before touching main.tf. Graph never implemented an Update method for accessPackageResourceRoleScope (confirmed against the live resource-type reference's Methods table: List, Create, Delete only). Left alone, a drifted field under an unchanged map key would make the provider attempt an apply-time PATCH Graph rejects. terraform_data.resource_role_scope_replace_trigger[each.key].input is the whole map entry, so any change to it replaces that terraform_data instance, and replace_triggered_by propagates the replacement to the corresponding msgraph_resource.resource_role_scopes instance β€” exactly the same mechanism terraform-msgraph-app-role-assignment uses for the same category of gap.
  • catalog_id is force-new but not mechanically enforced. msgraph_resource's generic body has no per-property schema for Terraform to key a forced replacement off of β€” the same situation as terraform-msgraph-group's mailNickname. An attempted change PATCHes through to Graph and fails there (an apply-time 400), not at plan time. This is documented, not silently swallowed β€” see Example 8 and πŸ” Troubleshooting.
  • THE DOCUMENTED OBJECT-GRAPH GAP: catalog-resource onboarding is not covered by any module in this catalog yet. Populating resource_role_scopes requires the referenced accessPackageResource (a group, application, or SharePoint site) to already be onboarded into the same catalog as this access package β€” a separate accessPackageResourceRequest, and the specific role/scope pair retrieved from catalogs/{id}/resourceRoles and catalogs/{id}/resources first. This module deliberately does not build any of that β€” it accepts already-known role/scope identifiers as plain string inputs only. A future access-package-resource module would be the right place to own catalog-resource onboarding; folding it into this module would force an oversized composite per this suite's explicit convention against that. Until that module exists, every caller of this module must look up role_origin_id/role_origin_system/scope_origin_id/scope_origin_system (and, where applicable, role_id/scope_id/role_resource_id) out of band β€” via Graph Explorer, PowerShell, or a portal lookup β€” before populating resource_role_scopes.
  • You cannot delete an access package that has any accessPackageAssignment. This is a real, documented hard constraint (Microsoft Learn's "Delete accessPackage" reference), not a Terraform or provider limitation β€” terraform destroy on this module will fail against Graph if any user currently holds an assignment to this package. See πŸ” Troubleshooting for the remediation path.
  • for_each key stability. SCOPE.md's Design decisions direct a resourceId_roleId composite naming convention for resource_role_scopes map keys. This is a naming convention only, not enforced by Terraform β€” note that Graph's own computed id for the created record is actually "{role.id}_{scope.id}" (confirmed from live Microsoft Learn examples), a related but distinct string from the caller-chosen map key, since the map key must be known at plan time and role/scope ids are frequently not (see Example 6's zero-ID role case).

🧱 Design Principles

Concern Secure default in this module Opt-out (caller must be explicit)
Visibility to requestors is_hidden defaults to false (Graph documents no explicit platform default; this is the ordinary published state, not an hardening) Caller sets is_hidden = true to stage a package before it's ready to be requested
Empty resource_role_scopes Defaults to {} β€” a real, valid access package is created, but grants no resource access until at least one entry is added Caller supplies one or more map entries explicitly
Catalog-resource onboarding Never performed by this module β€” a firm scope boundary, not a shortcut this module takes N/A β€” a future access-package-resource module owns this; not an opt-out, an architectural boundary
Force-new field changes Any change to a resource_role_scopes entry's identifiers under an existing map key is always a visible, plan-time delete+recreate β€” never a silent apply-time failure None β€” this reflects a genuine Graph API limitation (no Update method), not an policy choice
Sensitive outputs No output is ever derived from a credential-bearing property N/A β€” this entity carries no secret-bearing property at all

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin every real consumption of this module to an explicit tag, never a branch:

source = "git::https://github.com/microsoftexpert/terraform-msgraph-access-package.git?ref=v1.0.0"

πŸ§ͺ Testing

terraform validate and terraform fmt -check prove the offline contract: display_name is non-empty, catalog_id matches a GUID shape, and every resource_role_scopes entry has non-empty role_origin_id/role_origin_system/scope_origin_id/scope_origin_system before any Graph call is made β€” a malformed entry fails plan, never silently reaches apply.

What this cannot catch: msgraph_resource.this and msgraph_resource.resource_role_scopes's body arguments are generic maps from the provider's own perspective β€” the provider does not know what an accessPackage or accessPackageResourceRoleScope is. Concretely, in this module:

  • A syntactically valid GUID catalog_id that does not correspond to a real, existing catalog passes validate/plan cleanly and fails only at apply, with a Graph-side 400 or 404.
  • A resource_role_scopes entry whose role_origin_id/scope_origin_id do not correspond to an actually-onboarded accessPackageResource in the same catalog cannot be checked at plan time β€” it requires a live read of catalogs/{id}/resources and catalogs/{id}/resourceRoles, which this module deliberately does not perform (see 🧠 Architecture Notes' documented object-graph gap).
  • An attempted change to catalog_id on an existing access package is shown by plan as an in-place update (this module does not force a Terraform-level replacement on it) but fails at apply with a Graph 400, since catalog is not in Graph's documented Update property list (see Example 8).
  • terraform destroy against an access package that still has any accessPackageAssignment fails at apply with a Graph error β€” this cannot be predicted at plan time, since it depends on live assignment state this module does not read.

This module's non-empty-string and GUID-shape validation {} blocks are the only thing standing between the caller and a Graph 4xx error at apply time for anything beyond shape-level mistakes.


πŸ’¬ Example Output

$ terraform output
id = "642181f0-bc17-4fc6-9ebb-ff53dbf18c2f"
display_name = "Sales Representatives"
resource_role_scopes = {
 "sales-group_member" = "cde82ecb-e461-496b-98fb-4f807c7ca640_dbeb8772-9907-4e95-a28e-a8d70dbcda69"
}

πŸ” Troubleshooting

Symptom Cause Fix
terraform destroy fails with a Graph error referencing accessPackageAssignment You cannot delete an access package that has any active assignment β€” a real, documented hard constraint, not a Terraform or provider defect Remove all assignments first (via an accessPackageAssignmentRequest with requestType: adminRemove, outside this module's scope), then retry destroy
plan shows an in-place update to catalog_id, but apply fails with a 400 catalog is documented immutable β€” this module does not force a Terraform-level replacement on it (see 🧠 Architecture Notes) Revert catalog_id, or destroy and recreate the access package with the new catalog; do not expect an in-place move
plan shows a resource_role_scopes entry as replace (-/+) instead of update-in-place Expected β€” a field under an existing map key changed, and Graph has no Update method for accessPackageResourceRoleScope Confirm the new values are correct, then apply; if unintentional, revert the values back
apply fails creating a resourceRoleScopes entry with a 400/404 The referenced role/scope origin identifiers don't correspond to a resource actually onboarded into this package's catalog Confirm the resource is onboarded (catalogs/{id}/resources) and the role/scope pair is correct (catalogs/{id}/resourceRoles) before reapplying β€” this cannot be caught at plan time
apply fails with a 403 The calling principal lacks EntitlementManagement.ReadWrite.All (or an equivalent Entra entitlement-management role), or admin consent hasn't been granted Confirm the permission and admin consent β€” see πŸ”‘ Graph API Permissions Required
apply is slow to reflect a just-created catalog when used as catalog_id Graph's Entra ID Governance write path is not always immediately read-consistent Re-run plan/apply; do not add a fixed sleep β€” retry with backoff if this recurs

πŸ”— Related Docs

Releases

Packages

Contributors

Languages