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, frommainonly, as a write identity whose reach is limited by code checks and an Azure condition.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.
Owneris 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-planrefuses 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
Tiertag. 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,
Readerat 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-<class>-<name>.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
rwidentity trusts exactlyproject_id:<id>:ref_type:branch:ref:main. Onlymaincan sign in as it. - An
roidentity 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.
rocan only read: read Graph permissions, read directory roles, Azure roles of kindread, andStorage Blob Data Readeron state.Owneris 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 ownsp_policyoutput- 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.commatching onproject_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 AdministratororAppRoleAssignment.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:
-rwis Tier 0 in effect. It has standing access and owns every app it vends. The gate ismain: no direct pushes, and the pipeline must pass to merge.- Azure only stops
Ownerand self-grants. Which other roles and scopes-rwmay grant is checked at MR time (sp_policy, the module andcheck-plan), so the role list lives in one place. -rwcan 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$Privilegedinscripts/grant-write-access.ps1and move those grants elsewhere.GITLAB_TOKEN(group level) is a personal token withapiscope, 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.