Skip to content

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-bootstrap roster.
  • 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-bootstrap roster.
  • 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.

    curl --request PUT --header "PRIVATE-TOKEN: <pat>" \
      "https://gitlab.com/api/v4/projects/<project-id>" \
      --data "ci_id_token_sub_claim_components[]=project_path"
    

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:

cp sp-TEMPLATE.tf.example sp-pla-tf-azure-example-rw.tf

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: the aud the consuming pipeline asks GitLab for. It defaults from class (tf → https://gitlab.com, tool and vendor → api://AzureADTokenExchange). Set it when the pipeline uses something else. EPAC's CI template, for example, uses https://gitlab.com for a tool identity.
  • 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

  graph_app_roles = [
    "Directory.Read.All",
  ]

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

  directory_roles_read_only = ["Directory Readers"]

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-rw and sp-pla-tool-azgovviz-site-rw show the pattern.
  • Nothing above mg-intermediate-root except Reader. Reader at the tenant root is allowed for discovery tools that read the whole tenant (sp-pla-tool-azgovviz-ro does this). Any write role up there belongs in the azure-tenant-bootstrap roster. 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-management or mg-platform reaches rg-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 in operator_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 access matches what the permissions can do.
  • gitlab_project_path is 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 ro identity).
  • Every RBAC scope is as narrow as it can be and doesn't reach Tier 0 state.
  • Nothing above mg-intermediate-root other than Reader.

6. Apply

Merge, wait for the main pipeline's plan job, then apply the saved plan:

  1. Activate (tier 0) Service Principals (pag-t0-service-principals) in PIM → My roles → Groups. Request it in MyAccess first if you aren't eligible.
  2. Sign in again, then apply from the project folder:

    az account clear; az login
    git checkout main; git pull
    ./apply.ps1
    
  3. Check the plan it shows, type apply, and note the outputs.

  4. 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.