Skip to content

Service Principal Vending

Service principal vending is the centralized engine for declaring and provisioning machine identities (service principals) across the Azure platform estate. It operates strictly under Lane A — automated CI plans only, while apply is gated behind an elevated human operator with preflight verification — ensuring that no machine identity ever holds standing privileges to consent to directory roles or escalate access.


Why Service Principal Creation is Tier 0

Vending an Azure service principal involves three distinct operations:

  1. Creating the Application Registration and Service Principal in Microsoft Entra ID.
  2. Granting Microsoft Graph application permissions and admin consent (e.g., Application.ReadWrite.All, RoleManagement.ReadWrite.Directory, Directory.Read.All).
  3. Assigning Azure RBAC roles at management group or subscription scopes, and provisioning remote state backend containers.

Under Microsoft's Enterprise Access Model (EAM) and NIST SP 800-53 Rev. 5 (AC-6, AC-5), any identity that can create credentials, grant admin consent, or assign privileged roles sits firmly in the Control plane (Tier 0).

If a CI runner held standing credentials to execute these actions: - Any engineer with merge rights to the repository could vend a service principal with Global Administrator or root Owner. - A compromised runner or stolen pipeline token would yield full tenant compromise. - Separation of duties (NIST AC-5) and dynamic privilege management (NIST AC-2(6)) would be broken.

To eliminate standing machine elevation, the azure-service-principals repository implements Lane A.


Operating Model: Lane A (Zero Standing Elevation)

The estate operates on a dual-lane delivery model:

  • Lane A (Tier 0 / Sensitive): CI plans only using a scoped, read-only identity. Automated CI apply is permanently disabled. Apply is executed locally by an authorized, PIM-elevated human operator.
  • Lane B (Tier 1/2 / Bounded Workloads): Standard GitOps pipeline. Merge request reviews plan; merge to main auto-applies via a scoped OIDC workload identity.
flowchart TD
    subgraph CI["GitLab CI (Automated Verification)"]
        MR["Merge Request / Commit"] --> FMT["fmt & tflint"]
        FMT --> VAL["validate & checkov"]
        VAL --> PLAN["terraform plan<br/>(sp-pla-tf-service-principals-ro)"]
        PLAN --> POST["Post Plan & Review in MR"]
    end

    subgraph Gate["Human Gate & Elevation"]
        POST --> APPROVE["MR Approval & Merge to main"]
        APPROVE --> PIM["Operator PIM Elevation<br/>(Time-bound, logged)"]
    end

    subgraph Local["Local Execution (Authorized Operator)"]
        PIM --> PRE["pwsh -File ./preflight.ps1<br/>(Tripartite checks)"]
        PRE --> APPLY["terraform apply<br/>(az login session)"]
        APPLY --> LIVE["Live Service Principal & State Vended"]
    end

1. Automated CI is Plan-Only

  • GitLab CI executes static analysis (fmt, tflint, validate), security scanning (checkov), and terraform plan.
  • Authentication uses Workload Identity Federation (OIDC) via a dedicated read-only identity: sp-pla-tf-service-principals-ro.
  • This identity holds strictly read-only access: Reader at mg-intermediate-root, Directory Readers in Entra ID, and blob read access on the state container.
  • Automated apply is permanently disabled in .gitlab-ci.yml.

2. Local Human Apply

  • The human operator reviews and merges the MR in GitLab.
  • The operator pulls main locally and executes terraform apply within their authenticated az login session.
  • Microsoft Graph application permissions and Azure RBAC assignments are consented directly under the operator's transient authority.

Operator Prerequisites (Three Control Planes)

Executing terraform apply in azure-service-principals requires active assignments across three distinct security boundaries:

Control Plane Required Role Purpose
Microsoft Entra ID Global Administrator OR (Application Administrator + Privileged Role Administrator) Create app registrations, grant directory roles, and consent Graph application permissions.
Azure Management Plane Role Based Access Control Administrator (or Owner) scoped to mg-intermediate-root Assign Azure RBAC roles to the vended service principals across management groups.
Azure Data Plane Storage Blob Data Contributor on sa<tenant>tenantroot Read and write Terraform state in the remote container (use_azuread_auth = true).

All privileges must be activated just-in-time via Privileged Identity Management (PIM) with justification, approval, and audit logging.


Preflight Verification Script (preflight.ps1)

Before executing any Terraform commands locally, the operator runs:

pwsh -File ./preflight.ps1

What Preflight Does

To prevent partial applies, broken deployments, or lock failures mid-execution, preflight.ps1 dynamically inspects the environment:

  1. Extracts Backend Config Dynamically: Parses versions.tf directly to identify the expected storage account, container name, and resource group without duplicating values.
  2. Validates Azure CLI Session: Verifies that az login is active, the access token is unexpired, and the session matches the target tenant ID.
  3. Verifies Data Plane Access: Confirms the operator holds Storage Blob Data Contributor on the state backend.
  4. Verifies Entra Roles: Queries Microsoft Graph to confirm active Global Administrator or Application Administrator + Privileged Role Administrator assignments.
  5. Verifies Management Plane RBAC: Confirms RBAC authorization at mg-intermediate-root.

If any check fails, the script halts with an explicit error and remediation instructions, ensuring the operator never attempts a partial terraform apply.


Identity Taxonomy and Naming Conventions

Each service principal is declared in its own file in azure-service-principals following this standard:

sp-<domain>-<class>-<name>-<access>.tf
Component Allowed Values Description
domain pla, wl pla = platform-level shared service; wl = workload-specific.
class tf, tool, vendor Classification of the machine identity (see below).
name kebab-case Matches the consuming repository slug, tool name, or provider.
access ro, rw ro = read-only (e.g. audit/discovery tools, plan-only CI); rw = read-write.

Classification Classes

1. Platform Infrastructure Pipelines (class = "tf")

  • Purpose: CI/CD identities running Terraform for platform deployments (monitoring, networking, policy, governance).
  • OIDC Audience: Defaults to fic_audience = "https://gitlab.com".
  • State Isolation: Includes dedicated remote state container provisioning via state_rbac.
  • Examples:
  • sp-pla-tf-monitoring-rw: Deploys platform monitoring resources in mg-management.
  • sp-pla-tf-conditional-access-rw: Deploys Conditional Access policies.
  • sp-pla-tf-mcsb-overrides-rw: Manages Microsoft Cloud Security Benchmark exemptions.

2. Tool & Reporting Pipelines (class = "tool")

  • Purpose: Automated inspection, security scanning, and reporting tools requiring tenant-wide or management-group visibility.
  • OIDC Audience: Defaults to fic_audience = "api://AzureADTokenExchange" for Azure CLI / Azure PowerShell scripts (except EPAC CI which uses https://gitlab.com).
  • Privilege: Strictly read-only (Reader at mg-intermediate-root or Tenant Root).
  • Examples:
  • sp-pla-tool-azgovviz-ro: Azure Governance Visualizer reporting.
  • sp-pla-tool-epac-plan-ro: Enterprise Policy as Code plan inspection.
  • sp-pla-tool-rbac-extract-ro: RBAC baseline discovery scripts.
  • sp-pla-tool-entra-roles-ro: Entra directory role audit runner.

3. Third-Party Integrations (class = "vendor")

  • Purpose: Keyless SaaS integrations for external security posture, compliance, or operational platforms.
  • Authentication: Workload Identity Federation (OIDC) anchored to the vendor's external issuer. Zero client secrets or certificates.

Architecture: Module Composition

Each service principal is instantiated via solution-manage-azure-service-principals:

module "sp_pla_tool_azgovviz_ro" {
  source = "git::https://gitlab.com/grinntec-cloud/terraform-solutions/solution-manage-azure-service-principals.git?ref=v4.3.0"

  name        = "azgovviz"
  domain      = "pla"
  class       = "tool"
  access      = "ro"
  description = "Read-only identity for Azure Governance Visualizer discovery and reporting"

  # Workload Identity Federation (OIDC)
  gitlab_project_path = "grinntec-cloud/tooling/azgovviz"
  fic_audience        = "api://AzureADTokenExchange"

  # Azure RBAC Grants
  role_assignments = [
    {
      scope     = data.azurerm_management_group.intermediate_root.id
      role_name = "Reader"
    }
  ]

  # Optional backend state container provisioning
  state_rbac = null
}
graph LR
    SUB["sp-*.tf in azure-service-principals"] --> SOL["solution-manage-azure-service-principals (Tier 2)"]
    SOL --> SP["terraform-azuread-service-principal (Tier 1)"]
    SOL --> FIC["azuread_application_federated_identity_credential (OIDC)"]
    SOL --> RBAC["azurerm_role_assignment (Scoped RBAC)"]
    SOL --> CONT["azurerm_storage_container (Isolated Backend State)"]

Zero Secrets Guarantee

The platform maintains a strict zero-credential policy across all machine identities:

  • No Passwords or Client Secrets: No azuread_application_password or client secrets are ever generated or stored in CI/CD variables.
  • No Certificates: No static certificate credentials with rotation deadlines.
  • Subject-Bound OIDC: Every federated credential is tied strictly to the GitLab project path (project_path:<org>/<repo>) or vendor token subject claim. A compromised token from one repository cannot authenticate against another identity.