Skip to content

Security

The portfolio uses managed identities and Azure RBAC throughout — no long-lived credentials exist in code or containers.

Managed Identities

Each Container App has a user-assigned managed identity that is deployed unconditionally (even when deploy_containers=false) so role assignments propagate before the Container App is created.

Identity Resource Role assignments
dna-{env}-portfolio-we-id-fe Frontend Container App None (static file serving only)
dna-{env}-portfolio-we-id-be Backend Container App AcrPull on ACR, Key Vault Secrets User on KV

Managed identities use principalType: 'ServicePrincipal' in role assignment Bicep (not 'ManagedIdentity'). The principalId is read from the identity resource (identity_resource.properties.principalId), not from the Container App resource.

ACR Admin User

The Container Registry has adminUserEnabled: false. The backend managed identity is granted AcrPull — no shared admin credentials are used anywhere.

Key Vault

Key Vault is deployed with RBAC authorization mode (enableRbacAuthorization: true) — access policies are not used.

Soft delete and purge protection:

bicep
enableSoftDelete: true
softDeleteRetentionInDays: 7   // immutable after vault creation
enablePurgeProtection: true    // prevents permanent deletion during soft-delete window

Secrets cannot be permanently deleted until the 7-day retention window expires, even by administrators.

Granting yourself Key Vault access:

bash
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee <your-object-id-or-upn> \
  --scope $(az keyvault show --name <vault-name> --query id -o tsv)

Email Credentials Flow

EMAIL_ADDRESS and EMAIL_PASSWORD never appear as plaintext in code or logs:

  1. GitHub Secrets → Bicep @secure() parameters → stored as email-address / email-password Key Vault secrets on every infrastructure deploy
  2. Backend Container App pulls KV secrets at startup via keyVaultUrl + managed identity (secretRef)
  3. Backend reads os.getenv("EMAIL_ADDRESS") / os.getenv("EMAIL_PASSWORD") — falls back to demo mode (logs only) if placeholders are present

RBAC Summary

Principal Scope Role
Backend managed identity ACR AcrPull
Backend managed identity Key Vault Key Vault Secrets User
Backend managed identity App Configuration store App Configuration Data Owner
GitHub Actions OIDC (service principal) Subscription Contributor
GitHub Actions OIDC (service principal) Subscription Cost Management Reader

Admin Authentication

The portfolio admin API uses Entra ID (Microsoft Azure AD) for JWT-based authentication. Write endpoints (create/update/delete for projects, experience, skills) require a valid Entra access token from a member of the portfolio-admins security group.

JWT Validation Chain

Admin write endpoints use the require_admin_group dependency (api/auth.py). It tries each step in order:

Step Condition Behaviour
1. Dev bypass DISABLE_AUTH=true AND not prod URL Returns mock principal — local dev only
2. Legacy token LEGACY_ADMIN_TOKEN env var set Accepts static bearer token — migration window
3. fastapi-azure-auth BACKEND_APP_CLIENT_ID + AZURE_TENANT_ID set Validates auth-code-flow tokens issued by the app
4. PyJWT + JWKS Fallback when step 3 fails Validates standalone access tokens (e.g. from UIGen OAuth)

After token validation, the dependency checks ENTRA_ADMIN_GROUP_ID membership. If the groups claim is absent (Entra omits it when the user is in >200 groups), it falls back to a Microsoft Graph checkMemberGroups call.

UIGen Session Validation Endpoint

UIGen calls GET /auth/me after completing its OAuth code exchange to validate the session. This endpoint:

  • Validates the Bearer token using validate_entra_token (PyJWT + JWKS directly — not fastapi-azure-auth, which only accepts tokens from its own session flow)
  • Returns {"id": "<oid>", "email": "...", "name": "...", "role": "admin|guest"}
  • Is registered at both /api/auth/me and /auth/me in main.py — the UIGen shim proxy uses the unprefixed path

Entra App Registration Requirements

The Entra app registration must be configured correctly for tokens to validate:

  • requestedAccessTokenVersion: 2 — required to emit v2.0 tokens. PyJWT validates the issuer as https://login.microsoftonline.com/<tenant>/v2.0; v1.0 tokens have a different issuer format that fails validation.
  • SPA platform redirect URI — the localhost redirect URI must be registered on the SPA platform (not Web) for PKCE to work. The Web platform does not support PKCE's implicit grant avoidance, causing the authorization code exchange to fail in local dev.
  • api://<client-id>/admin scope — the admin scope is declared in the app manifest and used by SingleTenantAzureAuthorizationCodeBearer.

UIGen EasyAuth Exclusions (Dev)

On dev, the UIGen Container App uses EasyAuth (unauthenticatedClientAction: RedirectToLoginPage). The /auth/token and /auth/callback paths are excluded from the EasyAuth gate in container_apps.bicep. Without these exclusions, EasyAuth intercepts the OAuth token exchange and the login loop never completes.

Environment Variables

Variable Purpose Default
BACKEND_APP_CLIENT_ID Entra app client ID (JWT audience) "" — auth disabled
AZURE_TENANT_ID Entra tenant ID for JWKS URI + Graph ""
ENTRA_ADMIN_GROUP_ID Object ID of portfolio-admins group "" — any valid JWT accepted
LEGACY_ADMIN_TOKEN Static bearer token (migration window) "" — disabled
DISABLE_AUTH Bypass JWT validation in non-prod "false"

Troubleshooting

Container App can't pull image from ACR

The backend managed identity needs AcrPull on the ACR. Check role assignments:

bash
az role assignment list --assignee <identity-principal-id> --all

Backend can't read Key Vault secrets

Ensure the backend managed identity has Key Vault Secrets User on the vault:

bash
az role assignment list \
  --scope $(az keyvault show --name <vault-name> --query id -o tsv) \
  --all

az keyvault secret returns "Forbidden"

The vault uses RBAC (enableRbacAuthorization: true) — set-policy commands have no effect. Grant yourself Key Vault Secrets Officer at vault scope instead.

Admin API returns 401 / "Invalid or missing authentication token"

Token may be v1.0 format. Check the Entra app registration has requestedAccessTokenVersion: 2 in the manifest. v1.0 tokens have a different issuer (https://sts.windows.net/<tenant>/) that PyJWT rejects.

UIGen login loop never resolves on dev

EasyAuth is intercepting /auth/token or /auth/callback. Verify container_apps.bicep includes both paths in the UIGen EasyAuth exclusion list and redeploy infrastructure.

Next Steps

Page history

Field Value
Last updated 2026-05-19

Changelog

Date PRs Summary
2026-03-10 #66 Extracted from infrastructure.md; managed identities, RBAC, Key Vault, email credentials flow
2026-05-19 #384, #438, #439, #449, #451, #452 Add Entra ID admin auth section: JWT validation chain, UIGen session endpoint, PKCE requirements, EasyAuth exclusions, environment variables