Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

🔌 Power Platform Tenant Isolation Policy Terraform Module

Provisions the single, tenant-wide Power Platform tenant isolation policy — the inbound/outbound cross-tenant connection allow-list that governs every environment in the Entra tenant. Secure-by-default is_disabled = true (inert/off), a caller-keyed exception map, and a single primary resource named this. Built for microsoft/power-platform ~> 4.1.

Terraform provider module type resources


🧩 Overview

  • 🏗️ Provisions one powerplatform_tenant_isolation_policy — a tenant-wide singleton: there is exactly one of these per Entra tenant, and it takes no environment_id input at all.
  • 🔒 Defaults is_disabled = true (isolation OFF / inert) per this suite's secure-by-default convention for tenant-wide toggles — the caller must set is_disabled = false explicitly, understanding the blast radius, to begin enforcing cross-tenant connection restrictions.
  • 🗂️ allowed_tenants is a caller-keyed map(object({ inbound, outbound })), keyed by the external tenant's GUID (or the literal wildcard "*" for a default posture applied to every unlisted tenant) — rendered via a plain for expression into the resource's required attribute-style set.
  • ⚠️ This module ships a confirmed, exhaustively-reproduced upstream provider defect write-up — see "Schema notes that bite" and "Troubleshooting" below before relying on this module's terraform validate output.

💡 Why it matters: tenant isolation is the single biggest lever for containing cross-tenant data exfiltration risk in Power Platform — but it is also, structurally, one of the highest blast-radius toggles in the entire catalog: flipping it on affects every environment and every user in the tenant simultaneously, with no report-only/dry-run mode.


❤️ 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

graph TD
 TENANT["Entra Tenant (implicit — this module IS a tenant-wide singleton)"]:::this
 TIP["terraform-power-platform-tenant-isolation-policy (this module)"]:::this
 ENV["terraform-power-platform-environment (all environments in the tenant)"]:::neutral
 EXT["External Entra tenants (allowed_tenants exceptions)"]:::neutral
 TS["terraform-power-platform-tenant-settings (sibling tenant-wide singleton)"]:::neutral

 TENANT -->|"exactly one policy per tenant"| TIP
 TIP -->|"governs inbound/outbound Power Platform connections for"| ENV
 EXT -->|"allowed_tenants exception (tenant_id, inbound, outbound)"| TIP
 TIP -.->|"no direct cross-reference — both tenant-wide singletons"| TS

 classDef this fill:#742774,color:#fff,stroke:#742774;
 classDef neutral fill:#E8E8E8,color:#222,stroke:#999;
Loading

🧬 What this builds

graph TD
 D1["var.is_disabled (default true — inert)"]:::input
 D2["var.allowed_tenants (map keyed by tenant_id or wildcard)"]:::input
 D3["var.timeouts"]:::input

 R["powerplatform_tenant_isolation_policy.this"]:::this

 O1["output: id"]:::output
 O2["output: is_disabled"]:::output
 O3["output: allowed_tenant_ids"]:::output

 D1 -->|"is_disabled"| R
 D2 -->|"allowed_tenants = toset([for tenant_id,v in var.allowed_tenants: {...}])"| R
 D3 -->|"timeouts"| R

 R -->|"id"| O1
 R -->|"is_disabled"| O2
 D2 -->|"{ for tenant_id,v in var.allowed_tenants: tenant_id => {...} }"| O3

 classDef input fill:#E8E8E8,color:#222,stroke:#999;
 classDef this fill:#742774,color:#fff,stroke:#742774;
 classDef output fill:#E8E8E8,color:#222,stroke:#999;
Loading

Resource inventory: one resource, powerplatform_tenant_isolation_policy.this. No for_each children — allowed_tenants is a required attribute-style collection on this same resource, not an independent child.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
microsoft/power-platform ~> 4.1
Provider block None — the caller's root module configures authentication

Schema notes that bite:

  • is_disabled and allowed_tenants are BOTH required fields in the live schema (no provider-side default for either) — this module supplies its own secure defaults so the empty call produces the safe, inert resource.
  • The literal wildcard tenant id "*" is a real, valid allowed_tenants key that sets a default inbound/outbound posture for every external tenant NOT otherwise listed — it is not a placeholder.
  • This is a tenant-wide singleton — there is exactly one tenant isolation policy per Entra tenant. Running this module from two different root configurations against the same tenant will fight over the same underlying policy.
  • ⚠️ CONFIRMED UPSTREAM DEFECT, exhaustively reproduced this session (microsoft/power-platform 4.1.0): allowed_tenants is implemented in the provider using a raw Go slice model type ([]tenant_isolation_policy.AllowedTenantModel) rather than a framework-aware types.Set. Terraform's validate command substitutes an unknown value of the declared type for every variable-sourced expression — standard, documented CLI behavior, independent of whether a default or -var override is supplied — and this resource's raw-slice model type cannot represent "unknown" for the whole collection. The result: terraform validate, run standalone against this module, fails with:
Error: Value Conversion Error
An unexpected error was encountered trying to build a value. This is always
an error in the provider....
Path:
Target Type: []tenant_isolation_policy.AllowedTenantModel
Suggested Type: basetypes.SetValue

This was reproduced across 10+ distinct HCL constructions this session — direct variable assignment, for-expression, toset, local indirection, concat, JSON round-trip, set- vs. list-typed variables, required-vs-defaulted variables, all fail identically. A single scalar leaf value (e.g. one tenant_id sourced from a plain string variable inside an otherwise fully-literal collection) does NOT trigger it — only the outer collection's cardinality/shape being variable-derived does. This is not a house authoring defect: the equivalent variable-sourced Set Nested Attributes on terraform-power-platform-data-loss-prevention-policy (same session, same provider version) validate cleanly, proving the gap is specific to this resource's model implementation. No HCL-only workaround exists that preserves a genuinely reusable (variable-driven) module — see Troubleshooting below.


🔑 Required Entra App Registration Permissions & Power Platform Admin Role

  • Power Platform Administrator role on the authenticating principal (tenant-wide policy — no narrower, environment-scoped role applies here, unlike DLP policies).
  • Confirm current Power Platform API app-registration permission scope names against the provider's Authentication guide at authoring/deployment time — Microsoft has renamed and restructured these scopes across provider versions.

Power Platform Prerequisites

  • Power Platform Administrator role.
  • No specific licensing beyond standard Power Platform administration capabilities.
  • Confirm the full, current list of external tenant GUIDs that must be allow-listed BEFORE flipping is_disabled = false in a tenant with existing cross-tenant integrations — this policy has no "dry run"/report-only mode analogous to a Conditional Access policy.

📁 Module Structure

terraform-power-platform-tenant-isolation-policy/
├── providers.tf # required_providers + required_version — no provider {} block
├── variables.tf # is_disabled, allowed_tenants, timeouts
├── main.tf # single resource "powerplatform_tenant_isolation_policy" "this"
├── outputs.tf # id, is_disabled, allowed_tenant_ids
├── README.md # this file
├── SCOPE.md # lightweight standalone scope
└── examples/ # runnable example configurations

⚙️ Quick Start

module "tenant_isolation" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = {
      inbound  = true
      outbound = true
    }
  }
}

ℹ️ The caller's root module configures the powerplatform provider block (Azure CLI, Service Principal + secret/certificate, OIDC, or Managed Identity) — this module never references authentication. See the confirmed upstream defect above before relying on terraform validate for this module.


🔌 Cross-Module Contract

Consumes

Input Type Source module
(none) — This is a tenant-wide singleton; it consumes no sibling-module outputs. allowed_tenants keys are external tenant GUIDs supplied directly by the caller.

Emits

Output Description Consumed by
id Tenant isolation policy id — primary output. Exactly one per Entra tenant. Informational / audit
is_disabled Whether enforcement is currently disabled Reporting; caller gating logic
allowed_tenant_ids Map of tenant_id (or "*") => { inbound, outbound } Informational / audit

📚 Example Library

1 · Minimal inert policy (default, no enforcement)
module "inert" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"
}

💡 is_disabled defaults to true — the empty call produces a policy resource that enforces nothing. This is the safe default; no cross-tenant connections are affected.

2 · Enforcing isolation with a single allowed external tenant
module "isolated_with_partner" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = {
      inbound  = true
      outbound = true
    }
  }
}

🔒 Setting is_disabled = false begins enforcing inbound/outbound restrictions tenant-wide, immediately, for every environment in the tenant.

3 · Inbound-only exception (partner may connect in, not out)
module "inbound_only_partner" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "22222222-2222-2222-2222-222222222222" = {
      inbound  = true
      outbound = false
    }
  }
}
4 · Outbound-only exception (we may connect out, partner may not connect in)
module "outbound_only_partner" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "33333333-3333-3333-3333-333333333333" = {
      inbound  = false
      outbound = true
    }
  }
}
5 · Multiple partner tenants with mixed postures
module "multi_partner_isolation" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
    "22222222-2222-2222-2222-222222222222" = { inbound = true, outbound = false }
    "33333333-3333-3333-3333-333333333333" = { inbound = false, outbound = true }
  }
}
6 · Wildcard default posture for all unlisted tenants (restrictive)
module "wildcard_restrictive" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
    "*"                                    = { inbound = false, outbound = false }
  }
}

ℹ️ The "*" key sets the DEFAULT posture for every tenant not explicitly listed above it — here, every unlisted tenant is fully blocked (inbound and outbound both false).

7 · Wildcard default posture for all unlisted tenants (permissive outbound only)
module "wildcard_outbound_permissive" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "*" = { inbound = false, outbound = true }
  }
}

⚠️ Every unlisted external tenant may receive OUTBOUND connections from this tenant under this configuration, while none may connect IN. Confirm this matches the intended governance posture before applying — this is a tenant-wide change.

8 · Acquisition/merger scenario — several newly-trusted partner tenants at once
module "post_acquisition_isolation" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "44444444-4444-4444-4444-444444444444" = { inbound = true, outbound = true }
    "55555555-5555-5555-5555-555555555555" = { inbound = true, outbound = true }
    "66666666-6666-6666-6666-666666666666" = { inbound = true, outbound = true }
  }
}

💡 The keyed map(object) design means adding a fourth acquired tenant later never reindexes or disturbs the first three entries.

9 · Removing a previously-allowed tenant (governance change)
module "partner_offboarded" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    # "11111111-1111-1111-1111-111111111111" removed — partner relationship ended.
    "22222222-2222-2222-2222-222222222222" = { inbound = true, outbound = false }
  }
}

⚠️ Removing an entry here is a tenant-wide governance change, not a routine infrastructure edit — confirm the offboarded tenant's integrations have been decommissioned first.

10 · Custom per-operation timeouts
module "slow_region_isolation" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
  }

  timeouts = {
    create = "15m"
    update = "10m"
  }
}
11 · Explicitly re-affirming the inert default with a code comment (governance audit trail)
module "explicitly_inert" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  # Tenant isolation intentionally left disabled: no cross-tenant restriction requirement has been
  # identified for this tenant as of this review cycle. Re-evaluate at the next security review.
  is_disabled = true
}

💡 Even though is_disabled = true is the default, restating it explicitly with a comment creates an auditable record of a deliberate decision, not an oversight.

12 · Preparing an allow-list before enabling enforcement (staged rollout)
module "staged_rollout" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  # Stage 1: populate the allow-list while still disabled, so the exception list is fully reviewed
  # before flipping is_disabled = false in a follow-up change.
  is_disabled = true

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
    "22222222-2222-2222-2222-222222222222" = { inbound = true, outbound = false }
  }
}

ℹ️ allowed_tenants has no effect while is_disabled = true — this pattern lets a reviewer validate the exception list in a PR before a second change flips enforcement on.

13 · Full bidirectional trust with one partner, one-way with another
module "asymmetric_trust" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "77777777-7777-7777-7777-777777777777" = { inbound = true, outbound = true }
    "88888888-8888-8888-8888-888888888888" = { inbound = true, outbound = false }
  }
}
14 · Disabling enforcement while preserving the allow-list for later re-enable
module "temporarily_disabled" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  # Temporarily disabled during a cross-tenant migration cutover; allow-list preserved so
  # re-enabling later is a single is_disabled flip, not a full reconstruction.
  is_disabled = true

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
  }
}
15 · 🏗️ End-to-end composition — tenant isolation policy alongside environment-scoped DLP governance
module "tenant_isolation" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-tenant-isolation-policy.git?ref=v1.0.0"

  is_disabled = false

  allowed_tenants = {
    "11111111-1111-1111-1111-111111111111" = { inbound = true, outbound = true }
    "*"                                    = { inbound = false, outbound = false }
  }
}

module "environment" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-environment.git?ref=v1.0.0"

  display_name     = "Claims Processing — Production"
  location         = "unitedstates"
  environment_type = "Production"

  dataverse = {
    currency_code     = "USD"
    language_code     = 1033
    security_group_id = "8e6499a0-4b1b-4c2e-9f2b-6a9d3e2b7c11"
  }
}

module "dlp_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-power-platform-data-loss-prevention-policy.git?ref=v1.0.0"

  display_name     = "Claims Processing DLP"
  environment_type = "OnlyEnvironments"
  environments     = [module.environment.id]
}

ℹ️ Tenant isolation and DLP are complementary, independent controls: tenant isolation governs WHICH external tenants may connect at all; DLP governs WHICH connectors may be combined once connected. Neither module references the other's outputs — both are wired here purely to show a realistic governance-layer composition.


📥 Inputs

Group Variables
Enforcement toggle is_disabled (default true)
Exception list allowed_tenants (keyed map, default {})
Universal tail timeouts
Full variables.tf schema
variable "is_disabled" {
  type    = bool
  default = true
}

variable "allowed_tenants" {
  type = map(object({
    inbound  = bool
    outbound = bool
  }))
  default = {}
}

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

🧾 Outputs

Output Description Sensitive/Conditional
id Tenant isolation policy id (primary output) —
is_disabled Whether enforcement is currently disabled —
allowed_tenant_ids Map of tenant_id (or "*") => { inbound, outbound } —

🧠 Architecture Notes

  • Tenant-wide singleton, no environment_id. Unlike almost every other module in this catalog, this resource does not scope to an environment at all — it is one policy per Entra tenant.
  • allowed_tenants is a required attribute-style Set on the single resource, rendered from a keyed map(object) variable via a plain for expression wrapped in toset — never a dynamic block (invalid for this schema shape) and never a separate child resource.
  • The "*" wildcard key is a real provider value, not a house convention — it sets the default inbound/outbound posture for every external tenant not otherwise listed. Treat any change to a wildcard entry as equivalent in blast radius to changing is_disabled itself.
  • terraform validate cannot be made to pass cleanly for this module as long as allowed_tenants is exposed as a genuinely caller-configurable variable, due to a confirmed upstream provider defect (see "Schema notes that bite" and "Troubleshooting"). This module keeps the correct, idiomatic, reusable design rather than hardcoding a fixed-shape allowed_tenants purely to force a green validate run.

🧱 Design Principles

Default Opt-out
is_disabled = true (isolation OFF / inert) Caller sets is_disabled = false explicitly, with a comment acknowledging the tenant-wide blast radius
allowed_tenants defaults to {} (no exceptions configured) Caller populates the map explicitly
No tags variable (this provider has no tagging concept) N/A — not applicable to this provider
No provider {} block, no credential-shaped variable Caller's root module configures auth

🚀 Runbook

cd terraform-power-platform-tenant-isolation-policy
terraform init -backend=false
terraform validate # see "Schema notes that bite" — currently fails due to a confirmed upstream defect
terraform fmt -check

Pin consumption at ?ref=v1.0.0, never a branch. This is a plan-only authoring process — a human runs terraform apply from CI after review. Given the tenant-wide blast radius and the confirmed validate limitation above, require an explicit second reviewer for any change to is_disabled or allowed_tenants before a human applies it.


🧪 Testing

terraform fmt -check passes cleanly and confirms the configuration is consistently formatted. terraform validate, run standalone against this module, currently fails due to the confirmed upstream provider defect described above — this is independent of whether the configuration is correct; it reproduces for ANY variable-sourced allowed_tenants value, including the module's own defaults. Neither check exercises whether the Power Platform API will actually accept a given tenant GUID for your tenant; only a live plan/apply against a real tenant does that, and this module's authoring process is plan-only and does not perform that exercise.


💬 Example Output

$ terraform output
id = "9f8e7d6c-5b4a-3210-fedc-ba9876543210"
is_disabled = false
allowed_tenant_ids = {
 "11111111-1111-1111-1111-111111111111" = {
 "inbound" = true
 "outbound" = true
 }
 "*" = {
 "inbound" = false
 "outbound" = false
 }
}

🔍 Troubleshooting

Symptom Cause Fix
terraform validate fails with Error: Value Conversion Error... Target Type: []tenant_isolation_policy.AllowedTenantModel... Suggested Type: basetypes.SetValue Confirmed upstream provider defect (microsoft/power-platform 4.1.0): allowed_tenants is modeled with a raw Go slice type that cannot represent Terraform's "unknown" placeholder, which validate substitutes for every variable-sourced value regardless of default or -var override This is not a house authoring defect and has no HCL-only fix that preserves a reusable module. Recommended next steps: (1) file/track an upstream issue against microsoft/terraform-provider-power-platform; (2) treat this module's terraform validate gate as a known limitation in CI until the provider ships a fix; (3) real terraform plan/apply against a live tenant with concrete literal inputs from a root caller was not verified in this plan-only authoring process — confirm behavior in a controlled non-production tenant before relying on it in CI.
A terraform plan/apply in a real pipeline behaves differently than the standalone validate failure above Real root-module callers typically supply fully literal, concrete allowed_tenants values, which may follow a different internal evaluation path than this module's isolated, credential-free validate run Not independently confirmed by this authoring process (no live tenant access) — treat as an open question and verify in a sandboxed tenant before depending on it.
Adding the "*" wildcard key unexpectedly changes behavior for many external tenants at once The wildcard is a real, special provider value that sets the default posture for every unlisted tenant Treat any change to a "*" entry as equivalent in blast radius to flipping is_disabled — require the same review rigor.
is_disabled = false was applied but no restrictions appear to be in effect allowed_tenants was left empty or only contains a permissive wildcard entry Review the exact allowed_tenants map — an empty map with is_disabled = false still enforces isolation, but a permissive "*" = { inbound = true, outbound = true } entry effectively allows every tenant.

🔗 Related Docs


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

Releases

Packages

Contributors

Languages