Enterprise Policy as Code (EPAC) — Multi-tenant management

Enterprise Policy as Code (EPAC) — Multi-tenant management

· 7 min read

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 pacSelector in global-settings.json. EPAC is specifically built for this.

Key concepts

TermMeaning
pacSelectorA unique name for a managed environment (tenant). Acts as the identifier EPAC uses to determine which files and scopes apply during a deployment run.
deploymentRootScopeThe 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.
pacEnvironmentFilterA 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

FolderPurpose
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.

FolderFilteringExplanation
policyAssignments/Explicit via pacEnvironmentFilterYou specify which pacSelector(s) receive an assignment. EPAC skips the file if the active selector is not listed.
policyExemptions/Implicit via scope + policyAssignmentIdNo 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/NoneStandard Azure ARM format without EPAC extensions. All definitions go to the deploymentRootScope of the active pacSelector, regardless of which customer that is.
policySetDefinitions/NoneSame as policyDefinitions/ — standard ARM format, no filtering possible.
policyDocumentations/Explicit via pacEnvironmentEach documentation file references a specific pacEnvironment directly. This makes each file inherently tied to one customer.

Schema overview

FolderSchema URL
policyAssignments/https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-assignment-schema.json
policyDefinitions/https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-definition-schema.json
policySetDefinitions/https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-set-definition-schema.json
policyExemptions/https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-exemption-schema.json
policyDocumentations/https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/policy-documentation-schema.json
global-settings.jsonchttps://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/global-settings-schema.json

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 pacEnvironmentFilter in assignment files to target the right customer.
  • Copy and adjust the pipeline — only the pacEnvironmentSelector and 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.