EQTY LabDocs

Microsoft Entra ID

Configure Microsoft Entra ID for deployment

View as Markdown

Overview

The Guardian Platform integrates with Microsoft Entra ID (formerly Azure Active Directory) for enterprise-grade authentication and authorization.

Required app registrations:

  • Guardian Platform Backend API – Web application for backend API authentication and token validation
  • Microsoft Graph API Access – App with User.Read.All for user lookups via Microsoft Graph; the bootstrap chart reuses the backend app
  • Governance Worker – M2M confidential client for automated governance workflow execution
  • Guardian SPA – Single Page Application for frontend authentication

The entra-bootstrap Helm chart automates most of this: it runs a one-time Kubernetes Job that creates the backend, frontend, and worker app registrations, the access_as_user scope, and the Microsoft Graph permissions. It uses the backend app for Graph access instead of a separate registration. Run it before deploying the platform chart.

This guide covers:

  • Creating app registrations with the bootstrap chart, or by hand with the Azure CLI or Portal
  • Configuring API permissions and scopes
  • Setting up Microsoft Graph access
  • Organization and admin user setup by the post-install hook
  • Generating the token signing key
  • Configuring the Helm chart for Entra ID

Prerequisites

  • Microsoft Entra ID tenant (Azure AD tenant)
  • Azure CLI installed and configured
  • Permissions to create app registrations and grant admin consent in the tenant
  • Tenant ID (found in Azure Portal > Microsoft Entra ID > Overview)
  • kubectl and helm access to the deployment namespace
  • jq 1.6 or later, to read the bootstrap’s generated credentials
  • A clone of the deployment repository for the entra-bootstrap chart

Helm Configuration

Add the following to the values.yaml and secrets.yaml files. The setup steps below fill in the placeholders.

values.yaml:

yaml
auth-service:
  config:
    idp:
      provider: "entra"
      issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
      entra:
        tenantId: "<tenant-id>"
    tokenExchange:
      enabled: true
      keyId: "auth-service-001" # Identifier for the token signing key

governance-service:
  config:
    authProvider: "entra"
    entraTenantId: "<tenant-id>"

guardian:
  config:
    authProvider: "entra"
    entraTenantId: "<tenant-id>"
    entraClientId: "<spa-client-id>" # Frontend (SPA) app
    entraScopes: "openid profile email offline_access api://<backend-client-id>/access_as_user"

secrets.yaml:

yaml
global:
  secrets:
    create: true
    auth:
      provider: "entra"
      entra:
        secretName: "platform-entra"
        values:
          clientId: "<backend-client-id>"
          clientSecret: "<backend-client-secret>"
          tenantId: "<tenant-id>"
          graphClientId: "<graph-client-id>" # The backend app when using the bootstrap chart
          graphClientSecret: "<graph-client-secret>"
          tokenExchangePrivateKey: | # Generate with: openssl genrsa 2048
            -----BEGIN PRIVATE KEY-----
            ...
            -----END PRIVATE KEY-----
    governanceWorker:
      secretName: "platform-governance-worker"
      values:
        encryptionKey: "<worker-encryption-key>" # Generate with: openssl rand -base64 32
        clientId: "<worker-client-id>"
        clientSecret: "<worker-client-secret>"

Entra deployments always use token exchange: the Auth Service signs the platform’s access tokens with tokenExchangePrivateKey, which the chart stores as the token-exchange-private-key key of the platform-entra secret. Every Auth Service replica reads that key, so they all sign with the same one. With global.secrets.create: true, rendering fails without it; if you create platform-entra yourself, add the token-exchange-private-key key. guardctl generates the key and sets tokenExchange.

Create a service principal for the bootstrap, run the entra-bootstrap chart, and copy the IDs and secrets it saves into the platform’s chart values, in six steps.

1. Create the Bootstrap Service Principal

The bootstrap job calls Microsoft Graph as its own service principal, which needs Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All (application permissions). Sign in to the tenant and create it:

bash
az login --tenant <tenant-id>

BOOTSTRAP_APP_ID=$(az ad app create --display-name "Governance Bootstrap SP" --query appId -o tsv)
az ad sp create --id $BOOTSTRAP_APP_ID

# Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All
az ad app permission add --id $BOOTSTRAP_APP_ID \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions 1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9=Role 8e8e4742-1d2d-4f22-b4fa-e31d2a2e3798=Role

az ad app permission admin-consent --id $BOOTSTRAP_APP_ID

# Confirm both Graph permissions are listed
az ad app permission list --id $BOOTSTRAP_APP_ID -o table

az ad app credential reset --id $BOOTSTRAP_APP_ID --display-name "bootstrap" --years 2

Save the password from the last command as the bootstrap client secret. Admin consent needs a Global Administrator (or Privileged Role Administrator); if admin-consent fails, have one grant it in Azure Portal > Microsoft Entra ID > App registrations > Governance Bootstrap SP > API permissions.

2. Create the Service Principal Secret

The job reads the service principal’s credentials from the entra-bootstrap-sp secret:

bash
kubectl create secret generic entra-bootstrap-sp \
  --from-literal=client-id=<bootstrap-app-id> \
  --from-literal=client-secret=<bootstrap-client-secret> \
  --namespace guardian

This secret is only for the bootstrap; the platform services use platform-entra.

3. Prepare the Bootstrap Values

From the deployment repository checkout, start from the example values file:

bash
cp charts/entra-bootstrap/examples/values.yaml bootstrap-values.yaml

Edit these keys in bootstrap-values.yaml:

yaml
entra:
  tenantId: "<tenant-id>"

apps:
  frontend:
    redirectUris:
      - "https://<domain>"
      - "http://localhost:5173"

The SPA redirect URIs come only from apps.frontend.redirectUris; the http://localhost:5173 entry is for local Guardian development and can be removed. If the tenant already has a Guardian Platform deployment, add a per-environment suffix to the displayName values under apps: the job reuses the app registration with exactly that display name and overwrites its redirect URIs, and fails if more than one app registration has the name.

4. Run the Bootstrap

bash
helm upgrade --install entra-bootstrap ./charts/entra-bootstrap \
  --namespace guardian \
  --values bootstrap-values.yaml \
  --wait \
  --timeout 10m

kubectl logs job/entra-bootstrap-entra-bootstrap --namespace guardian

# Confirm the backend, frontend, and worker app registrations exist, beside Governance Bootstrap SP
az ad app list --filter "startswith(displayName,'Guardian Platform') or startswith(displayName,'Governance')" --query "[].{name:displayName, appId:appId}" -o table

The log ends with an Entra ID Bootstrap Summary of the backend, frontend, and worker app IDs. The job saves the app IDs, the tenant ID, and new client secrets to the entra-bootstrap-credentials secret (<release>-credentials, or credentialsSecret.name), not to its logs. Read them with jq:

bash
kubectl get secret entra-bootstrap-credentials --namespace guardian -o json \
  | jq '.data | map_values(@base64d)'
KeyValue
tenant-idThe directory (tenant) ID
backend-client-id, backend-client-secretThe backend app’s credentials
frontend-client-idThe frontend app ID
worker-client-id, worker-client-secretThe worker app’s credentials

Helm creates the secret empty and keeps it on uninstall, and the job can only read and update it.

Instead of these commands, the deployment repository’s helper script checks for the service principal secret named in the values file, runs the chart, prints the job logs and the command that reads the credentials, and then deletes the job: ./scripts/entra/bootstrap-entra.sh -f bootstrap-values.yaml -n guardian.

The job is idempotent: re-running it reuses app registrations with the same display name and keeps their client secrets. For an existing app with no client secret, it creates one and saves it. The job fails if it cannot create or save a secret.

5. Finish the App Registrations

The bootstrap’s own admin consent attempt fails without an administrator role, and it doesn’t set the worker’s Application ID URI. Signed in as a Global Administrator or Privileged Role Administrator:

bash
# Graph User.Read.All, which the admin-user hook and the Auth Service need
az ad app permission admin-consent --id <backend-client-id>

# The SPA's delegated scopes, needed where user consent is turned off
az ad app permission admin-consent --id <frontend-client-id>

# The Auth Service requests worker tokens for api://<worker-client-id>
az ad app update --id <worker-client-id> \
  --identifier-uris "api://<worker-client-id>"

6. Update Chart Values

Fill in the Helm Configuration sections from the credentials secret:

  • <tenant-id>: tenant-id
  • clientId / clientSecret and graphClientId / graphClientSecret: backend-client-id and backend-client-secret
  • governanceWorker.values.clientId / clientSecret: worker-client-id and worker-client-secret, plus an encryptionKey from openssl rand -base64 32
  • entraClientId: frontend-client-id; entraScopes: the backend app ID in api://<backend-client-id>/access_as_user
  • tokenExchangePrivateKey: a token signing key from openssl genrsa 2048, including its BEGIN and END lines

Add the organization and admin user as described in Create Organization and Admin User, then proceed to the Helm Deployment guide.

Alternative: Manual Setup via Azure CLI

Without the bootstrap chart, create the platform’s four app registrations with the Azure CLI and generate the token signing key, in six steps.

1. Authenticate to Azure

The tenant ID can be found in Azure Portal > Microsoft Entra ID > Overview. Login to Azure with the tenant:

bash
az login --tenant <tenant-id>

Verify the correct tenant:

bash
az account show

Update values.yaml with the tenant ID.

values.yaml -> auth-service.config.idp:

yaml
issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
entra:
  tenantId: "<tenant-id>"

values.yaml -> governance-service.config:

yaml
entraTenantId: "<tenant-id>"

values.yaml -> guardian.config:

yaml
entraTenantId: "<tenant-id>"

Update secrets.yaml with the tenant ID.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
tenantId: "<tenant-id>"

2. Create Backend API App Registration

Create the backend API app registration:

bash
az ad app create \
  --display-name "Guardian Platform Backend" \
  --sign-in-audience AzureADMyOrg

Save the appId and id from the output:

  • appId is the Client ID
  • id is the Object ID

Store both as variables for use in subsequent commands:

bash
BACKEND_CLIENT_ID="<app-id-from-output>"
BACKEND_OBJECT_ID="<id-from-output>"

Create a service principal and set the application ID URI:

bash
az ad sp create --id $BACKEND_CLIENT_ID

az ad app update --id $BACKEND_CLIENT_ID \
  --identifier-uris "api://$BACKEND_CLIENT_ID"

Generate a unique ID for the API scope:

bash
uuidgen

Save this UUID — it will be used as SCOPE_ID in the next step.

Create a file named backend-scope.json with the following content. Replace SCOPE_ID with the UUID from above:

json
{
  "api": {
    "requestedAccessTokenVersion": 2,
    "oauth2PermissionScopes": [
      {
        "id": "SCOPE_ID",
        "adminConsentDescription": "Allow the application to access Guardian Platform on behalf of the signed-in user",
        "adminConsentDisplayName": "Access Guardian Platform",
        "isEnabled": true,
        "type": "User",
        "userConsentDescription": "Allow the application to access Guardian Platform on your behalf",
        "userConsentDisplayName": "Access Guardian Platform",
        "value": "access_as_user"
      }
    ]
  }
}

Add the API scope to the app registration:

bash
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$BACKEND_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body @backend-scope.json

Create a client secret for the backend app:

bash
az ad app credential reset \
  --id $BACKEND_CLIENT_ID \
  --append \
  --query password \
  --output tsv

Save the client secret — it cannot be retrieved later.

Update secrets.yaml with the backend credentials.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
clientId: "<backend-client-id>"
clientSecret: "<client-secret-from-output>"

Update values.yaml with the backend client ID (used in the scopes string).

values.yaml -> guardian.config:

yaml
entraScopes: "openid profile email offline_access api://<backend-client-id>/access_as_user"

3. Create Graph API Access App Registration

Create the Graph API access app registration:

bash
az ad app create \
  --display-name "Guardian Platform Graph API" \
  --sign-in-audience AzureADMyOrg

Save the appId from the output — this is the Graph API Client ID.

Store it as a variable for use in subsequent commands:

bash
GRAPH_CLIENT_ID="<app-id-from-output>"

Create a service principal for the app:

bash
az ad sp create --id $GRAPH_CLIENT_ID

Add the Microsoft Graph API permission for reading users:

bash
az ad app permission add \
  --id $GRAPH_CLIENT_ID \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions df021288-bdef-4463-88db-98f22de89214=Role

This adds User.Read.All (df021288-bdef-4463-88db-98f22de89214) – Read all users’ full profiles.

Grant admin consent for the API permissions (requires admin privileges):

bash
az ad app permission admin-consent --id $GRAPH_CLIENT_ID

Create a client secret for the Graph API app:

bash
az ad app credential reset \
  --id $GRAPH_CLIENT_ID \
  --append \
  --query password \
  --output tsv

Save the client secret — it cannot be retrieved later.

Update secrets.yaml with the Graph API credentials.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
graphClientId: "<graph-client-id>"
graphClientSecret: "<client-secret-from-output>"

4. Create Governance Worker App Registration

The Governance Worker is a confidential M2M (machine-to-machine) client used for automated governance workflow execution.

Create the Governance Worker app registration:

bash
az ad app create \
  --display-name "EQTY Governance Worker" \
  --sign-in-audience AzureADMyOrg

Save the appId from the output — this is the Worker Client ID.

Store it as a variable for use in subsequent commands:

bash
WORKER_CLIENT_ID="<app-id-from-output>"

Create a service principal and set the application ID URI. The Auth Service requests worker tokens for api://<worker-client-id>, and Entra rejects that request if the URI is not set:

bash
az ad sp create --id $WORKER_CLIENT_ID

az ad app update --id $WORKER_CLIENT_ID \
  --identifier-uris "api://$WORKER_CLIENT_ID"

Create a client secret for the Worker app:

bash
az ad app credential reset \
  --id $WORKER_CLIENT_ID \
  --append \
  --query password \
  --output tsv

Save the client secret — it cannot be retrieved later.

Generate an encryption key for the worker:

bash
openssl rand -base64 32

Update secrets.yaml with the worker credentials.

secrets.yaml -> global.secrets.governanceWorker.values:

yaml
encryptionKey: "<generated-key>"
clientId: "<worker-client-id>"
clientSecret: "<client-secret-from-output>"

5. Create Frontend SPA App Registration

Create the SPA app registration:

bash
az ad app create \
  --display-name "Guardian Platform Frontend" \
  --sign-in-audience AzureADMyOrg

Save the appId and id from the output:

  • appId is the SPA Client ID
  • id is the SPA Object ID

Store both as variables:

bash
SPA_CLIENT_ID="<app-id-from-output>"
SPA_OBJECT_ID="<id-from-output>"

Configure SPA redirect URIs and set token version to v2.0. Replace <domain> with the Guardian Platform domain:

bash
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$SPA_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body "{\"spa\":{\"redirectUris\":[\"https://<domain>\"]},\"api\":{\"requestedAccessTokenVersion\":2}}"

Create a service principal for the SPA app:

bash
az ad sp create --id $SPA_CLIENT_ID

Get the backend API scope ID:

bash
az ad app show --id $BACKEND_CLIENT_ID --query 'api.oauth2PermissionScopes[0].id' -o tsv

Save this scope ID — it will be used in the next command.

Grant the SPA access to the backend API. Replace <scope-id> with the value from above:

bash
az ad app permission add \
  --id $SPA_CLIENT_ID \
  --api $BACKEND_CLIENT_ID \
  --api-permissions <scope-id>=Scope

Optionally, grant admin consent for the API permission:

bash
az ad app permission admin-consent --id $SPA_CLIENT_ID

Note: Admin consent may require higher privileges. If this fails, grant consent later through the Azure Portal.

Update values.yaml with the SPA client ID.

values.yaml -> guardian.config:

yaml
entraClientId: "<spa-client-id>"

6. Generate the Token Signing Key

Generate an RSA private key for the Auth Service to sign the platform’s access tokens:

bash
openssl genrsa 2048

Update secrets.yaml with the key, including its BEGIN and END lines.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
tokenExchangePrivateKey: |
  -----BEGIN PRIVATE KEY-----
  ...
  -----END PRIVATE KEY-----

Alternative: Web UI Setup

Click to expand Azure Portal instructions

Using the Azure Portal

1. Create Backend API App Registration

  • Navigate to Azure Portal > Microsoft Entra ID > App registrations
  • Click “New registration”
  • Name: “Guardian Platform Backend”
  • Supported account types: “Accounts in this organizational directory only”
  • Click “Register”
  • Note the Application (client) ID and Directory (tenant) ID
  • Navigate to “Manifest”
  • Find “accessTokenAcceptedVersion” and set it to 2 (for v2.0 tokens)
  • Click “Save”
  • Navigate to “Expose an API”
  • Set Application ID URI: api://<client-id>
  • Add a scope:
    • Scope name: access_as_user
    • Admin consent display name: “Access Guardian Platform”
    • Admin consent description: “Allow the application to access Guardian Platform on behalf of the signed-in user”
    • State: Enabled
    • Click “Add scope”
  • Navigate to “Certificates & secrets”
  • Click “New client secret”
  • Description: “Guardian Platform Secret”
  • Expires: 24 months (or the organization’s policy)
  • Click “Add”
  • Copy the secret Value immediately — it won’t be shown again

Update values.yaml with the tenant ID and backend credentials.

values.yaml -> auth-service.config.idp:

yaml
issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
entra:
  tenantId: "<tenant-id>"

values.yaml -> governance-service.config:

yaml
entraTenantId: "<tenant-id>"

values.yaml -> guardian.config:

yaml
entraTenantId: "<tenant-id>"
entraScopes: "openid profile email offline_access api://<backend-client-id>/access_as_user"

Update secrets.yaml with the backend credentials.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
clientId: "<backend-client-id>"
clientSecret: "<client-secret>"
tenantId: "<tenant-id>"

2. Create Graph API Access App Registration

  • Create another app registration: “Guardian Platform Graph API”
  • Navigate to “API permissions”
  • Click “Add a permission”
  • Select “Microsoft Graph” > “Application permissions”
  • Add: User.Read.All
  • Click “Add permissions”
  • Click “Grant admin consent” (requires admin role)
  • Navigate to “Certificates & secrets”
  • Create a client secret
  • Save the secret value

Update secrets.yaml with the Graph API credentials.

secrets.yaml -> global.secrets.auth.entra.values:

yaml
graphClientId: "<graph-client-id>"
graphClientSecret: "<graph-client-secret>"

3. Create Governance Worker App Registration

  • Create app registration: “EQTY Governance Worker”
  • Supported account types: “Accounts in this organizational directory only”
  • No redirect URI needed (M2M client)
  • Click “Register”
  • Navigate to “Expose an API”
  • Set Application ID URI: api://<worker-client-id>
  • Navigate to “Certificates & secrets”
  • Create a client secret
  • Save the secret value

Generate an encryption key for the worker:

bash
openssl rand -base64 32

Update secrets.yaml with the worker credentials.

secrets.yaml -> global.secrets.governanceWorker.values:

yaml
encryptionKey: "<generated-key>"
clientId: "<worker-client-id>"
clientSecret: "<worker-client-secret>"

4. Create SPA App Registration

  • Create app registration: “Guardian Platform Frontend”
  • Navigate to “Manifest”
  • Find “accessTokenAcceptedVersion” and set it to 2 (for v2.0 tokens)
  • Click “Save”
  • Navigate to “Authentication”
  • Click “Add a platform” > “Single-page application”
  • Redirect URIs: https://<domain>
  • Click “Configure”
  • Navigate to “API permissions”
  • Click “Add a permission”
  • Select “My APIs”
  • Select “Guardian Platform Backend”
  • Check the access_as_user scope
  • Click “Add permissions”
  • Optional: Click “Grant admin consent”

Update values.yaml with the SPA client ID.

values.yaml -> guardian.config:

yaml
entraClientId: "<spa-client-id>"

Generate the token signing key as in step 6 of the manual CLI setup.

Create Organization and Admin User

Before deploying the platform, add the organization and admin user to values.yaml. The platform chart’s post-install hook creates them during the install:

yaml
# Add to values.yaml
entra:
  createOrganization: true
  organizationName: "your-org-name"
  displayName: "Organization Display Name"

  createPlatformAdmin: true
  platformAdminEmail: "admin@example.onmicrosoft.com" # Must exist in Entra
  tenantId: "<tenant-id>" # Omit to read tenant-id from the Entra secret

This will:

  • Create an organization in the governance database
  • Look up the Entra user by email via Graph API, using the Graph credentials in the Entra secret
  • Create a platform-admin user record linked to the Entra user

The hook fails if the Graph credentials are missing or the user cannot be found. It runs on every install and upgrade and never creates duplicate records. On an existing install it points the organization and the platform admin’s user record at Entra and makes the admin an active organization_owner again: other organization roles are kept, and project roles and project assignments in the organization are removed. It does not change an existing organization’s display name.

Verification

Verify the OIDC discovery endpoint:

bash
curl "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration"

Test token issuance with backend API credentials:

bash
curl -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=<backend-client-id>" \
  -d "client_secret=<backend-client-secret>" \
  -d "scope=api://<backend-client-id>/.default" \
  -d "grant_type=client_credentials"

Test Graph API access:

bash
curl -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=<graph-client-id>" \
  -d "client_secret=<graph-client-secret>" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials"

If both commands succeed and return access tokens, the Entra configuration is correct.

Security Best Practices

  • Use Certificate Authentication: For production, consider using certificates instead of client secrets
  • Rotate Secrets: Regularly rotate client secrets (recommended: every 6-12 months), update the Kubernetes secrets, and restart Auth Service so it picks up the new worker secret
  • Conditional Access: Implement Conditional Access policies for enhanced security
  • Multi-Factor Authentication: Require MFA for all users
  • Least Privilege: Only grant the minimum required Graph API permissions
  • Monitor Sign-ins: Enable sign-in logs and monitor for suspicious activity
  • App Consent Policies: Configure app consent policies to control which apps users can consent to

Troubleshooting

  • Bootstrap job fails to authenticate — Solution: Check the client-id and client-secret keys in entra-bootstrap-sp, entra.tenantId, and that admin consent was granted for the bootstrap service principal’s permissions
  • Bootstrap log says “Admin consent may need to be granted manually” — Expected: the job’s consent attempt failed; run the consent commands in step 5
  • No client secret in the credentials secret — Solution: The app already existed with a client secret, which the job keeps and cannot read. Create a new secret with az ad app credential reset --id <app-id> --append --query password --output tsv
  • Found more than one app registration named ... — Solution: Delete or rename the duplicate app registrations, or set a unique displayName under apps, and re-run
  • “AADSTS65001: The user or administrator has not consented to use the application” — Solution: Grant admin consent for API permissions in the Azure Portal
  • “AADSTS700016: Application not found in the directory” — Solution: Verify the client ID is correct and the app registration exists in the tenant
  • “Invalid scope” — Solution: Ensure the entraScopes configuration includes api://<backend-client-id>/access_as_user
  • Graph API returns 403 Forbidden — Solution: Verify admin consent was granted for Graph API permissions

Official Documentation