Create a Service Principal¶
Use this runbook to create an Azure App Registration (Service Principal) to give a GitLab pipeline or tool its own Azure identity. This service principal (SP) signs in with OIDC and has no secret or certificate.
| The Projet Supports Creating an SP that can... | |
|---|---|
| Be used in a GitLab pipeline with OIDC | ✅ |
| Use Microsoft Graph application permissions | ✅ |
| Have a read-only directory (Entra) role (Global Reader, Directory Readers, Reports Reader, Security Reader, Message Center Reader) | ✅ |
Get an Azure (RBAC) role from operator_sp_roles at mg-intermediate-root or below |
✅ |
| Get Reader at the tenant root | ✅ |
| Use a client secret or certificate | ❌ i |
| Have a write directory (Entra) role (Application Administrator, Privileged Role Administrator, etc.) | ❌ ii |
Get a write Azure (RBAC) role above mg-intermediate-root |
❌ iii |
Get an Azure (RBAC) role not defined in operator_sp_roles |
❌ iv |
Why not?
- i. The SP signs in with OIDC only. There is no secret to store, rotate or leak.
- ii. A write Entra role can take over other identities, so it's Tier 0. A pipeline on any branch can use an SP made here, so anyone who can push a branch would get Tier 0 access. The solution fails the plan. Use the
azure-tenant-bootstraproster. - iii. A write role that high is Tier 0. Nothing in the code blocks it, so the MR review must reject it. Use the
azure-tenant-bootstraproster. - iv. The person applying can only assign roles on that list, so the apply fails with a 403. Add the role to the list first: Manage what roles can be assigned.
For why SPs are made this way, and why this project is Tier 0, see Platform Service Principal Vending.
How it works¶
You add one file, sp-<domain>-<class>-<name>-<access>.tf, to t0-azure-service-principals. When applied, it creates:
- the app registration and its service principal
- a federated credential that trusts one GitLab project
- every permission the SP holds: Graph permissions, directory roles, Azure RBAC and, for Terraform SPs, access to its state container
Because everything is in one file, the MR shows exactly what the SP can do.
| Project | Role in this process |
|---|---|
t0-azure-service-principals |
Holds the sp-*.tf files. CI runs the plan, and you apply it with ./apply.ps1 (Tier 0, Lane A: CI plans, a human applies). |
solution-manage-azure-service-principals |
The solution each sp-*.tf file calls. It enforces the naming rules and the read-only directory role safelist. |
gitlab-projects |
Configures the consuming project: its OIDC subject claim and its ARM_CLIENT_ID variable. |
Before you start, decide three things¶
How many SPs the consuming project needs¶
This depends on the consuming project's tier and lane (see Determine an Access Package's Tier).
| Consuming project | Create |
|---|---|
| Tier 1 or 2 deployment, applied by CI | One rw SP with what the apply needs |
| Tier 0, Lane A (CI plans, a human applies) | One ro SP for the plan only. The human applying gets write access from their PIM group |
| Tool pipeline (AzGovViz, EPAC, RBAC extract) | One SP per job with different rights, e.g. sp-pla-tool-epac-plan-ro and sp-pla-tool-epac-deploy-policy-rw |
Tier 0 permissions
If the SP needs any of Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All, Group.ReadWrite.All, GroupMember.ReadWrite.All or any RoleManagement.* write permission, the consuming project is Tier 0. It must be Lane A and its SP must be ro.
Use the narrower permissions where they're enough: Application.ReadWrite.OwnedBy instead of Application.ReadWrite.All, Group.Create instead of Group.ReadWrite.All.
The name¶
sp-<domain>-<class>-<name>-<access>. Use the same value for the file name, the module block name and the Entra display name.
| Segment | Values | Meaning |
|---|---|---|
domain |
pla, wl |
Platform/governance, or workload |
class |
tf, tool, vendor |
Runs Terraform (gets state access), runs scripts, or is a third-party integration |
name |
kebab-case | Usually the consuming project's name, e.g. azure-monitoring |
access |
ro, rw |
ro only if every permission the SP will hold is read-only. Work out its permissions (see step 3) before you pick |
Example: sp-pla-tf-azure-monitoring-rw
The consuming project's path¶
The full GitLab path, exactly as it appears in the URL, e.g. grinntec-cloud/deployments/azure-platform/azure-monitoring. If it's off by one character, sign-in fails.
1. Prepare the consuming GitLab project¶
The federated credential trusts the subject project_path:<path>. GitLab
only sends that subject if the project is set to.
- Project made by
gitlab-projects(terraform-gitlab-deployment-project): already set. Nothing to do. -
Any other project: set it with the Projects API.
Renaming or moving the project breaks sign-in
The trust is tied to the path. If the project is renamed or moved, update
gitlab_project_path in the same change.
2. Create the declaration file¶
📁
t0-azure-service-principals: 📃sp-<domain>-<class>-<name>-<access>.tf
Copy the template and rename it:
Fill in the identity and the project:
module "sp-pla-tf-azure-example-rw" {
# checkov:skip=CKV_TF_1:semver tag is controlled and auditable for internal modules
source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-azure-service-principals.git?ref=v4.4.0"
# Name the sp-<domain>-<class>-<name>-<access> according to the naming convention
domain = "pla"
class = "tf"
name = "azure-example"
access = "rw"
# The project whose pipeline authenticates as this SP. Used verbatim as the
# OIDC federated-credential subject — must equal the real path exactly or auth fails.
gitlab_project_path = "grinntec-cloud/deployments/azure-platform/azure-example"
# GitLab CI uses aud: "https://gitlab.com" for keyless OIDC federation
fic_audience = "https://gitlab.com"
# The owner and purpose of this service principal.
owner = "neil@grinntec.net"
purpose = "Pipeline identity for azure-example — deploys the example resources."
# Permissions: see step 3.
}
source: use the latest tag of the solution. Check Code → Tags in the solution project.fic_audience: theaudthe consuming pipeline asks GitLab for. It defaults fromclass(tf→https://gitlab.com,toolandvendor→api://AzureADTokenExchange). Set it when the pipeline uses something else. EPAC's CI template, for example, useshttps://gitlab.comfor atoolidentity.owner: an email or UPN. It's written to the app's tags.purpose: a real sentence, 10–200 characters. Say what it does, not what it is.
3. Add the permissions¶
Add only what the pipeline calls. Each block can be left empty or left out.
Microsoft Graph permissions¶
Application permissions, granted and admin-consented across the tenant on
apply. Names come from the
Graph permissions reference.
Prefer .Read. permissions, and prefer Graph permissions over directory roles.
No 'Grant admin consent' click in the portal
Admin consent is part of the apply. For each permission the solution adds
it to the app's manifest (what the portal lists under API
permissions) and creates an azuread_app_role_assignment on the
Microsoft Graph service principal. That assignment is the consent: the
portal's Grant admin consent button creates the same object. After the
apply, the portal shows each permission as Granted for <tenant>.
It needs Privileged Role Administrator, which is why the operator group
holds it. If a permission shows Not granted, the apply didn't finish:
don't click the button, re-run the main pipeline and apply again so
state and the tenant stay the same.
Read-only directory roles¶
Only for a tool that checks role membership instead of calling Graph. The
solution only accepts Global Reader, Directory Readers, Reports Reader,
Security Reader and Message Center Reader. Anything else fails the plan.
Azure RBAC¶
azure_role_assignments = [
{
scope = "/subscriptions/<sub-id>/resourceGroups/rg-example-prod"
role = "Contributor"
},
]
- Use the smallest scope that works. A resource group or a single
resource is better than a subscription, and a subscription is better than a
management group.
sp-pla-tf-azure-monitoring-rwandsp-pla-tool-azgovviz-site-rwshow the pattern. - Nothing above
mg-intermediate-rootexcept Reader. Reader at the tenant root is allowed for discovery tools that read the whole tenant (sp-pla-tool-azgovviz-rodoes this). Any write role up there belongs in theazure-tenant-bootstraproster. The solution doesn't check scope, and the operator's RBAC Administrator sits at the tenant root, so the apply would succeed. The MR review is the only control. - Keep away from Tier 0 assets. A write role on the management
subscription,
mg-managementormg-platformreachesrg-terraform-state. That makes the identity Tier 0. - The role has to be on the operator's list. You apply with
pag-t0-service-principals, whose RBAC Administrator is conditioned to the roles inoperator_sp_roles(solution-privileged-access-allowlist→locals.tf): Reader, Contributor, User Access Administrator, Role Based Access Control Administrator, Management Group Contributor, Resource Policy Contributor, and Storage Blob Data Contributor / Reader. For any other role, widen the list first, as in Manage what roles can be assigned. Otherwise the apply fails with a 403.
Terraform state (class = "tf" only)¶
state_rbac = {
storage_account_id = data.azurerm_storage_account.tfstate.id
container = "azure-example"
resource_group_id = data.azurerm_resource_group.tfstate.id
}
Creates the container in sa17452008tenantroot and grants Storage Blob Data
Contributor on it, plus Reader on rg-terraform-state. Both data sources are
already in data.tf.
For a read-only plan identity (Lane A), give it read access only. It plans
with -lock=false:
state_rbac = {
storage_account_id = data.azurerm_storage_account.tfstate.id
container = "azure-example"
create_container = false # only if something else already manages the container
role = "Storage Blob Data Reader"
}
Leave state_rbac out for tool and vendor identities.
create_container decides who owns the container
With create_container = true (the default) the container is a resource
in this project's state. Changing it to false on an existing identity
deletes the container and the state in it. Only set false on a new
identity whose container is created somewhere else, such as an
azure-tenant-bootstrap step.
4. Add the outputs¶
📁
t0-azure-service-principals: 📃outputs.tf
The consuming project needs the client ID, and a tf identity also needs its
backend block. Add an output for each so the apply prints them:
output "backend_azure_example" {
description = "Ready-to-paste backend configuration for azure-example."
value = module.sp-pla-tf-azure-example-rw.state_backend
}
# The module marks client_id sensitive; a client ID isn't a secret, so unwrap
# it here to print it in the apply output.
output "azure_example_client_id" {
description = "Client ID of sp-pla-tf-azure-example-rw, for azure-example's ARM_CLIENT_ID CI variable."
value = nonsensitive(module.sp-pla-tf-azure-example-rw.client_id)
}
5. Open the MR and check the plan¶
Commit and open an MR. CI runs secrets, fmt, tflint, validate, checkov and plan, and posts the plan to the MR.
For a new identity, the plan should only add resources:
| Resource | How many |
|---|---|
azuread_application, azuread_service_principal, azuread_application_federated_identity_credential |
1 each |
azuread_app_role_assignment |
1 per Graph permission |
azuread_directory_role, azuread_directory_role_assignment |
1 each per directory role |
azurerm_role_assignment |
1 per RBAC entry, plus 1–2 for state |
azapi_resource (state container) |
1, if create_container is true |
Any change or destroy of another identity's resources means something is wrong. Stop and find out why before you merge.
Reviewer checklist:
- The name matches the file and the module block, and
accessmatches what the permissions can do. -
gitlab_project_pathis the real path of the consuming project. - Every Graph permission and role is needed, and none is in the Tier 0
list (or the consuming project is Lane A and this is its
roidentity). - Every RBAC scope is as narrow as it can be and doesn't reach Tier 0 state.
- Nothing above
mg-intermediate-rootother than Reader.
6. Apply¶
Merge, wait for the main pipeline's plan job, then apply the saved plan:
- Activate (tier 0) Service Principals
(
pag-t0-service-principals) in PIM → My roles → Groups. Request it in MyAccess first if you aren't eligible. -
Sign in again, then apply from the project folder:
-
Check the plan it shows, type
apply, and note the outputs. - Deactivate the group.
What the script checks and what to do when it fails: Lane A Apply.
Don't run terraform apply yourself
A local terraform plan or apply fails on purpose (ci-only-guard.tf).
The script applies exactly the plan CI saved for the merged commit.
7. Wire up the consuming project¶
📁
gitlab-projects: 📃<project>.tf
Put the client ID from the apply output into the consuming project's
declaration. The module sets it as the ARM_CLIENT_ID CI/CD variable:
arm_client_id = "<client-id>" # gitleaks:allow — Application (client) ID is a public identifier, not a secret
arm_subscription_id = "<subscription-id>"
For a tf identity, paste the backend_<name> output into the consuming
project's versions.tf backend block (backend_config in gitlab-projects
for a new project).
For a project not managed by gitlab-projects, set ARM_CLIENT_ID,
ARM_TENANT_ID and ARM_SUBSCRIPTION_ID under Settings → CI/CD →
Variables instead.
Check it worked¶
Run a pipeline in the consuming project. The sign-in step should succeed and the first Terraform or Az command should get past authentication.
To see the identity in Entra:
az ad sp list --display-name sp-pla-tf-azure-example-rw --query "[].{name:displayName, appId:appId}" -o table
az ad app federated-credential list --id <client-id> --query "[].{subject:subject, audiences:audiences}" -o table
az role assignment list --assignee <client-id> --all -o table
The subject should read project_path:<the consuming project's path>.
When it fails¶
| You see | Meaning | Do this |
|---|---|---|
| AADSTS700213 / "No matching federated identity record found" in the consuming pipeline | The subject GitLab sent doesn't match the credential | Check gitlab_project_path is exact, and that the project's ci_id_token_sub_claim_components is ["project_path"] (step 1) |
| AADSTS700212 / "No matching federated identity record found for presented assertion audience" | fic_audience doesn't match the aud in the pipeline's id_tokens |
Set fic_audience to what the pipeline asks for |
| Plan fails: "Only safelisted read-only directory roles are allowed" | You asked for a directory role outside the safelist | Use a Graph permission instead, or, if it really needs a write role, it belongs in azure-tenant-bootstrap |
| Plan fails: "name must be lowercase kebab-case", "purpose must be 10-200 characters", "owner must be an email" | A field doesn't pass the solution's checks | Fix the field |
Apply fails with 403 on Microsoft.Authorization/roleAssignments/write, ABAC condition not fulfilled |
The RBAC role isn't in operator_sp_roles |
Widen the list first (step 3), don't elevate further |
| Apply fails with 403 on Graph or "Insufficient privileges" | Your PIM activation expired, or you didn't sign in again after activating | Re-activate, az account clear; az login, re-run the main pipeline, apply again |
| Consuming pipeline gets 403 on a resource | The identity signed in but lacks the role | Add the role in this file. Role assignments can take a few minutes to apply |
| Consuming Terraform can't open state | No state_rbac, wrong container name, or the backend isn't using use_azuread_auth = true |
Check state_rbac.container matches the backend's container_name |
Remove a service principal¶
Delete its sp-*.tf file and its outputs, open an MR, and check the plan
only destroys that identity's resources. Merge and apply as in
step 6. The app, its credential, its role assignments and its
state container all go.
Deleting the state container deletes the state
If the identity created its container, removing it deletes the consuming
deployment's state. If that deployment still holds resources, run its own
terraform destroy first. To keep the container instead, add a removed
block in the same MR so Terraform forgets it without deleting it:
removed {
from = module.sp-pla-tf-azure-example-rw.azapi_resource.state_container
lifecycle {
destroy = false
}
}
Don't use create_container = false for this: on an existing identity it
deletes the container too.
To bring an identity that was made by hand into code, see
docs/sp-import-guide.md
in the project.