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.
Scope: platform machine identities only
This page covers platform machine identities: service principals owned by the cloud platform team for platform tooling. Examples include Terraform pipelines for monitoring, networking and policy, governance tools such as AzGovViz and EPAC, and vendor integrations that need tenant-wide or management-group access.
It does not cover machine identities that workload teams create for their own applications. Those follow a different, lighter model, summarised in Workload machine identities (Lane B).
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 service principal is declared in code in
azure-service-principals. The repository is the inventory. If an identity is not in code, it should not exist. - Consistent naming and ownership: Every identity follows the
sp-<domain>-<class>-<name>-<access>convention. Its purpose, scope and access level show in its name, and each one maps to a consuming repository or tool. - Least privilege by default: Role assignments are declared next to the identity and reviewed in the MR. Nobody grants
Owner"just to get it working", and read-only (ro) and read-write (rw) identities stay separate. - No secrets to manage: The vending module only issues Workload Identity Federation (OIDC) credentials. No client secrets or certificates are ever created, so none can leak, expire or need rotating.
- 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).
- Clean lifecycle: Deleting the file and applying removes the app registration, federated credentials, role assignments and state container together. Nothing is left behind.
- Isolated blast radius: Each pipeline or tool gets its own identity and its own state container, rather than sharing a single powerful service principal. A compromised identity exposes only its own scope.
- Faster, standard onboarding: A new platform tool gets its identity from a small, standard Terraform block, not a ticket and a portal session. Reviewers check intent, not click-by-click configuration.
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-ro holds Directory Readers in Entra ID and Reader at mg-intermediate-root.
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. Platform identities are app registrations with tenant-wide or management-group reach, so all three systems are involved. 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. The permissions needed are the same either way. Only the way they are granted differs.
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 | Equivalent Graph application permission | 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
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, allow Reader and Contributor, never 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. |
Provisioning the state container for a vended identity also needs Storage Blob Data Contributor (data plane) on the state storage account. Management-plane Contributor alone does not give blob access when use_azuread_auth = true.
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 real question is not which roles are used, but who holds them, and for how long. The next section answers it.
Why Platform Service Principal Vending is Tier 0 & Lane A¶
Under Microsoft's Enterprise Access Model (EAM) (see the Access Tiering Model), any identity that can create credentials, grant admin consent or assign privileged roles sits in the control plane (Tier 0). Everything that can control a Tier 0 identity is also Tier 0.
That second point is what rules out CI apply. If a pipeline holds the write permissions above, then the pipeline is Tier 0. So is everything that can influence it: the GitLab instance, every Maintainer on the project, the runners, the shared CI templates and every provider and module the job downloads.
How a CI-vended setup gets compromised¶
A pipeline with standing write access to Entra ID can be abused in many ways. None of these needs an Azure credential to be stolen. The attacker only needs to get code into a job that already holds one.
| Attack path | How it works |
|---|---|
| Malicious or careless merge | One extra role_assignments entry or Graph permission in a large MR is easy to miss in review, especially when the plan output is long. After merge, CI applies it with no further check. |
| Pipeline tampering | Anyone with Maintainer rights can edit .gitlab-ci.yml, change protected-branch rules or retarget a job. If the federated credential trusts the whole project rather than a single protected ref, a job on any branch can get the token. |
| Shared template or supply chain | The job runs code it did not write: ci-templates includes, Terraform providers, module tags, container images and linters. A compromise of any of them runs inside the privileged job. |
| Runner compromise | Access to the runner host, or a malicious job on a shared runner, can read the OIDC token or Azure access token from job memory, environment or disk. |
| GitLab account takeover | A phished password or leaked personal access token for a Maintainer becomes a route to tenant admin. GitLab's security becomes the tenant's security. |
| Persistence | With Application.ReadWrite.All, an attacker can add a client secret to any app. That secret outlives the short-lived OIDC token, and removing the pipeline does not remove it. |
Each of these turns a software delivery incident into a full tenant compromise.
Split-brain models are still bad practice¶
A common compromise is to let CI do most of the work while a human does the "dangerous" step by hand. Each of these reduces the risk, but none removes it:
"CI creates the app, a human grants admin consent."
CI still needs Application.ReadWrite.All or Cloud Application Administrator (High). That is enough to add a credential to an app that is already consented with powerful permissions, and then sign in as it. The consent gate only protects new grants, not the reuse of existing ones. Consent also happens in the portal, outside Terraform. So state drifts from reality, and the approver sees a list of permissions with no MR context, which tends to become a rubber stamp.
"CI only uses Application.ReadWrite.OwnedBy, so it can only touch its own apps."
In this model CI owns every service principal it vends, including the ones a human later grants high privileges to. Owning an app means being able to add credentials to it. So the CI identity controls the most privileged identities in the tenant.
"CI assigns Azure roles, but RBAC conditions block Owner and User Access Administrator."
CI can still grant Contributor at management-group scope to any new identity, including one it controls. Contributor can run commands on VMs and automation accounts whose managed identities may hold higher privileges. Conditions narrow the problem, but they do not close it.
"CI applies, but only after a manual approval job or protected environment." The privileged credential still lives in the pipeline, and the gate is a GitLab setting that a Maintainer can change. The approved job still runs whatever code is in the repository, templates and providers at that moment. The Entra ID audit log records the pipeline's service principal, not the person who clicked approve.
"Give the CI identity PIM-eligible roles." PIM activation relies on a person: MFA, a written justification and an approver. A pipeline cannot supply any of these meaningfully. In practice, a CI identity's privileged roles end up as permanent active assignments, which is standing access by another name.
Every split-brain model has the same flaw: a non-human identity still holds standing write access to the control plane. A split between CI and a human also adds drift, weaker evidence and a false sense of safety.
Why Lane A closes the gap¶
| CI apply | Split-brain | Lane A | |
|---|---|---|---|
| Standing write access to Entra ID | Yes | Yes (partial) | No |
| GitLab, runners and templates are Tier 0 | Yes | Yes | No. CI is read-only. |
| Blast radius of a CI compromise | Full tenant | Near-full tenant | Directory read only |
| Change tied to a named person in Entra ID audit | No | Partly | Yes, through PIM activation |
| All configuration in Terraform state | Yes | No (manual steps) | Yes |
| Separation of duties (NIST AC-5) | No | Partial | Yes. MR review, then a separate PIM approval. |
| Just-in-time privilege (NIST AC-2(6), AC-6) | No | Partial | Yes |
Lane A is slower: an operator must elevate and run apply locally. That trade-off is deliberate. Platform identity changes are rare and high-impact, so the cost is small compared with the risk it removes.
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 Lane A would make the platform team a bottleneck, and it is not necessary.
Lane A exists because 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.
Lane A or Lane B?¶
The deciding factor is what the identity needs, not who asks for it:
| An identity is Lane A if it needs any of these | An identity can be Lane B if it meets all of these |
|---|---|
| An Entra ID app registration or service principal | It is a managed identity (user-assigned or system-assigned) |
| 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 through Lane A.
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. No one can add secrets to it, and GitLab can still sign in using OIDC federated credentials.
- A constrained deploy identity. The workload pipeline's own identity is vended once through Lane A 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.