Entra App Registration
Learn how to create the three Entra ID app registrations FDAI needs - fdai-api
(the Operator API audience), fdai-console-spa (the SPA sign-in client), and
fdai-approval-bot (the Teams approval client) - plus the App Roles, service
principals, and role assignments that make sign-in and Teams on-behalf-of (OBO)
authorization work. This runbook covers both the local sign-in test
(console/README.md § Local sign-in test) and the
deploy-time setup referenced by
deploy-and-onboard.md and
user-rbac-and-identity.md § 10.
Customer-agnostic: every id below is a shell variable or a
<placeholder>. Never paste a real tenant, app, or scope GUID into a tracked file - keep them in a gitignored.env.localor a secret store.
What gets created
Section titled “What gets created”| Registration | Purpose | Key settings |
|---|---|---|
fdai-api | Web API audience for the console, ChatOps backend, and internal attachment intake. | Application ID URI api://<api-app-id>; one delegated scope access; five human App Roles and one application-only attachment role; v2 access tokens. |
fdai-console-spa | SPA sign-in client (MSAL, PKCE). | SPA redirect URIs; delegated permission to fdai-api’s access scope. |
fdai-approval-bot | Teams approval client. | Delegated permission to fdai-api’s access scope for OBO tokens; no client secret is created by this runbook. |
None holds the executor identity - that is a separate user-assigned Managed Identity (security-and-identity.md).
Prerequisites
Section titled “Prerequisites”-
azlogged in to the target tenant. Confirm before every step:Terminal window az account show --query "{sub:id, tenant:tenantId, user:user.name}" -o json -
A directory role that may create app registrations and grant admin consent (Application Administrator or Cloud Application Administrator, or Global Administrator).
-
For automated deployment, the self-hosted runner Managed Identity is an owner of
fdai-console-spaand has the Microsoft GraphApplication.ReadWrite.OwnedByapplication permission with admin consent. This lets the workflow update only applications owned by that identity.
Configure protected Entra control readback
Section titled “Configure protected Entra control readback”fdaictl provision entra keeps human approval evidence separate from control
readback. It reads the signed-in human identity and current aw-approvers
membership first, using the original Azure CLI context. A process-wide
serialized boundary keeps this human phase and the nested executor phase from
interleaving with another identity operation. The command then validates the
target-profile v2 executor token and exact active tenant/subscription before
reading the Premium plan, five exact role groups, Conditional Access, Access
Reviews, authentication methods, Azure Policy assignments, executor identity,
and every app/group plan refresh. Initial observation, approval revalidation,
claim recovery, and receipt readback use the same executor boundary. A missing
or mismatched executor context, target, permission, or readback blocks the
operation; the command never falls back to the human context for these reads.
The deployment owner must grant the executor service principal the
least-privilege Microsoft Graph application permissions required by those
read endpoints and complete tenant admin consent before running the command.
This includes AccessReview.Read.All for Access Review definitions and
Policy.Read.AuthenticationMethod for the authentication-method policy. The
executor also needs Azure RBAC permission to read policy assignments at the
profile-bound scope.
Don’t retry these application permissions with an interactive Azure CLI scope.
The Azure CLI human sign-in uses a Microsoft first-party client that cannot
dynamically request them and returns AADSTS65002. Provisioning only verifies
the preconfigured permissions and controls; it never grants Graph permissions,
performs admin consent, or mutates an identity control.
1. Create fdai-api
Section titled “1. Create fdai-api”# Single-tenant API app.API_APPID=$(az ad app create \ --display-name "fdai-api" \ --sign-in-audience AzureADMyOrg \ --query appId -o tsv)
# Five human App Roles plus one application-only attachment role.python3 - <<'PY' > /tmp/fdai_approles.jsonimport json, uuidroles = [ ("Reader", "View the operator console", ["User"]), ("Contributor", "Reader plus author draft governance PRs", ["User"]), ("Approver", "Contributor plus review and approve governance PRs and HIL", ["User"]), ("Owner", "Full administration of the fork's control plane", ["User"]), ("BreakGlass", "Segregated emergency access (never auto-activated)", ["User"]), ( "Document.ChannelAttachment.Submit", "Submit bounded channel attachments to the internal ingestion intake", ["Application"], ),]print(json.dumps([{ "allowedMemberTypes": members, "description": d, "displayName": n, "id": str(uuid.uuid4()), "isEnabled": True, "value": n,} for n, d, members in roles]))PYaz ad app update --id "$API_APPID" --app-roles @/tmp/fdai_approles.jsonaz ad app update --id "$API_APPID" --identifier-uris "api://$API_APPID"Add the access delegated scope and force v2 access tokens (so iss is the
.../v2.0 issuer the verifier defaults to):
API_OBJID=$(az ad app show --id "$API_APPID" --query id -o tsv)SCOPE_GUID=$(python3 -c "import uuid; print(uuid.uuid4())")python3 - "$SCOPE_GUID" <<'PY' > /tmp/fdai_api_scope.jsonimport json, sysprint(json.dumps({"api": { "requestedAccessTokenVersion": 2, "oauth2PermissionScopes": [{ "id": sys.argv[1], "adminConsentDescription": "Allow the console to call the fdai Operator API on behalf of the signed-in operator", "adminConsentDisplayName": "Access the fdai Operator API", "userConsentDescription": "Allow the console to call the fdai Operator API on your behalf", "userConsentDisplayName": "Access the fdai Operator API", "isEnabled": True, "type": "User", "value": "access", }],}}))PYaz rest --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJID" \ --headers "Content-Type=application/json" \ --body @/tmp/fdai_api_scope.json2. Create fdai-console-spa
Section titled “2. Create fdai-console-spa”SPA_APPID=$(az ad app create \ --display-name "fdai-console-spa" \ --sign-in-audience AzureADMyOrg \ --query appId -o tsv)SPA_OBJID=$(az ad app show --id "$SPA_APPID" --query id -o tsv)
# Seed local Vite origins here. The deploy workflow adds the deployed console# HTTPS origin after Terraform creates the Static Web App.SCOPE_GUID=$(az ad app show --id "$API_APPID" \ --query "api.oauth2PermissionScopes[?value=='access'].id | [0]" -o tsv)python3 - "$API_APPID" "$SCOPE_GUID" <<'PY' > /tmp/fdai_spa.jsonimport json, sysprint(json.dumps({ "spa": {"redirectUris": ["http://localhost:5273", "http://127.0.0.1:5273"]}, "requiredResourceAccess": [{ "resourceAppId": sys.argv[1], "resourceAccess": [{"id": sys.argv[2], "type": "Scope"}], }],}))PYaz rest --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$SPA_OBJID" \ --headers "Content-Type=application/json" \ --body @/tmp/fdai_spa.json3. Create fdai-approval-bot
Section titled “3. Create fdai-approval-bot”APPROVAL_BOT_APPID=$(az ad app create \ --display-name "fdai-approval-bot" \ --sign-in-audience AzureADMyOrg \ --query appId -o tsv)APPROVAL_BOT_OBJID=$(az ad app show --id "$APPROVAL_BOT_APPID" --query id -o tsv)
python3 - "$API_APPID" "$SCOPE_GUID" <<'PY' > /tmp/fdai_approval_bot.jsonimport json, sysprint(json.dumps({ "requiredResourceAccess": [{ "resourceAppId": sys.argv[1], "resourceAccess": [{"id": sys.argv[2], "type": "Scope"}], }],}))PYaz rest --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$APPROVAL_BOT_OBJID" \ --headers "Content-Type=application/json" \ --body @/tmp/fdai_approval_bot.jsonThe protected bootstrap preserves unrelated delegated permissions, creates the
service principal, and verifies the exact fdai-api scope. Tenant consent,
Teams installation, the Bot resource, its managed identity, and the
group-connected approval destination remain separate provider-hosted steps.
Keep local redirect URIs synchronized
Section titled “Keep local redirect URIs synchronized”The console: prepare full stack task reads the local tenant and SPA client values from
console/.env.local, then safely retries both fixed Vite origins through
scripts/deployment/azure/sync-entra-spa-redirect.py. The helper preserves every existing
redirect. Its --allow-loopback-http option accepts HTTP only for localhost or 127.0.0.1;
the default command remains HTTPS-only. A tenant mismatch or insufficient Microsoft Graph
permission stops preparation before the local services start.
Keep the deployed redirect URI synchronized
Section titled “Keep the deployed redirect URI synchronized”Set these GitHub Actions repository variables for each deployment target:
| Variable | Value |
|---|---|
AZURE_TENANT_ID | Target Entra tenant id. |
ENTRA_CONSOLE_SPA_CLIENT_ID | Application client id of that tenant’s fdai-console-spa. |
When deploy-dev.yml runs with both apply=true and deploy_console=true, it
reads console_default_hostname from Terraform and runs
scripts/deployment/azure/sync-entra-spa-redirect.py. The helper:
- Verifies that the active Azure CLI tenant equals
AZURE_TENANT_ID. - Preserves every existing SPA redirect URI and adds the deployed HTTPS origin only when it is missing.
- Reads the app registration again and fails the deployment if the new URI is not visible.
The operation is safe to retry (idempotent). A different subscription in the same tenant uses the same tenant-local app registration. A deployment to a different tenant needs that tenant’s SPA client id and a runner identity owned by that tenant. Missing variables, a tenant mismatch, or insufficient Graph permission stops the deployment instead of leaving sign-in partially configured.
4. Service principals + role assignment
Section titled “4. Service principals + role assignment”# Enterprise apps (needed for App Role assignment + admin consent).az ad sp create --id "$API_APPID"az ad sp create --id "$SPA_APPID"az ad sp create --id "$APPROVAL_BOT_APPID"
# Assign a user the Reader App Role on fdai-api (repeat per user/role).USER_OBJID=$(az ad signed-in-user show --query id -o tsv) # or another user's idAPI_SP_OBJID=$(az ad sp show --id "$API_APPID" --query id -o tsv)READER_ROLE_ID=$(az ad app show --id "$API_APPID" \ --query "appRoles[?value=='Reader'].id | [0]" -o tsv)python3 - "$USER_OBJID" "$API_SP_OBJID" "$READER_ROLE_ID" <<'PY' > /tmp/fdai_assign.jsonimport json, sysprint(json.dumps({"principalId": sys.argv[1], "resourceId": sys.argv[2], "appRoleId": sys.argv[3]}))PYaz rest --method POST \ --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$API_SP_OBJID/appRoleAssignedTo" \ --headers "Content-Type=application/json" \ --body @/tmp/fdai_assign.json
# One-time admin consent so a signed-in user gets no consent prompt.az ad app permission admin-consent --id "$SPA_APPID"az ad app permission admin-consent --id "$APPROVAL_BOT_APPID"For a real deployment, assign the App Roles to the five aw-* Entra security
groups instead of to individual users
(user-rbac-and-identity.md § 4.4).
After the dedicated Operator channel-edge Managed Identity exists, assign only the application-only attachment role to its service principal. Don’t assign a human FDAI role to this identity.
EDGE_CLIENT_ID=<channel-edge-managed-identity-client-id>EDGE_SP_OBJID=$(az ad sp show --id "$EDGE_CLIENT_ID" --query id -o tsv)ATTACHMENT_ROLE_ID=$(az ad app show --id "$API_APPID" \ --query "appRoles[?value=='Document.ChannelAttachment.Submit'].id | [0]" -o tsv)python3 - "$EDGE_SP_OBJID" "$API_SP_OBJID" "$ATTACHMENT_ROLE_ID" <<'PY' \ > /tmp/fdai_channel_attachment_assign.jsonimport json, sysprint(json.dumps({"principalId": sys.argv[1], "resourceId": sys.argv[2], "appRoleId": sys.argv[3]}))PYaz rest --method POST \ --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$API_SP_OBJID/appRoleAssignedTo" \ --headers "Content-Type=application/json" \ --body @/tmp/fdai_channel_attachment_assign.jsonEnable the internal channel intake before enabling attachments at the edge. Edge startup requests the exact API audience and calls the authenticated intake probe. A missing role definition, assignment, or audience keeps the edge unready.
5. Map ids to configuration
Section titled “5. Map ids to configuration”The values from the steps above feed the runtime config. Keep them out of tracked files.
| Value | Operator API env | SPA env (Vite) |
|---|---|---|
| Tenant id | FDAI_ENTRA_TENANT_ID | VITE_MSAL_TENANT_ID |
api://$API_APPID | FDAI_API_AUDIENCE | - |
api://$API_APPID/access | - | VITE_MSAL_API_SCOPE |
$SPA_APPID | - | VITE_MSAL_CLIENT_ID |
$APPROVAL_BOT_APPID | FDAI_TEAMS_APPLICATION_ID | - |
Operator API verifier env: deploy-and-onboard.md
(FDAI_ENTRA_TENANT_ID, FDAI_API_AUDIENCE, optional FDAI_ENTRA_ISSUER /
FDAI_ENTRA_JWKS_URI). SPA env: console/README.md § Fork configuration.
6. Verify
Section titled “6. Verify”az ad app show --id "$API_APPID" \ --query "{uri:identifierUris, tokenVer:api.requestedAccessTokenVersion, \ scopes:api.oauth2PermissionScopes[].value, roles:appRoles[].value}" -o jsonaz ad app show --id "$SPA_APPID" \ --query "{spa:spa.redirectUris, perms:requiredResourceAccess[].resourceAppId}" -o jsonaz ad app show --id "$APPROVAL_BOT_APPID" \ --query "{perms:requiredResourceAccess[].resourceAppId}" -o jsonThen run the local sign-in test in
console/README.md: a request with no token returns
401; a signed-in user with no App Role returns 403; a user with Reader
loads the console.
Teardown
Section titled “Teardown”az ad app delete --id "$SPA_APPID"az ad app delete --id "$APPROVAL_BOT_APPID"az ad app delete --id "$API_APPID"Deleting the app registrations also removes their service principals and role assignments. Rotate any client secret first if one was added. The flows above add none: the SPA is a public client, the API validates tokens, and approval-bot authentication remains a deployment-owned managed-identity binding.