Skip to content

Create a Service Principal

The engineer's guide to vending platform service principals. Use this runbook to:

  • Create a Tier 0 or Tier 1 service principal that gives a GitLab pipeline or tool its own Azure identity.
  • Grant what the pipeline can't, and wire up the consuming project.
  • Replace an identity from t0-azure-service-principals with a new one.
  • Remove a service principal.

For why identities are vended this way, and what limits the pipeline that applies them, see Platform Service Principal Vending.

One block per identity, ro and rw kept apart

Each identity is one module block. An ro identity can sign in from any branch, so it may only read. An rw identity can sign in from main only. Most Terraform projects need both: ro for MR plans and rw for the apply. You never type the tier: the module works it out from what the identity asks for, and the plan shows it.

The projects

Project Lane Holds
azure-service-principals-platform B (CI applies from main as -rw) One pla-<class>-<name>.tf per system, sp_policy in environments/<env>.tfvars, and scripts/grant-write-access.ps1
solution-manage-azure-service-principals-platform — The module each block calls. Holds the hard limits (no Owner, ro only reads, OIDC only) and works out the tier
ci-templates — terraform-sp-vending-pipeline.yml: plan, check-plan, admin-consent, entra-roles and apply

What it can't do, on purpose: grant Graph admin consent, assign Entra roles, create secrets or certificates, assign Owner, give an ro identity a write role, grant anything to -rw, or go outside sp_policy. Tier 2 (workload) identities go in azure-service-principals-workload.


Before writing code, establish these parameters:

Name and class: name is kebab-case, usually the consuming project's name (e.g. azure-monitoring). class is tf (runs Terraform, gets a state container) or tool (runs a script, no state). The identity is named sp-pla-<class>-<name>-<ro|rw>.

Consuming project ID: the numeric ID from the project's Settings > General. For an ro identity, you also need its group ID.

Permissions: every Graph permission, directory role and Azure role (with scope) the pipeline calls. Each must be allowed by sp_policy in environments/prod.tfvars. If it isn't, change the policy first.


Create the identity

📁 azure-service-principals-platform: 📃 pla-<class>-<name>.tf

  1. Make one file for the system, e.g. pla-tf-azure-monitoring.tf.
  2. Copy this block, once for ro and once for rw if it needs both.
  3. Change only the lines marked # change. Copy the rest as is.
module "sp-pla-tf-azure-monitoring-rw" { # change: sp-pla-<class>-<name>-<access>
  source  = "gitlab.com/grinntec-cloud/solution-manage-azure-service-principals-platform/azure"
  version = "4.0.0"

  name    = "azure-monitoring"                     # change
  class   = "tf"                                   # change: tf (runs Terraform, can keep state here) | tool (runs a script, no state)
  access  = "rw"                                   # change: ro (any branch, read only) | rw (main only)
  owner   = "monitoring-team@grinntec.net"         # change: accountable owner (Owner tag)
  purpose = "Provisions the monitoring resources." # change: one sentence

  # The GitLab project that uses this identity (IDs: Settings > General).
  consuming_gitlab_project_id = "86000551" # change: the project whose pipelines sign in as it

  # if access is = ro > uncomment this and set that project's group ID.
  # otherwise leave the line commented out as rw trusts main only, so the group isn't checked
  # consuming_gitlab_group_id = "128852684"

  # The same in every block: copy as is.
  policy = var.sp_policy
  owners = local.app_owners
  repo   = local.repo

  # ==== TERRAFORM STATE ====
  # class = "tf"   > keep this line: it gets its own state container.
  # class = "tool" > delete this line: a script has no state (the module refuses it).
  state_storage_account_id = local.state_storage_account_id

  # ==== MICROSOFT GRAPH: API permissions ====
  # (a person grants admin consent)
  graph_permissions = [] # e.g. ["Directory.Read.All"]

  # ==== ENTRA ID: directory roles ====
  # (a person assigns them)
  directory_roles = [] # e.g. ["Security Reader"]

  # ==== AZURE RBAC: role assignments ====
  # (Terraform creates them)
  azure_role_assignments = [
    { scope = "/subscriptions/<id>/resourceGroups/rg-monitoring-prod", role = "Contributor" },
  ]

  # Only for Tier 0 permissions or roles:
  # reasons = { "<permission or role>" = "<why>" }
}
  • Keep all three permission blocks. Use [] when it needs none.
  • Use the smallest scope that works. A resource group or a single container is better than a subscription. pla-tf-azure-monitoring.tf and pla-tool-azgovviz-analyzer.tf show the pattern.
  • Access roles need a limit. User Access Administrator or Role Based Access Control Administrator must name what it may grant:

    { scope = "<rg id>", role = "User Access Administrator", constrain_roles = ["Log Analytics Contributor"] }
    
  • Tier 0 needs a reason. Anything Tier 0 (e.g. App vending) needs an entry in reasons, 10 characters or more.

  • State: the rw block creates a container named after name and gets write access. The ro block gets read access to the same container, and plans with -lock=false. Add depends_on = [module.<the rw block>] to the ro block.

Only needs to read? Make ro only

If the consuming project's pipeline only plans, make only the ro block. pla-tf-conditional-access.tf and pla-tf-entra-diagnostics.tf are examples.

Already has a state container? Reuse it

📁 azure-service-principals-platform: 📃 pla-<class>-<name>.tf

  1. Name the container in the block, under state_storage_account_id:

    state_storage_account_id = local.state_storage_account_id
    state_container          = "management-groups"
    
  2. If the block is rw, add an import block so Terraform takes over the container instead of creating it:

    # Remove after the first apply.
    import {
      to = module.sp-pla-tf-azure-management-groups-rw.azapi_resource.state_container[0]
      id = "${local.state_storage_account_id}/blobServices/default/containers/management-groups"
    }
    
  3. Check the plan shows the container as imported, not created or destroyed.

  4. After the apply, delete the import block in a new MR.

Open the MR and check the plan

Commit and open an MR. The pipeline runs:

secrets → fmt / tflint / validate → checkov → plan → check-plan / admin-consent / entra-roles

  1. plan posts the plan. A new identity should only add resources.
  2. check-plan must pass (green). It refuses anything unsafe, one REFUSED: line per problem.
  3. admin-consent and entra-roles go amber when a person must grant something. Note what they list: you need it after the apply.

Look at the identity's Tier tag in the plan. If it says Tier 0 and you didn't expect it, check what made it so before you merge.

When it fails:

check-plan says Do this
<role> isn't on the allowed list at <scope> Narrow the scope, or change sp_policy first
a read-only identity may only hold read roles Move the write role to the rw block
Owner is never allowed Use a narrower role
nothing may be granted to the write identity Add it to scripts/grant-write-access.ps1 instead (senior review)
the scope isn't known at plan time Use a literal scope string, not a value from another resource
new owner <id> isn't allowed Use owners = local.app_owners
FLAG: ... will be destroyed Not a refusal. Stop and find out why before you merge

Apply

  1. Merge the MR.
  2. Wait for main's pipeline to reach apply.
  3. Press play on apply. It signs in as sp-pla-tf-service-principals-platform-rw and applies main's saved plan.

The job stops early if ARM_CLIENT_ID isn't set for environment apply. That variable is protected, so only main sees it.


Grant what the pipeline can't

📁 Entra admin center (needs an Entra administrator)

Do what the MR's admin-consent and entra-roles notes list. Each says where to go:

  1. Graph permissions: App registrations > the identity > API permissions > Grant admin consent.
  2. Directory roles: Roles and administrators > the role > Add assignments.

Re-run admin-consent and entra-roles on main. Both should go green.


Wire up the consuming project

📁 The consuming GitLab project

1. Set its sign-in token. Once per project, not per identity. Needs Maintainer on that project:

glab api --method PUT "projects/<consuming_gitlab_project_id>" -F 'ci_id_token_sub_claim_components=["project_id","ref_type","ref"]'

2. Tell its pipeline which identity to sign in as. Once per identity:

  1. In Entra, open App registrations > the identity, and copy its Application (client) ID.
  2. In the consuming project, open Settings > CI/CD > Variables, and add or edit the variable its pipeline signs in with (e.g. ARM_CLIENT_ID).
  3. Value: the client ID. Description: the identity's name. Protect variable: ticked for rw, not for ro.

3. Check the token's audience. Find aud: in the consuming project's .gitlab-ci.yml (or the template it includes). It must match the identity's class:

id_tokens:
  GITLAB_OIDC_TOKEN:
    aud: https://gitlab.com            # class = "tf"   (Terraform)
    # aud: api://AzureADTokenExchange  # class = "tool" (scripts, Az PowerShell)

Check it worked: run the consuming project's pipeline. The sign-in step should pass, and the first Terraform or Az command should get past authentication.

az ad sp list --display-name sp-pla-tf-azure-monitoring-rw --query "[].{name:displayName, appId:appId}" -o table
az ad app federated-credential list --id <client-id> --query "[].{subject:subject, audiences:audiences}" -o table

An rw subject reads project_id:<id>:ref_type:branch:ref:main.

When it fails:

You see Do this
"No matching federated identity record found" Check step 1 was run on the right project, and consuming_gitlab_project_id is right. An rw identity only signs in from main
An "audience" error Fix aud: (step 3) to match the identity's class
403 on a resource The identity signed in but lacks the role. Add it in the block. Role assignments can take a few minutes
403 on Graph Admin consent isn't granted yet. See Grant what the pipeline can't

Replace an old identity

📁 azure-service-principals-platform and the consuming project

Use this when the consuming project already signs in as an identity from t0-azure-service-principals.

  1. Create the new identity as above, and grant its consent and roles first.
  2. Then set the sign-in token and the client ID variable together. The old identity trusts the old token form, so after the token change only the new one can sign in.
  3. Run the consuming project's pipeline and check it signs in.
  4. If it doesn't, undo both. Set the token back, and the variable back to the old client ID:

    glab api --method PUT "projects/<consuming_gitlab_project_id>" -F 'ci_id_token_sub_claim_components=["project_path"]'
    
  5. Once it works, delete the old identity in t0-azure-service-principals.

The old and new identity may have the same name. In Entra, pick the new one by its Created on date, or by its notes, which name azure-service-principals-platform.


Change what identities may ask for

📁 azure-service-principals-platform: 📃 environments/prod.tfvars → sp_policy

sp_policy is the only list of allowed Graph permissions, directory roles and Azure roles. The module and check-plan both read it, and -rw can grant any role except Owner, so adding a role here needs nothing else.

  azure_roles = {
    # Platform hosting resource groups only.
    "Contributor" = {
      kind   = "write"
      scopes = ["/resourceGroups/(rg-monitoring-prod|rg-azgovviz-hosting-prod)(/|$)"]
    }
  }
  • kind is read, write or access. Only read roles can go on an ro identity.
  • scopes are regexes, matched against the full scope. Keep access roles narrow.
  • Never add Owner.

Changing the list loosens or tightens what CI will apply, so the MR needs senior review.


Remove a service principal

  1. Delete its block (or its whole pla-<class>-<name>.tf file) and open an MR.
  2. Check the plan only destroys that identity's resources. check-plan flags each app it will destroy.
  3. Merge and press play on apply.

Removing an rw tf identity deletes its state container

The rw block created the container, so removing it deletes the consuming deployment's state. If that deployment still holds resources, run its own terraform destroy first, or keep the container with a removed block in the same MR:

removed {
  from = module.sp-pla-tf-azure-example-rw.azapi_resource.state_container
  lifecycle {
    destroy = false
  }
}

Then remove any Graph consent or directory role a person granted by hand, as admin-consent and entra-roles list.