I met Simon Owen at the Norfolk Power Platform User Group recently, and while I enjoyed his talk on the Impact of AI, it was his Governance as Code work that left me with a question I couldn't put down. Could his ideas actually be built, and if so, how? I haven't seen anything from Simon on building them out yet - I'd love to see his approach - but I'm curious by habit, so I wanted to have a go.
A few months ago I wrote about setting an ALM strategy before the agents pile up. Near the end I argued for automating the setup, not just the deployment, and suggested a few PAC CLI scripts as a rough starting point. That still holds. But a script creates an environment and then forgets about it. It doesn't remember why the environment exists, it doesn't notice when someone changes a setting in the admin centre, and it can't tell you what's about to happen before it happens.
So the question was simple. If the environment strategy is the policy, how much of it could I actually write down as code - and keep honest - with Terraform?
This post is what I found. I've spent a fair bit of time on it - past the provider docs and into a working example, applied to a dev tenant. That's as far as it goes. It's a dev tenant without the premium licences or paid Dataverse capacity a real estate would have, none of it is production, and I'd still call this a thought experiment rather than a blueprint.
What Simon got me thinking about
Simon is a Microsoft MVP who writes EmPOWER Your World, and he's spent years on Power Platform governance, adoption and enablement. Three pieces of his work did most of the heavy lifting in my head afterwards.
The first is Scalable Governance (and its v2). Every use case gets sized Small, Medium, Large or X-Large, using criteria in plain business language - data risk, financial risk, business continuity, how many people it's shared with. Each size comes with its own standards, labelled FIRM (non-negotiable) or FLEX (open to discussion), and his other modules - environment strategy, DLP, security - snap onto those sizes. That's already the shape of a set of Terraform modules. It just doesn't know it yet.
The second is his AI Agent Lifecycle model. It treats agents as "products with lifecycles", not "prompts with luck": Design, Deploy, Monitor and Retire, each with its own gate and its own named human owner. He's blunt that "the platform team" is not an owner.
The third is the one that really set me off. His Governance as Code model argues that most organisations write their governance down and call it done, and that "a standard that no build step ever reads is not a control; it is a hope with a version number." He describes a ladder from tacit to documented, executable, embedded and adaptive, and two postures: advisory governance that coaches a human and fails open, and enforcing governance that gates a system and fails closed.
Simon's model is deliberately technology-neutral. It's a way of thinking, not a Terraform design, and it doesn't prescribe a repo, modules or tooling. What I wanted to know was narrower and more hands-on: could I take one layer of a Power Platform estate - the environments and the rules around them - and get it from documented to embedded, where the standard is applied at the moment something is created rather than checked afterwards?

The shape I built
The rule I started from was blunt: if a governed setting isn't in the repo, it doesn't exist. Nobody creates environments in the admin centre. A new lane for a cost centre or a solution is a pull request.
That left me with two kinds of config that matter.
Tiers. One file per size, describing what that size means - sharing limits, solution checker, backup retention, whether it fans into shared test and prod. These map straight onto Simon's sizes, plus a locked-down sandbox for learning and an exceptions tier for the things that genuinely don't fit.
Lanes. One small file per cost centre or solution, saying who owns it, who pays for it, which tier it is, and whether the access or complexity triggers from the ALM post apply.
# lanes/customer-service.yaml
lane: customer-service
cost_centre: CC-0000
tier: medium
owner: service-apps-owner@contoso.example
triggers: { access: false, complexity: false }Everything else comes from the tier. I liked that split more than I expected to. The lane file is something a business owner can read and approve in thirty seconds, and the tier file is the bit security owns. Terraform reads both folders with yamldecode, merges them, and loops over the lanes with for_each.

Then there's FIRM and FLEX. I started out wondering how you'd express Simon's distinction in code, and his own Governance as Code postures pretty much answered it for me: FIRM standards behave like enforcing governance and fail closed, while FLEX standards are advisory and fail open. That's roughly what the example does. A lane check runs on every pull request, and FIRM rules fail the run - an exception lane with a missing or past expiry date, for example. Most of those rules also sit in Terraform as preconditions, so a plan still fails closed if someone skips the script. FLEX findings, like a Small lane with the access trigger set, print a warning and let the run carry on. What it doesn't do yet is make a named approver acknowledge that warning.
Where the provider surprised me
I went in expecting to write a lot of glue. Microsoft's official Power Platform Terraform provider turned out to cover far more than I'd assumed - environments, environment groups and their rule sets, managed environment settings, data policies, tenant settings and application users are all resources in their own right. Everything below is checked against v4.2.1 of the docs.
The governance backbone from the ALM post - govern groups, not environments - maps almost one-to-one. One environment group per tier, one rule set per group, and each lane's environment created straight into the right group. Sandbox and Production lanes also get their own Entra security group, bound to the environment from the first minute. Developer lanes don't get one at all, because the provider won't take a security group alongside the owner a Developer environment needs.
# modules/environment-group (trimmed)
resource "powerplatform_environment_group" "this" {
display_name = "Tier - ${title(var.tier.tier)}"
description = "Governed by Terraform. Do not edit rules in the admin centre. ${var.tier.description}"
}
resource "powerplatform_environment_group_rule_set" "this" {
environment_group_id = powerplatform_environment_group.this.id
rules = local.rules # built from the tier's YAML file
}
# modules/lane (trimmed)
locals {
# <cost centre>-<lane>-<stage>, for example cc-0000-customer-service-dev
display_name = "${local.cost_centre_slug}-${local.name_label_slug}-${var.stage}"
}
# A Developer environment can't take a security group, so none is created
resource "azuread_group" "this" {
count = var.environment_type == "Developer" ? 0 : 1
display_name = local.display_name
security_enabled = true
mail_enabled = false
mail_nickname = local.mail_nickname
}
resource "powerplatform_environment" "this" {
display_name = local.display_name
location = var.location
environment_type = var.environment_type # Developer in the example lanes
owner_id = var.environment_type == "Developer" ? var.owner_id : null
environment_group_id = var.environment_group_id
dataverse = {
language_code = var.language_code
currency_code = var.currency_code
security_group_id = var.environment_type == "Developer" ? null : one(azuread_group.this[*].object_id)
}
lifecycle {
prevent_destroy = true
ignore_changes = [owner_id]
}
}That prevent_destroy isn't decoration. Changing location on an environment forces a destroy and recreate, and I really don't want a typo in a pull request quietly planning the deletion of a production environment.
A few other things fitted better than I expected. Environment routing - sending new makers to personal developer environments instead of the default one - lives in powerplatform_tenant_settings, and you can point it at a group so those environments are governed by construction. I'd assumed that one would need a script. The pipeline's service principal can be declared as a powerplatform_application_user in every target environment, which is the exact step I called out in the ALM post as the one that bites you. And the data policies can be declared per tier too, alongside the advanced connector policies the group rule set supports.
Not everything was that smooth, and two things only showed up once I pointed it at a tenant. I'd used a data source to list every environment, and it turned out to read Dataverse in each one. The Default environment errored, because the pipeline's service principal isn't a member there, so I took it out and built the data policy lists from the environments Terraform already manages. And when I renamed the environments, the provider threw an error even though Microsoft had returned a 204 and the rename had gone through.
The managed environment wrinkle
This is the bit that made me stop and read more carefully.
Environment groups can only contain managed environments, and the provider also has a powerplatform_managed_environment resource for sharing limits, solution checker mode and the rest. My first instinct was to use both. That turned out to be the wrong instinct.
Microsoft's environment groups documentation says that if you pick a group when creating an environment, "the environment is created as a managed environment within that group, automatically inheriting the group's rules upon creation." Published rules then lock the matching settings read-only in every member environment, while rules you haven't configured stay at the environment level. The provider's docs for powerplatform_managed_environment add the Terraform side as a known limitation: "When an environment is part of an environment group, managed environment settings do not apply." Updates won't change anything, and you get a warning in plan instead.
So in my example, the group rule set is the only source of truth for anything in a tier, and grouped environments never get a managed environment resource at all. That resource is reserved for an environment that deliberately sits outside a group. One owner per setting, or Terraform and the group will argue forever.
I'll be honest about one thing. Microsoft's docs describe choosing a group in the admin centre, and when I started I was only assuming that setting environment_group_id at creation through Terraform would behave the same way. I've since tried it. In my dev tenant, Terraform created the environments with environment_group_id already set, and the next plan came back clean. The caveat is that they were Developer environments rather than Sandbox, because that tenant has no paid Dataverse capacity, so a Sandbox created straight into a group is still on my list to try.
How change would flow
The modules are only half of it. The part I find more interesting is how change would move through the system.
A lane request comes in, and someone asks Simon's front-door question first - should this exist at all? - before sizing it. Then a pull request adds the lane file, and CI posts the terraform plan as a comment alongside a few policy checks, like "an exception must have an expiry date". Merging applies the change behind a protected gate.
Then every night the same plan runs again with -detailed-exitcode. An exit code of 2 means reality has wandered away from the repo, and the job opens an issue. Somebody either puts it back or raises a PR to make the change official. Break-glass fixes in the admin centre are still allowed - sometimes it's 6pm and something's on fire - but the drift job keeps shouting until the repo catches up.
For environments that already exist, I'd adopt rather than recreate: write the lane file, use Terraform import blocks to bring the environment under management, and keep going until the plan shows no changes before tightening anything.

Where agents fit
This is where I had to be clear with myself about the boundary.
Terraform is good at the shape of the estate: which environments exist, what rules they follow, who can get in. It isn't where agents should be governed day to day, and it shouldn't be how solutions get deployed either - that's what ALM pipelines are for.
For the agents themselves, the governance layer I'd point to is Microsoft Agent 365. Its agent registry in the Microsoft 365 admin centre gives you an inventory of agents across the tenant, including Copilot Studio agents, a view of agents without owners, and management rules - with observability and deeper controls through Entra, Purview and Defender depending on licensing. That lines up neatly with the Monitor and Retire stages of Simon's lifecycle.
So the split I landed on is this. Terraform owns the Deploy-stage plumbing - the agent lands in an environment whose group matches its risk size, behind the right data policy. Agent 365 owns the agent once it exists. And CI connects the two without trying to own both: the registry is exposed through Microsoft Graph (in preview at the time of writing), so a scheduled job could flag any agent in a governed lane with no owner. Report, don't manage, at least until those APIs settle.
There's also a more speculative idea I keep coming back to: an intake agent that asks Simon's sizing questions, drafts the lane file and opens the pull request, with a human still reading the plan and approving the merge. The agent does the paperwork. The people make the call.

What I'm still unsure of
Plenty, which is partly why I wanted to write this down.
Gaps in the provider. I couldn't find a resource for Power Platform Pipelines. On reflection, I think that's the right line anyway. Moving solutions between environments belongs to makers and their ALM pipelines, not to the thing that shapes the estate. What I think is worth Terraform owning is the setup around them: the environments they deploy into, and the pipeline's application user in each one, which the example already declares. I also couldn't find a way to allocate prepaid Copilot Credits to an environment - powerplatform_billing_policy covers pay-as-you-go through an Azure subscription, not capacity allocation. Given how much weight the ALM post puts on credits per cost centre, that one stings.
Preview features. Developer environments, the Copilot Studio Application Insights resource and RBAC role assignment are all marked preview in the provider. If you're putting governance into code, it's worth being honest about what's GA and what's preview, and that applies here too.
The settings nobody owns. Microsoft's docs say settings without a published group rule stay at the environment level, but the provider won't apply managed environment settings while an environment is in a group. The rule I've landed on is that every setting gets exactly one home. If a whole tier needs it, it's a group rule. If only one environment needs it, that environment doesn't really belong in the tier, so it moves to the exceptions tier outside a group, with an owner and an expiry date, and that's the one place the managed environment resource is allowed.
Settings that live elsewhere. Dataverse settings such as auditing are a different thing again, with their own powerplatform_environment_settings resource. What's left is a setting the group rule set can't express yet. In v4.2.1 the group's sharing rule covers canvas apps, while sharing limits for agents and solution-aware cloud flows only exist on the managed environment resource. Until that changes, I'd rather call it out as a known gap than pretend Terraform owns it.
The admin identity. Something has to hold tenant-wide rights to apply all this. Best practice is OIDC, separate identities for plan and apply, and state split per layer. The example is a demo and uses one identity for both, so that's the first thing I'd change before going anywhere near production. Even done properly, it's still a high-value identity.
People. If the pull request is slower than clicking, people will click. The governed path has to be the easy path, which is why sandbox routing and a locked-down default environment matter as much as any module. I can imagine this getting to a point where most of it is automated, but that's just a thought, and my repo is essentially a demo. For now, Terraform can enforce the floor, but it can't decide whether an agent should exist.
State at scale. I ended up giving each cost centre its own Terraform state file, alongside separate state for the tenant settings and the groups, and one shared state for the fan-in test and production environments and the tier data policies, all kept in Azure blob storage. The upside is blast radius: a plan for one cost centre can't refresh another's environments, and the cost centre plans run in parallel. What I can't tell you is how that holds up with dozens of cost centres rather than the one in my example. Every new state file is another plan and apply in the pipeline, and the shared state has to read all of them, so plan and apply times are something you'd need to measure at scale before trusting the design.

Read it, don't ship it
If you want to see what this looks like as real code, it's on GitHub at harrytraynor/pp-governance-terraform. It's the example code for this post, built on Microsoft's official Power Platform provider. Tiers and lanes live in YAML files, there are modules for the environment groups, lanes and data policies, each cost centre has its own state, and GitHub Actions plans every pull request, applies after a single approval and checks for drift every night.
It's an example to learn from, not something that's been hardened for production, and the README is upfront about that. Clone it, plan it against a dev tenant, and read the plan before you apply anything.
Worth the experiment
None of the individual pieces here are new. Environment groups, data policies, security groups and Terraform pipelines all exist today. What surprised me was how little glue it would take to make the environment strategy the only way the estate can actually change, and how clearly the gaps show up once you try.
If you've already tried codifying Power Platform governance, especially the credit allocation and pipeline gaps, I'd love to hear how you got round them. And if you haven't read Simon's work yet, start with Scalable Governance v2, then Governance as Code.
A standard nobody's build step reads is still just a hope. I'd rather find out what it takes to make it a pull request.