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.
- GRINNTEC: Platform Engineering
- GRINNTEC: Tenant Root Bootstrap
- GRINNTEC: Subscription Vending
- GRINNTEC: Privileged Access, PIM, and What Belongs in CI
- CI/CD and pipeline design for Tier 0
- Microsoft Entra Workload Identity Federation
Why Service Principal Creation is Tier 0¶
Vending an Azure service principal involves three distinct operations:
- Creating the Application Registration and Service Principal in Microsoft Entra ID.
- Granting Microsoft Graph application permissions and admin consent (e.g.,
Application.ReadWrite.All,RoleManagement.ReadWrite.Directory,Directory.Read.All). - 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
mainauto-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), andterraform 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:
Readeratmg-intermediate-root,Directory Readersin Entra ID, and blob read access on the state container. - Automated
applyis permanently disabled in.gitlab-ci.yml.
2. Local Human Apply¶
- The human operator reviews and merges the MR in GitLab.
- The operator pulls
mainlocally and executesterraform applywithin their authenticatedaz loginsession. - 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:
What Preflight Does¶
To prevent partial applies, broken deployments, or lock failures mid-execution, preflight.ps1 dynamically inspects the environment:
- Extracts Backend Config Dynamically: Parses
versions.tfdirectly to identify the expected storage account, container name, and resource group without duplicating values. - Validates Azure CLI Session: Verifies that
az loginis active, the access token is unexpired, and the session matches the target tenant ID. - Verifies Data Plane Access: Confirms the operator holds
Storage Blob Data Contributoron the state backend. - Verifies Entra Roles: Queries Microsoft Graph to confirm active
Global AdministratororApplication Administrator+Privileged Role Administratorassignments. - 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:
| 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 inmg-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 useshttps://gitlab.com). - Privilege: Strictly read-only (
Readeratmg-intermediate-rootor 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_passwordor 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.