Enterprise Policy as Code (EPAC) — Multi-tenant management
In the previous article Enterprise Policy as Code (EPAC) - Introduction & Setup I showed how to set up a new EPAC environment.
In this article we assume that multiple customers are managed from a single management environment.
Alongside my own environment — we call this cloudmartinpronk — I want to add customer-a and customer-b. How do you do that?
The core principle: each customer = one
pacSelectoringlobal-settings.json. EPAC is specifically built for this.
Key concepts
| Term | Meaning |
|---|---|
pacSelector | A unique name for a managed environment (tenant). Acts as the identifier EPAC uses to determine which files and scopes apply during a deployment run. |
deploymentRootScope | The Azure management group that serves as the root for all policy deployments in a given environment. EPAC deploys definitions and assignments at or below this scope. |
pacEnvironmentFilter | A property in assignment and documentation files that restricts which pacSelector(s) a file applies to. If the active selector is not listed, EPAC skips the file entirely. |
1. global-settings.jsonc — one entry per customer
{
"pacEnvironments": [
{
"pacSelector": "cloudmartinpronk", // your own tenant
"tenantId": "cccccccc-...",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/abcd5e37b-..."
},
{
"pacSelector": "customer-a", // customer A
"tenantId": "aaaaaaaa-...",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/efgh6f48c-..."
},
{
"pacSelector": "customer-b", // customer B
"tenantId": "bbbbbbbb-...",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/fghi7g59d-..."
}
]
}
2. Folder structure — subfolders per customer
EPAC reads all files recursively. You control which customer receives a file via pacEnvironmentFilter.
Definitions/
global-settings.jsonc
│
├── policyAssignments/
│ ├── _shared/
│ │ └── require-tags.jsonc ← tagging baseline for all customers
│ ├── cloudmartinpronk/
│ │ └── cis-compliance.jsonc ← CIS benchmark
│ ├── customer-a/
│ │ └── iso27001-compliance.jsonc ← ISO 27001
│ └── customer-b/
│ └── nist-compliance.jsonc ← NIST SP 800-53
│
├── policyDefinitions/
│ └── _shared/ ← Definitions for all customers
│
├── policySetDefinitions/
│ └── _shared/ ← Set definitions for all customers
│
├── policyExemptions/
│ ├── _shared/
│ ├── cloudmartinpronk/ ← Exemptions per customer
│ ├── customer-a/
│ └── customer-b/
│
└── policyDocumentations/
├── cloudmartinpronk/ ← Documentation per customer
├── customer-a/
└── customer-b/
Example: Definitions/policyAssignments/customer-a/iso27001-compliance.jsonc
{
// ISO 27001:2013 compliance assignment — for Customer A only.
// pacEnvironmentFilter ensures this file is ignored for all other
// pacSelectors (customer-b, cloudmartinpronk, etc.).
"$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-assignment-schema.json",
"nodeName": "/customer-a/",
"pacEnvironmentFilter": [
"customer-a"
],
"assignment": {
"name": "customer-a-iso27001",
"displayName": "[Customer A] ISO 27001:2013",
"description": "Assign the ISO 27001:2013 initiative to Customer A's management group."
},
"definitionEntry": {
// Built-in Azure initiative: ISO 27001:2013
"policySetId": "/providers/Microsoft.Authorization/policySetDefinitions/89c6cddc-1c73-4ac1-b19c-54d1a15a42f2"
},
"enforcementMode": "Default",
"scope": {
"customer-a": [
"/providers/Microsoft.Management/managementGroups/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
]
}
}
Example: _shared/require-tags.jsonc
{
// Baseline tagging policy that applies to ALL customers.
// EPAC deploys this to every pacEnvironment listed under 'scope' below.
"$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-assignment-schema.json",
"nodeName": "/shared/",
"assignment": {
"name": "require-env-tag",
"displayName": "Require Environment tag on resource groups",
"description": "Enforces an 'Environment' tag on all resource groups."
},
"definitionEntry": {
// Built-in Azure policy: Require a tag on resource groups
"policyId": "/providers/Microsoft.Authorization/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025"
},
"parameters": {
"tagName": "Environment"
},
"enforcementMode": "Default",
"scope": {
// Per pacSelector you specify the root scope for that customer.
// EPAC automatically selects the correct scope based on the active pacEnvironmentSelector.
"cloudmartinpronk": [
"/providers/Microsoft.Management/managementGroups/cccccccc-cccc-cccc-cccc-cccccccccccc"
],
"customer-a": [
"/providers/Microsoft.Management/managementGroups/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
],
"customer-b": [
"/providers/Microsoft.Management/managementGroups/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
]
}
}
3. Pipelines — one pipeline per customer
Copy your current epac-prod-pipeline.yml to e.g. epac-customer-a-prod-pipeline.yml and only change the pacEnvironmentSelector and the serviceConnection(s).
EPAC folder structure — purpose and usage
EPAC uses a fixed folder structure under Definitions/ where each folder has a specific responsibility. Understanding this distinction is important: misplaced files will be ignored or processed incorrectly by EPAC.
policyAssignments/
Links an existing policy or initiative to an Azure scope (management group, subscription). This is the core of EPAC — here you define who gets which rules and where they apply. Without an assignment, a policy definition has no effect.
The require-tags.jsonc is a good example of this: it assigns a built-in Azure policy to three customers, each on their own scope.
policyDefinitions/
Contains custom-written policy rules — the if/then logic that Azure does not provide out of the box. Azure has hundreds of built-in policies, but sometimes a customer has a specific requirement that is not covered. For example: “VMs may only run in the West Europe region” or “Storage accounts may not have public access except for specific accounts.” That logic is written here.
policySetDefinitions/
Bundles multiple policies (both built-in and custom) into one assignable package — also known as an initiative. Instead of assigning ten individual policies separately, you create one initiative and assign that. Useful if you want to build a custom “customer baseline” that is a mix of Azure built-in and custom policies.
policyExemptions/
Records exceptions to existing policy assignments. In practice there are always resources that need to be temporarily or permanently exempted from a policy — for example a legacy system that cannot meet a specific CIS requirement. With EPAC you manage those exceptions as code, making them traceable and auditable in git.
policyDocumentations/
Configuration for automatically generating compliance reports. Based on your assignments, EPAC can generate an overview of which policies are active, what their effect is, and how they map to frameworks such as ISO, CIS or NIST. Useful for customer reports and audits — without having to maintain this manually.
Summary
| Folder | Purpose |
|---|---|
policyAssignments/ | Who gets which policy and where |
policyDefinitions/ | Custom policy rules not available in Azure |
policySetDefinitions/ | Bundle of multiple policies as a single package |
policyExemptions/ | Policy exceptions as code |
policyDocumentations/ | Automated compliance reporting |
Filtering per folder type
policyAssignments are deployed to the correct customer via the pacSelector.
| Folder | Filtering | Explanation |
|---|---|---|
policyAssignments/ | Explicit via pacEnvironmentFilter | You specify which pacSelector(s) receive an assignment. EPAC skips the file if the active selector is not listed. |
policyExemptions/ | Implicit via scope + policyAssignmentId | No filter property, but the scope and assignment ID reference a specific tenant. In another tenant they simply do not exist, so the exemption is not applied there. |
policyDefinitions/ | None | Standard Azure ARM format without EPAC extensions. All definitions go to the deploymentRootScope of the active pacSelector, regardless of which customer that is. |
policySetDefinitions/ | None | Same as policyDefinitions/ — standard ARM format, no filtering possible. |
policyDocumentations/ | Explicit via pacEnvironment | Each documentation file references a specific pacEnvironment directly. This makes each file inherently tied to one customer. |
Schema overview
Conclusion
Managing multiple tenants from a single EPAC environment is less complex than it might initially seem. The design is built around one simple rule: one pacSelector per customer.
Everything else follows from that:
- Add the customer to
global-settings.jsonc— that’s the only place you register a new tenant. - Use
_shared/for policies that apply to everyone, and customer-specific subfolders for policies that don’t. - Use
pacEnvironmentFilterin assignment files to target the right customer. - Copy and adjust the pipeline — only the
pacEnvironmentSelectorand service connection change.
The folder structure may look extensive at first glance, but each folder has a single, well-defined responsibility. Once you understand that assignments link policies to scopes, definitions contain the logic, and exemptions handle the exceptions — the rest falls into place naturally.
The result is a fully auditable, git-managed setup where onboarding a new customer is a matter of adding a few lines of configuration rather than rebuilding anything from scratch.