Skip to content

Platform Service Principal Vending

Platform service principal vending is the central process for declaring and creating the machine identities (service principals) that the cloud platform team owns and uses to run its own tooling. Every identity is a small Terraform block in azure-service-principals-platform. CI plans it as a read-only identity and applies it, from main only, as a write identity whose reach is limited by code checks and an Azure condition.

Azure Access Management

Lane A and Lane B Delivery Methodology

Access Tiering Model: EAM, NIST and Tier 0/1/2

Hands-on: Create a Service Principal

Microsoft Learn: Workload identity federation · Azure ABAC conditions for role assignments

Scope: platform machine identities only

This page covers platform machine identities: Tier 0 and Tier 1 service principals owned by the cloud platform team for platform tooling. Examples include Terraform pipelines such as azure-monitoring, and governance tools such as the AzGovViz scanner and analyzer.

It does not cover machine identities that workload teams create for their own applications (Tier 2). Those live in azure-service-principals-workload and follow a lighter model, summarised in Workload machine identities (Lane B). Managed identities belong with their resource, and vendor apps that need a secret are out of scope.

Replacing t0-azure-service-principals

Platform identities used to be vended by t0-azure-service-principals, applied by a person under Lane A. Its identities are moving to azure-service-principals-platform one at a time (azure-monitoring was first). Until the move is done, both projects exist.


Why vend platform service principals?

Service principals are identities too. If they are created by hand, you soon lose track of them: nobody knows who owns an app registration, what it can access, or whether anything still uses it. Platform identities usually hold broad, tenant-wide or management-group access, so this matters most here. Vending means every platform machine identity comes from one controlled, repeatable process, so the tenant does not fill up with unmanaged, highly privileged ones.

Vending gives the platform:

  • One source of truth: Every platform service principal is declared in code in azure-service-principals-platform, one file per system (pla-<class>-<name>.tf). The repository is the inventory. If an identity is not in code, it should not exist.
  • Consistent naming and ownership: Every identity is named sp-pla-<class>-<name>-<ro|rw> and tagged with its owner, purpose, repo and tier. Its notes name the project that made it.
  • Least privilege by default: Every permission is declared next to the identity and reviewed in the MR. Owner is never allowed, and read-only (ro) and read-write (rw) identities are always separate.
  • No long-lived application credentials: Sign-in is GitLab OIDC only. The module has no input for a secret or certificate, and check-plan refuses any password on an app. There is nothing to store, leak or rotate.
  • The tier is worked out, not typed: The module sets each identity's tier from what it asks for, and puts it on the Tier tag. Nobody can label a Tier 0 identity as Tier 1 by mistake.
  • Auditability and change control: Every create, change or removal goes through a merge request, with a reviewer, a plan and a git history. This gives traceable evidence for NIST AC-2 (Account Management) and AC-6 (Least Privilege).
  • Isolated blast radius: Each pipeline or tool gets its own identities and, for Terraform, its own state container, rather than sharing a single powerful service principal.
  • Faster, standard onboarding: A new platform tool gets its identity from a small, standard Terraform block and a merge, not a ticket and a portal session.

What permissions are required to manage service principals?

A service principal can hold permissions in three separate systems, and it can hold them in any combination at the same time:

  • Microsoft Entra ID directory roles (for example, Directory Readers) control what it can do in the directory.
  • Microsoft Graph application permissions (for example, Directory.Read.All) control which Graph APIs it can call.
  • Azure RBAC roles (for example, Reader at a management group) control what it can do to Azure resources.

For example, sp-pla-tf-service-principals-platform-ro holds Directory Readers in Entra ID and Reader at the tenant root management group.

Entra ID and Azure RBAC are fully separate: a directory role gives no rights on subscriptions, and an RBAC role gives no rights in the directory. The one bridge is that a Global Administrator can elevate access to get User Access Administrator at the root scope. Entra directory roles and Graph application permissions overlap more, because both authorise Graph calls. Treat them as separate grants and review them together.

Managing platform service principals needs permissions in the same three systems. The tables below list the least-privileged option for each operation. They apply to whoever does the work, whether that is a person or a pipeline running as a service principal.

Entra ID and Microsoft Graph

Terraform's azuread provider does this work by calling the Microsoft Graph API. The caller can be authorised in either of two ways:

  • An Entra ID directory role, which can be assigned to a person or to a service principal.
  • A Graph application permission, which is granted to a service principal through admin consent.
Operation Least-privileged Entra ID role Approximate Graph application-permission alternative Risk
Read apps, service principals and role assignments (terraform plan) Directory Readers Directory.Read.All Low. It gives read access only.
Create an app registration and service principal Application Developer Application.ReadWrite.OwnedBy Medium. It can manage only the apps it owns.
Manage any app registration, service principal or federated credential Cloud Application Administrator Application.ReadWrite.All High. It can add a credential to a more privileged app, then sign in as that app.
Grant admin consent for Microsoft Graph application permissions Privileged Role Administrator AppRoleAssignment.ReadWrite.All Critical. It can grant itself any Graph permission.
Assign Entra ID directory roles Privileged Role Administrator RoleManagement.ReadWrite.Directory Critical. It can assign Global Administrator.

Note

Entra directory roles and Microsoft Graph application permissions are separate authorization mechanisms and are not always interchangeable. The exact permissions required must be validated against the operations performed by the provider.

Note

Cloud Application Administrator and Application Administrator can consent to delegated permissions and to non-Graph application permissions, but not to Microsoft Graph application permissions. That needs Privileged Role Administrator.

Azure RBAC

Assigning Azure roles to a vended service principal needs Microsoft.Authorization/roleAssignments/write at the target scope:

Least-privileged role Notes Risk
Role Based Access Control Administrator Can manage role assignments only. It can be limited with conditions (for example, any role except Owner). High, or Critical without conditions.
User Access Administrator Can assign any role, with no conditions. Critical at management-group scope.
Owner Full control plus role assignment. Avoid it for this purpose. Critical.

Creating the state container for a vended identity is a control-plane write (Microsoft.Storage/storageAccounts/blobServices/containers/write). It does not need data access or the account keys. Storage Account Contributor can read the keys, and with them every state file in the account, so it is not used.

What this means

Only the read row is low risk. Every write operation that vending needs is rated High or Critical. Even at least privilege, the identity that creates service principals, consents to Graph permissions and assigns roles can give itself, or any identity it creates, full tenant control. Least privilege shrinks the set of roles, but it cannot make vending low-risk.

So the design question is which of these operations a pipeline does, and what stops it doing more. The next sections answer it.


How vending works

azure-service-principals-platform signs in as its own pair of identities, which it also manages:

Identity Signs in from Used by Holds
sp-pla-tf-service-principals-platform-ro Any branch of this project MR and main plans Directory Readers, Reader at the tenant root, read on its state container
sp-pla-tf-service-principals-platform-rw main of this project only The apply job (press play) App vending (custom Entra role), conditioned Role Based Access Control Administrator at the tenant root, and Terraform State Container Creator on the state account
flowchart LR
    dev["Engineer adds<br/>pla-&lt;class&gt;-&lt;name&gt;.tf"] --> mr["Merge request"]
    mr --> plan["plan<br/>(as -ro)"]
    plan --> checks["check-plan (blocks)<br/>admin-consent / entra-roles (list)"]
    checks --> review["Review + merge"]
    review --> mainplan["main plan<br/>(as -ro)"]
    mainplan --> apply["apply: press play<br/>(as -rw, main only)"]
    apply --> entra["App registration,<br/>federated credential,<br/>Azure roles,<br/>state container"]
    apply -.-> human["A person grants<br/>Graph consent and<br/>Entra roles"]

Each vended identity gets one federated credential that trusts one GitLab project by its project ID, not its path:

  • An rw identity trusts exactly project_id:<id>:ref_type:branch:ref:main. Only main can sign in as it.
  • An ro identity trusts any branch of that project, and also checks the project's group.

So the consuming project must send its project ID in its token (ci_id_token_sub_claim_components = ["project_id", "ref_type", "ref"]). Renaming or moving a project no longer breaks sign-in.


What stops the write identity doing more

-rw can create apps and grant Azure roles, so it is the identity that matters. Four layers limit it. The first three are code, checked before merge. The last is Azure, checked at apply.

flowchart TB
    a["1. Module hard limits<br/>(no Owner, ro only reads, OIDC only, Tier 0 needs a reason)"]
    b["2. sp_policy in the tfvars<br/>(allowed Graph permissions, directory roles,<br/>Azure roles and scopes)"]
    c["3. check-plan CI job<br/>(re-checks every role assignment in the saved plan)"]
    d["4. Azure ABAC condition on -rw<br/>(never Owner, never to itself)"]
    a --> b --> c --> d

1. The module's hard limits

solution-manage-azure-service-principals-platform has rules no caller can loosen:

  • Sign-in is GitLab OIDC on the project ID only. There is no input for a password or certificate. Single tenant, always.
  • ro can only read: read Graph permissions, read directory roles, Azure roles of kind read, and Storage Blob Data Reader on state.
  • Owner is never allowed.
  • Anything not in the policy is refused.
  • Anything Tier 0 needs a reason of 10 or more characters (reasons).

2. sp_policy

sp_policy in environments/<env>.tfvars is the one list of what any identity may ask for: read Graph permissions, narrow Graph writes (Application.ReadWrite.OwnedBy, Group.Create), Tier 0 directory roles (App vending) and the Azure roles with the scopes, as regexes, they are allowed at. For example, Contributor is only allowed on rg-monitoring-prod and rg-azgovviz-hosting-prod, and User Access Administrator only on rg-monitoring-prod.

Access roles (User Access Administrator, Role Based Access Control Administrator) are kind access. Every one must carry a condition naming the roles it may grant (constrain_roles). Changing the list loosens or tightens what CI will apply, so it needs senior review.

3. check-plan

The check-plan CI job reads the saved plan and refuses, among others:

  • Owner, or any Azure role or scope not in the plan's own sp_policy output
  • a write role on any identity whose module ends -ro
  • anything granted to the write identity itself
  • any password on an app, and any Graph consent or Entra role assignment resource
  • a sign-in trust that isn't https://gitlab.com matching on project_id:
  • an app owner who isn't in app_owner_ids (or -rw)
  • a scope or owner that isn't known at plan time, so can't be checked

It also flags any app or service principal the plan will destroy.

4. The Azure condition

scripts/grant-write-access.ps1 gives -rw its standing access, once per environment, outside Terraform. Its Role Based Access Control Administrator at the tenant root carries a condition that Azure enforces: it may add or remove any role except Owner, and never for itself. This is the backstop if the code checks are bypassed.

What a person still does

Two grants are Tier 0 acts, so no pipeline identity holds the rights to make them:

  • Graph admin consent needs Privileged Role Administrator or AppRoleAssignment.ReadWrite.All. The module only requests Graph permissions.
  • Entra directory roles need Privileged Role Administrator, which can make anything Tier 0. The module only records them as tags.

The admin-consent and entra-roles jobs compare the plan with what is live in Entra, and list what a person must grant or remove. They change nothing. When there is something to do, they show amber on the pipeline, not red.


Why CI applies

Platform identity vending used to be Lane A: CI planned and a person applied with a PIM-activated role. azure-service-principals-platform uses the split-identity pattern instead: a read-only identity for every plan, and a write identity that only main can use. The project's README gives the reason as speed of vending, and lists what that costs.

This doesn't make vending low-risk. It moves the controls from "a person must be present" to "code limits what CI can do, and Azure limits the rest". The attack paths below still apply, and the controls above are what narrow each one:

Attack path What narrows it here
Malicious or careless merge sp_policy, the module and check-plan refuse anything outside the list. The plan and the three check jobs post to the MR.
Unmerged branch Only -ro signs in from a branch, and it only reads. -rw's credential and its ARM_CLIENT_ID (a protected variable) are main only.
Pipeline tampering or supply chain ci-templates is pinned to a commit. Modules are pinned to a version. The Azure condition still blocks Owner and self-grants.
Persistence through app credentials -rw holds App vending, not Application.ReadWrite.All, and check-plan refuses passwords. But -rw owns every app it vends (see below).
Escalation through consent or Entra roles CI can't do either. A person grants them, listed by admin-consent and entra-roles.

Accepted risks

These come from the project's README, and are accepted on purpose:

  • -rw is Tier 0 in effect. It has standing access and owns every app it vends. The gate is main: no direct pushes, and the pipeline must pass to merge.
  • Azure only stops Owner and self-grants. Which other roles and scopes -rw may grant is checked at MR time (sp_policy, the module and check-plan), so the role list lives in one place.
  • -rw can grant access roles (User Access Administrator, Role Based Access Control Administrator). Only code makes them narrow, so a bad merge could grant one that Azure wouldn't stop. Review by 2026-12-08. To lock down, add them back to $Privileged in scripts/grant-write-access.ps1 and move those grants elsewhere.
  • GITLAB_TOKEN (group level) is a personal token with api scope, and it reaches MR jobs. Here it only posts MR notes. The fix is a Reporter-only bot token set as a project variable of the same name.

The full list of roles a CI identity must never hold is in Prohibited CI Permissions.

Bootstrap

The project creates its own identities and its own state container, so the first apply can't come from CI. A person applies once per environment, locally on local state, then moves the state into the container it created, runs scripts/grant-write-access.ps1, gives -ro Directory Readers, and sets the project's CI/CD variables. After that, every change goes through an MR and main's pipeline.


Workload machine identities (Lane B)

Workload teams create machine identities much more often than the platform team does. Every app, function or pipeline may need one. Putting all of them through the platform project would make the platform team a bottleneck, and it is not necessary.

Platform identities need tenant-wide permissions: Entra ID writes, Graph consent and management-group role assignments. Most workload identities need none of these. When an identity can be created and granted access entirely inside the workload's own subscription, the worst case of a compromised pipeline is that one subscription, which the workload pipeline already controls. That risk can be managed with guardrails, so the identity can be created by the workload's own pipeline in Lane B.

Platform or workload?

The deciding factor is what the identity needs, not who asks for it:

An identity is a platform identity if it needs any of these An identity can be a workload identity if it meets all of these
An Entra ID app registration or service principal It is a managed identity. Use a system-assigned identity when the Azure resource itself consumes the identity. Use a user-assigned managed identity when an external workload requires a reusable identity or supported federated credential.
Microsoft Graph permissions or Entra ID directory roles It needs no Graph permissions and no directory roles
Azure roles at management-group scope, or in more than one subscription Its roles are all within the workload's own subscription
Privileged roles such as Owner, User Access Administrator or Role Based Access Control Administrator Its roles come from an approved list, such as Reader, Contributor or data-plane roles
Ownership by the platform team, or use by platform tooling It is owned by the workload team and used only by that workload

If a workload identity needs anything in the left-hand column, for example Graph access to read users or send mail, it is raised as a request to the platform team and vended as a platform identity.

Guardrails that make Lane B safe

  • Managed identities, not app registrations. Creating one is an Azure operation inside a resource group, not a write to Entra ID. Managed identities do not require application secrets to be stored in workload code or configuration. Where external federation is required, use a supported federated identity credential configuration, normally with a user-assigned managed identity or application registration.
  • A constrained deploy identity. The workload pipeline's own identity is vended once by the platform during subscription onboarding. It can assign roles only within its subscription, and only from the approved list, enforced with Azure RBAC conditions.
  • Policy and detection. Azure Policy enforces naming and mandatory tags. Governance tooling such as AzGovViz and the RBAC extract flags any assignment outside the guardrails.

Full guide

Workload Identity Vending covers the deploy identity's RBAC conditions, the approved role list and the managed identity patterns in detail.