Skip to content

Fork Guide

This guide takes you from a blank Azure subscription to a running FoundryGate deployment. Budget two hours if you’re comfortable with Azure; longer if this is your first time with Bicep or APIM.

Prerequisites

  • Azure CLI (az) ≥ 2.60
  • Bicep CLI (az bicep install)
  • .NET 10 SDK
  • GitHub CLI (gh) ≥ 2.40
  • An existing Azure API Management instance
  • An existing Azure AI Foundry account with at least one model deployment

  1. Fork and clone the repo

    Terminal window
    gh repo fork kolatts/foundry-gate --clone
    cd foundry-gate
  2. Register an Entra App

    Terminal window
    az ad app create \
    --display-name "FoundryGate" \
    --sign-in-audience AzureADMyOrg

    Note the appId — you’ll need it as entraClientId in the Bicep params.

    Add the Admin app role in the Azure portal under your app registration → App roles → Create:

    • Display name: FoundryGate Admin
    • Value: FoundryGate.Admin
    • Allowed member types: Users/Groups

    Graph API permissions are granted later, not here. The Entra sync calls Microsoft Graph as a user-assigned managed identity (created by the Bicep deploy) — there is no client secret. After the first infra deploy, grant the Graph application roles Application.Read.All, User.Read.All and GroupMember.ReadBasic.All as app-role assignments on the Microsoft Graph service principal to both id-foundrygate-api-{env} and id-foundrygate-func-{env}. Both, because the nightly EntraSyncFunction calls Graph as the Functions identity — grant only the API’s and every nightly run fails with Authorization_RequestDenied while the on-demand sync in the UI works fine, which is a confusing way to find out. The Owner Setup Runbook has the loop that does both; #120 has a PowerShell equivalent. Then set Entra__Enabled=true on both hosts.

  3. Configure APIM

    In your APIM instance:

    a. Create a product named foundrygate (or your preferred name — record it as apimProductId).

    b. Add an API that backends to your Foundry endpoint:

    • Backend URL: https://{your-foundry-name}.openai.azure.com/
    • URL scheme: HTTPS
    • Add this API to the foundrygate product.

    c. Apply the token metric policy on the API (All operations):

    <policies>
    <inbound>
    <base />
    <llm-emit-token-metric
    namespace="foundrygate"
    output-type="SubscriptionId" />
    </inbound>
    </policies>

    d. Apply the token limit policy (optional but recommended for rate safety):

    <azure-openai-token-limit
    tokens-per-minute="500000"
    counter-key="@(context.Subscription.Id)"
    estimate-prompt-tokens="true" />

    e. Grant APIM access to Foundry:

    Terminal window
    az role assignment create \
    --role "Cognitive Services User" \
    --assignee <apim-managed-identity-principal-id> \
    --scope <foundry-resource-id>
  4. Set Bicep parameters

    Copy infra/parameters/dev.bicepparam and fill in your values:

    param location = 'eastus2'
    param environmentName = 'dev'
    param entraClientId = '<your-app-registration-client-id>'
    param entraTenantId = '<your-tenant-id>'
    param apimResourceId = '/subscriptions/.../Microsoft.ApiManagement/service/...'
    param apimProductId = 'foundrygate'
    param foundryResourceId = '/subscriptions/.../Microsoft.CognitiveServices/accounts/...'
  5. Create the GitHub Environments and the CI identity (OIDC)

    The workflows deploy through four GitHub Environments. Create them in your fork (repo Settings → Environments, or with gh api -X PUT repos/<you>/foundry-gate/environments/<name>):

    EnvironmentProtection
    devnone — deploys automatically on merge
    productionrequired reviewer; deploys from protected branches only
    dev-destroyrequired reviewer + 5 min wait
    prod-destroyrequired reviewers + 30 min wait; protected branches only

    Then one app registration for GitHub Actions with a federated credential per environment — no client secret anywhere:

    Terminal window
    az ad app create --display-name "foundrygate-github-ci" --sign-in-audience AzureADMyOrg
    # for each of dev, production, dev-destroy, prod-destroy:
    az ad app federated-credential create --id <appObjectId> --parameters '{
    "name": "foundrygate-dev",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:YOUR_ORG/foundry-gate:environment:dev",
    "audiences": ["api://AzureADTokenExchange"]
    }'

    Its service principal needs Owner on the subscription (the template is subscription-scope and creates role assignments), AcrPush on the registry once it exists, and membership of the SQL Entra admin group (Azure SQL is Entra-only — there is no password).

  6. Add the GitHub Environment variables

    There are no secrets. The workflows read a handful of identifiers as Environment variables and resolve every resource name from the infra deployment’s outputs:

    Variabledevproductiondestroy envs
    AZURE_CLIENT_ID<appId><appId><appId>
    AZURE_TENANT_ID<tenantId><tenantId><tenantId>
    AZURE_SUBSCRIPTION_ID<subscriptionId><subscriptionId><subscriptionId>
    FG_ENTRA_API_CLIENT_IDAPI app registration (step 2)same—
    FG_ENTRA_WEB_CLIENT_IDBlazor SPA app registrationsame—
    FG_SQL_ADMIN_GROUP_OBJECT_ID / _NAME—your production SQL admin group—

    Until AZURE_CLIENT_ID is set the deploy workflows fail with a message pointing here rather than an Entra error, and PR jobs (what-if, UI preview) skip. Full list and rationale: CI/CD reference.

  7. Deploy the whole stack once (day 0)

    Actions → Deploy All → Run workflow → environment: dev, tick create-model-deployments. This is the only run that creates the Claude deployments — they are create-once under ARM, so every later run leaves the box unticked. OpenAI deployments need no flag: ARM reconciles them on every run. The chain is infra → database (dacpac + seed) → API → Functions and UI in parallel → post-deploy tests → a summary with every URL. Expect 20–30 minutes; APIM v2 provisioning is most of it.

    To do the same by hand, see Infrastructure → Deploying.

  8. From now on, merge to main

    A merge that touches anything deployable runs the same deploy-all.yml chain against dev — one workflow, in order, every time, so a change shared between the API and the Functions can never deploy half of itself. PRs still get a Bicep what-if comment for infra/** changes; docs go to GitHub Pages from their own workflow. To redeploy one component, dispatch API Deploy, Functions Deploy, UI Deploy or Infra Deploy. Production is Deploy All with environment: production, behind the reviewer gate.

  9. Verify

    • Navigate to the Static Web App URL (from infra output or Azure portal).
    • Sign in with an Entra account.
    • You should land on /me with a fresh QuotaAllocation and a provisioned APIM key.
    • Assign yourself the FoundryGate.Admin app role in Entra and refresh — the admin nav appears.

Troubleshooting

APIM returns 401 on first AI call
Check that the foundrygate product is published and your APIM subscription is in active state. Run GET /keys/me to confirm the key was provisioned.

POST /users/sync or POST /groups/{id}/sync-entra returns 503
Entra__Enabled is still false on the API — the response says so, and names the Graph roles. Turn it on once the roles below are granted. (It is a 503 rather than a 400 because nothing is wrong with the request: the host has not been configured for the feature.)

POST /users/sync fails with 500 (Authorization_RequestDenied in the API logs)
The API’s managed identity (id-foundrygate-api-{env}) is missing one of the Graph application roles Application.Read.All, User.Read.All, GroupMember.ReadBasic.All. Grant them as app-role assignments on the Microsoft Graph service principal (Owner Setup Runbook §6); no restart is needed. If the on-demand sync works but the nightly one fails the same way, it is the other identity — id-foundrygate-func-{env} needs the same three roles.

POST /users/sync returns skippedGroupAssignmentCount > 0 and never deactivates anyone
Group assignments are expanded to their members (#121), so this count is 0 on a healthy tenant. Non-zero means the API could not read one of the assigned groups — usually a missing GroupMember.ReadBasic.All, sometimes a group that has been deleted. The run saw only part of your developer population, so it deliberately deactivates nobody; the group is named in the API log and in the users.synced audit row. Fix the role (or unassign the deleted group) and re-run.

Container App shows “no healthy upstream”
The API is still starting or the migration hasn’t run. Check the Container App log stream and verify the SQL connection string Key Vault reference resolved correctly.