Skip to content

STG Deployment Runbook — Azure Container App + Auth

Audience: whoever actually provisions docgen's staging environment. This is the ordered, human-executed sequence — Terraform plan/apply, az ad app calls, Key Vault seeding — plus the resulting env values each side needs. It does not run anything itself; every command below is meant to be copy-pasted and reviewed by a person.

Modeled on: altaforge-ra and altaforge-sdp's existing stg/canadacentral stacks in altaforge-infrastructure. See the Terraform this runbook drives:

  • azure/modules/apps/docgen/backend/ — the Container App module
  • azure/environments/stg/canadacentral/docgen/{base,keyvault,backend,auth}/ — the four stacks
  • azure/modules/common/app-registration/ — wraps the Entra app-registration resources used by docgen/auth

Status as of writing: nothing has been applied. This is a definition + plan, not a completed deployment. Nothing in altaforge-infrastructure has been committed either — the Terraform lives on disk pending review.


0. Unknowns to resolve before step 1

Do not silently invent answers to these — confirm with the platform/infra owner first.

  1. No Dockerfile / image-build pipeline exists for altaforge-docgen yet. docgen/pyproject.toml defines the package (uv + hatchling-style uv_build, Python 3.11, fastapi/uvicorn deps, docgen.server:app entrypoint on port 8769) but there is no Dockerfile, no docker-compose*.yaml, and no CI step that builds/pushes an image (checked .github/workflows/{ci,altaforge-enforce,test-frontend}.yml — none reference Docker). This must be written before terraform apply on docgen/backend can succeed — the stack references afglobalacrshared01.azurecr.io/docgen/backend:staging-latest, which does not exist. Model the Dockerfile on altaforge-ra's Dockerfile.backend (also confirm WORKDIR matches what the migration job's uv run alembic upgrade head expects — docgen/alembic.ini has script_location = src/docgen/data/migrations, so the image's workdir must be the docgen/ package root).
  2. Whether docgen's consumers share one Entra tenant. RA/SDP/Control-Center canadacentral apps use the altaforgecan CIAM tenant (cfb62d3f-ffc1-4758-959f-3ead9e73182c, per docs/work-logs/2026-07-01-vs-stg-auth-tf-audit.md and control-center/auth/terraform.tfvars). It is unconfirmed whether client-site, altaml-dealdesk, Odyssey, and Tomorrow Law all authenticate against this same tenant. If any consumer lives in a different tenant, cross-tenant client-credentials needs either a multi-tenant sign_in_audience on docgen's API app registration or an explicit enterprise-app (service principal) provisioning of the consumer's app in docgen's tenant — a materially bigger change than what's defined here.
  3. Whether docgen needs its own user-facing app registration at all. docgen has no login UI (docs/solutions/README.md: "every org is a multi-tenant slice of the same DocGen engine", consumed headlessly). The delegated-user path (AZURE_APP_CLIENT_ID / JwtAuthBackend) may end up meaning "accept a token minted for the CALLING app" (i.e., AZURE_APP_CLIENT_ID = client-site's own app id) rather than a docgen-owned registration. The Terraform defines a docgen-owned user app registration (docgen_user_app in auth/main.tf) as the simplest starting point; revisit if a consumer needs docgen to accept its own existing user tokens instead.
  4. Whether client-site is a confirmed docgen consumer yet. docgen/docs/solutions/_candidates/site-resource-group.md — filed under _candidates, not a top-level solution folder like odyssey/tomorrow-law/altaml-dealdesk. Confirm SITE's status before wiring its production client-credentials grant.
  5. Subscription / resource names. This runbook uses the subscription id already live in altaforge-infrastructure (ea95e48f-8bc9-40e6-8061-612a90ca50df, altaml-maz-invent) and the naming module's output for af-stg-canadacentral-* — these are the real, already-in-use names for this repo, not invented. The Entra tenant id and the four consumer client ids below ARE placeholders (CHANGE_ME) — they must come from whoever owns those app registrations, never guessed.
  6. Container sizing. The task that produced this runbook asked to "confirm sizing" now that docgen's progress store is DB-backed. azure/modules/apps/docgen/backend defaults to cpu=0.5, memory=1Gi, min_replicas=1, max_replicas=3 — the same starting point RA/SDP backends use, deliberately conservative. There is no load data for docgen yet; treat these as a starting point to tune after the first real stg traffic, not a validated number.

1. Terraform — infrastructure layer

Run from altaforge-infrastructure/azure/environments/stg/canadacentral/docgen/. Deploy in this order (mirrors the RA/SDP "base → keyvault → backend" sequence in the root README's Stack Deployment Order):

az login
az account set --subscription ea95e48f-8bc9-40e6-8061-612a90ca50df

# 1. Resource group + managed identity
cd base && terraform init && terraform plan && terraform apply
cd ..

# 2. Pod Key Vault (+ role assignment granting the docgen identity read access)
cd keyvault && terraform init && terraform plan && terraform apply
cd ..

STOP — seed the pod Key Vault before applying backend/. backend/main.tf reads these via kv_secret_refs at runtime (not at plan time — no data "azurerm_key_vault_secret" plaintext-into-state anti-pattern):

KV=$(terraform -chdir=keyvault output -raw key_vault_name)

az keyvault secret set --vault-name "$KV" --name "app-insights-connection-string" --value "<from Layer 3 application-insights stack output>"
az keyvault secret set --vault-name "$KV" --name "docgen-db-connection-string"    --value "postgresql+psycopg://<user>:<pass>@<shared-psql-host>/docgen"
az keyvault secret set --vault-name "$KV" --name "docgen-anthropic-api-key"      --value "<staging Anthropic API key — direct key, not the LiteLLM gateway; see docgen CLAUDE.md LLM row>"
# Only needed if azure_m2m_scope is set in backend/terraform.tfvars (docgen calling ANOTHER altaforge-auth service m2m):
az keyvault secret set --vault-name "$KV" --name "docgen-m2m-client-secret" --value "<client secret from that OTHER service's app registration, if applicable>"

Provision the docgen database on the shared Postgres server per the standard AltaForge pattern (docs/standards/naming-and-architecture.md §5.2, CREATE DATABASE docgen; CREATE ROLE docgen_app ...) before setting docgen-db-connection-string.

# 3. Entra app registrations (see §2 below for the full auth plan before running this)
cd auth && terraform init && terraform plan && terraform apply
cd ..
terraform -chdir=auth output docgen_user_app
terraform -chdir=auth output docgen_api_app

Copy application_id from docgen_user_app and docgen_api_app, and caller_role_id/caller_role_value from docgen_api_app, into backend/terraform.tfvars (azure_app_client_id, azure_api_app_client_id) and record them for §2's consumer-side grants.

# 4. Backend Container App + migration job
cd backend && terraform init && terraform plan && terraform apply
cd ..
terraform -chdir=backend output backend_fqdn
terraform -chdir=backend output migration_job_name

Run the migration job before the first real traffic hits the new revision (same pre-deploy gate pattern as RA — see the CI/CD Architecture Guide referenced in modules/common/migration-job/main.tf):

RG=$(terraform -chdir=base output -raw resource_group_name)
JOB=$(terraform -chdir=backend output -raw migration_job_name)
az containerapp job start --name "$JOB" --resource-group "$RG"

2. Azure AD app-registration plan

What gets created

Registration Purpose Terraform Consumed by
stg-canadacentral-docgen User-facing app — delegated user tokens auth/main.tfmodule.docgen_user_app docgen's JwtAuthBackend (AZURE_APP_CLIENT_ID)
stg-canadacentral-docgen-api API/resource app — m2m client-credentials audience, exposes app role docgen.api.caller auth/main.tfmodule.docgen_api_app docgen's ClientCredentialsAuthBackend (AZURE_API_APP_CLIENT_ID), and every consumer's ServiceTokenProvider (mode=m2m, SERVICE_TOKEN_M2M_SCOPE=api://<docgen_api_app client id>/.default)

This is a new pattern for this repo — every existing app (control-center, VerticalScope trio, altaml/dealdesk) uses one shared registration for both delegated and (dormant) service auth. docgen genuinely needs the split because it has two distinct real callers (a signed-in human via a consumer app, and a consumer's own backend calling headlessly) validated by two different backend classes (JwtAuthBackend vs ClientCredentialsAuthBackend, routed by CompositeAuthBackend on the aud claim — see vendor/altaforge-libraries/altaforge/src/altaforge/auth/backends.py). There is no existing shared tenant/app pattern for m2m in this repo to reuse; this is the first one.

Per-consumer grant (repeat for each of the 4 apps)

For client-site, altaml-dealdesk, client-odyssey, client-tomorrowlaw, each needs its own existing (or new) app registration to be granted the docgen.api.caller application role on stg-canadacentral-docgen-api, so its ServiceTokenProvider (mode=m2m) can obtain a token docgen's ClientCredentialsAuthBackend accepts.

DOCGEN_API_APP_ID="<docgen_api_app.application_id from auth/ output>"
DOCGEN_API_SP_ID="<docgen_api_app.service_principal_object_id from auth/ output>"
ROLE_ID="<docgen_api_app.caller_role_id from auth/ output>"

# For each consumer app CONSUMER_SP_ID (its enterprise application / service
# principal object id — NOT its application/client id):
CONSUMER_SP_ID="<consumer's service principal object id>"

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${CONSUMER_SP_ID}/appRoleAssignments" \
  --body "{\"principalId\": \"${CONSUMER_SP_ID}\", \"resourceId\": \"${DOCGEN_API_SP_ID}\", \"appRoleId\": \"${ROLE_ID}\"}"

This is the actual enforcement point — Entra will not issue a docgen-api-audience token to a service principal that has no app-role assignment on it (service_principal_app_role_assignment_required = true on docgen_api_app). Adding api_access (a requiredResourceAccess entry) on each consumer's own app registration is optional/documentary — it makes the grant visible in the consumer app's "API permissions" portal blade, but the role assignment above is what Entra actually checks.

If any of these 4 consumer apps already has its own Terraform-managed app registration in altaforge-infrastructure (none currently reference docgen — checked), prefer adding an azuread_app_role_assignment resource there over a one-off az rest call, so the grant is reproducible.

Env vars — which side gets which id

Env var Value Set on
AZURE_TENANT_ID the Entra tenant id (§0 unknown #2) docgen backend
AZURE_APP_CLIENT_ID docgen_user_app.application_id docgen backend
AZURE_API_APP_CLIENT_ID docgen_api_app.application_id docgen backend
SERVICE_TOKEN_MODE m2m each consumer app (client-site, dealdesk, odyssey, tomorrowlaw)
SERVICE_TOKEN_M2M_TENANT_ID same tenant id as above each consumer app
SERVICE_TOKEN_M2M_CLIENT_ID the CONSUMER's own app (client) id each consumer app
SERVICE_TOKEN_M2M_CLIENT_SECRET a client secret minted on the CONSUMER's own app registration each consumer app (secret store, never committed)
SERVICE_TOKEN_M2M_SCOPE api://<docgen_api_app.application_id>/.default each consumer app

(SERVICE_TOKEN_* names are defined in vendor/altaforge-libraries/altaforge/src/altaforge/auth/client/config.py.)


3. Resulting values to feed each side

docgen's apps/web/.env.local (local) → Key Vault + Container App env (stg)

Already covered by §1's az keyvault secret set + backend/terraform.tfvars edits above. Summary of the full auth-relevant env set docgen's backend needs in stg:

DOCGEN_ENV=staging
DATABASE_URL=<from docgen-db-connection-string KV secret>
ANTHROPIC_API_KEY=<from docgen-anthropic-api-key KV secret>
AZURE_TENANT_ID=<tenant id>
AZURE_APP_CLIENT_ID=<docgen_user_app.application_id>
AZURE_API_APP_CLIENT_ID=<docgen_api_app.application_id>

DISABLE_AUTH must NOT be set in stg — docgen removed its /auth/dev-login bypass (docgen#363) and now validates real JWTs only.

Each consumer app's runtime env (client-site, altaml-dealdesk, client-odyssey, client-tomorrowlaw)

SERVICE_TOKEN_MODE=m2m
SERVICE_TOKEN_M2M_TENANT_ID=<tenant id>
SERVICE_TOKEN_M2M_CLIENT_ID=<that consumer's own app/client id>
SERVICE_TOKEN_M2M_CLIENT_SECRET=<that consumer's own client secret — Key Vault / GitHub Secret, never committed>
SERVICE_TOKEN_M2M_SCOPE=api://<docgen_api_app.application_id>/.default

For client-site specifically (the repo this task originated from): this maps to backend/.env → whatever secret store client-site's own deploy uses (see client-site/CLAUDE.md's Quick Start — backend/.env locally; production wiring for client-site's own Azure deployment is out of scope for this runbook and not yet defined in altaforge-infrastructure as of this writing).