The Atlas MikeOSS.Azure's docs, bound to the code — and to the migration that built it
108 documents

The migration, PRD by PRD

The trail of changes since the upstream fork: the milestone azure-migration issues in order, each paired with the code it produced. How Mike went from hosted-Supabase SaaS to a single-tenant Azure deployment.

Issue 023 — Install configurator (/install + downloadable scripts)

Goal

A first-time-install and ongoing-reconfiguration tool that:

  • Captures the bits of data Mike needs to operate (API keys, group IDs, etc.) from whoever's deploying it.
  • Configures the things the deployed Container App's Managed Identity is authorised to configure (Key Vault secrets, its own config).
  • Generates downloadable PowerShell scripts for the things the MI isn't authorised to do (Entra app registrations, AOAI Foundry deployments, anything that needs the operator's own AAD/subscription scope).
  • Detects drift, missing config, and required-state delta over the deployment's lifetime — same UI for v1 install, mid-life key rotation, and v2 upgrade catch-up.
  • Works for both Marketplace customers (zero CLI knowledge required) and developers pushing to a fresh Azure environment (no special CI dance).

Non-goals

  • Per-tenant runtime config (default models, UI prefs, feature flags). Those live in Postgres and are managed by a separate runtime admin UI — not by /install.
  • Per-user data management. The customer's own admin tools, not ours.
  • Custom domains and SSL. Deferred to v2. v1 ships with the *.azurecontainerapps.io FQDN.
  • Multi-tenant /install (one Container App serving many customers). Each customer gets their own Container App; this stays single-tenant.
  • Provisioning Azure resources from /install. That's Bicep's job. /install only configures resources Bicep already provisioned.

Architecture overview

                    ┌─────────────────────────────────────────────────────┐
                    │                  Customer's Azure                    │
                    │                                                       │
   Marketplace      │   ┌────────────┐  ┌─────────────┐  ┌─────────────┐  │
   "Deploy"  ──────►│   │  Bicep     │  │  Container  │  │  Key Vault  │  │
                    │   │  template  │─►│  App        │  │  (kv-mike)  │  │
   OR (devs)        │   │            │  │  (backend)  │  │             │  │
   deploy.ps1   ───►│   │            │  │             │◄►│  - all      │  │
                    │   └────────────┘  │  /install   │  │    config   │  │
                    │                    │             │  │  - secrets  │  │
                    │                    └──────┬──────┘  └──────▲──────┘  │
                    │                           │                 │         │
                    └───────────────────────────┼─────────────────┼─────────┘
                                                │                 │
                                            served HTML       writes from
                                                │             scripts (operator's
                                                ▼             az login)
                            ┌────────────────────────────┐         │
                            │    Operator's browser      │         │
                            │                            │         │
                            │   - paste bootstrap token  │         │
                            │   - work through checklist │         │
                            │   - download scripts ──────┼─────────┘
                            │   - paste KV values        │
                            └────────────────────────────┘

Three components:

  1. Bicep / deploy.ps1 — provisions resources + KV + initial bootstrap token. Auto-grants the deployer "Key Vault Secrets Officer" on the deployed KV.
  2. /install in the backend Container App — server-rendered HTML page that uses the Container App's Managed Identity to read/write KV, exposes a manifest-driven checklist of config items, serves downloadable scripts, and tracks the bootstrap → Entra auth handover.
  3. Downloadable PowerShell scripts — operator runs them locally with their own az login. Each script does work the MI can't (Entra app creation via Microsoft Graph, AOAI Foundry deployment, role assignments). Scripts write their results to KV; /install picks them up on next page refresh.

The 11 architectural decisions

Each row: option picked, terse rationale.

# Decision Pick Rationale (one line)
1 Form factor Bundled web UI in backend + downloadable PowerShell scripts UI handles MI-scope work; scripts handle external-AAD/subscription scope. Permissions split = surface split.
2 Channel between scripts and /install Key Vault Single source of truth, no second state store, handoff is "just write the secret," resumable across days for free.
3 First-time auth before Entra exists Bootstrap token generated by Bicep, surfaced via deployment outputs Concrete and self-contained, works in air-gapped tenants, mirrors MatterAI's defaultAdminPassword pattern.
4 State model Manifest-driven live observation — repo-shipped manifest + per-load probes against KV/Container App Drift-free by construction; upgrade flow automatic; reconfig = same code path as install.
5 Where operational config lives Key Vault, with process.env.X as dev-mode override Zero env vars on the Container App past KEY_VAULT_NAME + MI_CLIENT_ID. Runtime config changes never need a revision restart.
6 What the Marketplace "Create" form captures Marketplace minimal — Bicep essentials only (region, RG name, env tag) Single canonical config UX in /install. createUiDefinition.json stays tiny and rarely changes.
7 Script generation pattern Static scripts in repo, parametrized via CLI args Inspectable across customers, single signed bundle, args visible at invocation, idempotent re-runs.
8 Operator UX shape Free-form checklist (one page, all items, dependency-gated actions) Mirrors manifest model directly; reconfig = same UI; resumability free; honest about scope.
9 Bootstrap token lifecycle Alive until first Entra admin successfully signs in, then auto-invalidated Tied to verifiable real-world condition (not a clock); self-eclipses safely; single auth path post-install.
10 Operator's KV-write permission Bicep auto-grants Key Vault Secrets Officer to the deployer Friction-free; tied to the privileged provisioning act; least privilege within realistic scope; revocable via standard az role assignment delete.
11 Idempotency in scripts Strict idempotency — every "do thing" wrapped in a "check first" guard Re-runs converge on goal state; partial-state recovery automatic; operator never has to figure out which steps completed.
12 Config refresh in the running app TTL cache + explicit /admin/reload-config from /install Deterministic for /install UX; TTL backstop for out-of-band changes (scripts, manual az); no revision restart for routine config changes.

Manifest model

Source of truth lives in backend/src/lib/install/manifest.ts. TypeScript, type-checked, in code (not YAML — we want compile-time safety on check functions).

// backend/src/lib/install/manifest.ts

export type ManifestItem = {
  /** Stable id; never changes once assigned. */
  id: string;
  /** Human-readable for the checklist. */
  label: string;
  /** Visual grouping in the UI ("Foundations" | "External Azure setup" | "Tenant policy" | "Optional" | "Lifecycle"). */
  section: ManifestSection;
  /** False = optional; install can complete without it. */
  required: boolean;
  /** ids of items that must be green before this can be acted on. */
  requires: string[];
  /** Returns true when the item is satisfied in current observable state. */
  check: (ctx: InstallContext) => Promise<boolean>;
  /** How the operator fixes it when not satisfied. */
  fixedBy: FixedBy;
  /** When non-empty: change requires Container App revision restart, NOT just a config reload. */
  requiresRevisionRestart?: boolean;
};

export type FixedBy =
  | { type: "in-app-form"; fields: FormField[]; submitTo: "kv" }
  | { type: "external-script"; scriptName: string; argsTemplate: ArgTemplate }
  | { type: "auto"; description: string };  // e.g. "auto-flips green when first admin signs in"

export type InstallContext = {
  kv: KeyVaultClient;          // backed by Container App's MI
  containerAppFqdn: string;     // detected at runtime
  resourceGroup: string;
  subscriptionId: string;
  tenantId?: string;            // null until entra-tenant-id is set in KV
};

Each item knows:

  • How it's verified (check) — typically a KV-secret-presence check, sometimes more (e.g., "Entra app's redirect URI matches current FQDN").
  • How it's fixed (fixedBy) — either a form on /install (writes straight to KV) or a downloadable script with a parametrized command line, or auto (system handles it).
  • What it depends on (requires) — used to grey out actions when prerequisites aren't met.

/install renders the manifest by:

  1. Walking every item and calling check.
  2. Grouping by section.
  3. Rendering each item as a checklist row with status icon + action affordance.
  4. Greyed-out actions on items whose requires aren't all green.

Bootstrap → Entra handover sequence

Step 1 — Deploy
  Bicep generates: install-bootstrap-token = <random 48 char base64>
  Bicep writes KV secret: install-bootstrap-token = <token>
  Bicep grants deployer: Key Vault Secrets Officer on kv-mike-XXX
  Bicep output:
    - installUrl = https://<fqdn>/install
    - bootstrapToken = <token>
  Operator sees both in deployment "Outputs" tab (Marketplace)
  or in deploy.ps1 stdout (devs).

Step 2 — Enter
  Operator opens installUrl in browser → sees paste form.
  Pastes token. /install:
    - reads kv:install-bootstrap-token
    - constant-time-compares
    - issues a session cookie (1h, HttpOnly, Secure, SameSite=Strict)
    - cookie carries claim: source=bootstrap

Step 3 — Work the checklist
  Operator pastes API keys → /install writes to KV → reload-config.
  Operator downloads create-entra-apps.ps1 → runs it locally → script
    creates app regs in customer's tenant via Microsoft Graph, writes
    entra-backend-client-id, entra-client-id, entra-client-secret,
    entra-tenant-id to KV via the operator's az login + KV Secrets
    Officer grant from step 1.
  Operator refreshes /install → manifest re-checks → items go green.
  Operator pastes admin group GUID → /install writes to KV.
  Etc.

Step 4 — Cross the threshold
  All `required: true` items green. /install shows "Try sign-in" CTA.
  Operator clicks → redirects through normal Entra flow:
    /auth/select-provider?provider=microsoft → Microsoft → callback
    → backend issues Mike session token → / (the app, not /install).

Step 5 — Auto-retire bootstrap
  When the auth middleware validates the first session whose principal
  is in the configured admin group, it:
    - sets KV secret install-bootstrap-token = "" (or deletes it)
    - logs installer.bootstrap-retired event
    - flips the "Bootstrap retired" manifest item green
  From now on /install requires Entra admin role for any access.

Step 6 — Optional later: revoke installer KV access
  /install offers a "Revoke my installer access" item that downloads
  a one-line script: az role assignment delete --assignee <id> ...
  Operator runs it → KV Secrets Officer grant removed → operator can
  no longer write KV directly. Future config changes go through
  /install (which uses the Container App's MI).

Step 7 — Recovery (if needed)
  Customer locks themselves out (e.g. removed everyone from admin
  group). Subscription owner re-mints bootstrap:
    az keyvault secret set --vault-name kv-mike-X --name install-bootstrap-token --value <new>
  /install accepts the new token at the paste form. Cycle restarts.

Secret-ref restart caveat (transitional)

Until the env→KV-cache refactor lands, the deployed Container App's env vars are mostly secret-refs to KV (e.g., ANTHROPIC_API_KEY=secretref:anthropic-api-key). Updating the underlying KV secret does not auto-update the running revision — the secret value is resolved once at revision creation. To pick up a KV change in the running app you must either:

  • az containerapp revision restart -n <app> -g <rg> --revision <activeRev> (fast, in-place restart of the running pods), OR
  • az containerapp update --set-env-vars <name>=secretref:<same-name> (creates a new revision; takes 30-60s)

/install items that target KV secrets currently behind secret-refs (Anthropic, OpenAI, Gemini, AOAI, supabase, the entra-client-secret, the auth-state-secret) must trigger one of these after every KV write. Manifest items declare requiresRevisionRestart: true and /install calls the restart at the end of the form-submit handler.

This entire section dies once the env→KV-cache refactor lands — at that point the backend reads KV directly via getConfig(), secret-refs are removed, and flushConfigCache() is the only invalidation needed.

Config refresh flow

                       backend/src/lib/config.ts
   ┌──────────────────────────────────────────────────────────┐
   │                                                            │
   │  getConfig(key):                                           │
   │    1. envKey = key.upper().replace('-', '_')               │
   │    2. if process.env[envKey] is set: return it (dev path)  │
   │    3. cached = cache.get(key)                              │
   │    4. if cached and (now - cached.fetchedAt) < TTL_MS:     │
   │         return cached.value                                │
   │    5. fresh = await kv.getSecret(key)                      │
   │    6. cache.set(key, { value: fresh, fetchedAt: now })     │
   │    7. return fresh                                         │
   │                                                            │
   │  flushConfigCache(): cache.clear()                         │
   │                                                            │
   └──────────────────────────────────────────────────────────┘
                              ▲
                              │ called by:
                              │
   ┌──────────────────────────┴──────────────────────────────┐
   │                                                          │
   │  - POST /admin/reload-config  (after /install KV write) │
   │  - 5-min TTL backstop  (for out-of-band KV writes by    │
   │                         scripts, manual az, etc.)        │
   │                                                          │
   └──────────────────────────────────────────────────────────┘

TTL: 5 minutes. Adjustable via env var CONFIG_CACHE_TTL_SECONDS (default 300). Set to 0 in tests for deterministic per-call fetches.

Manifest items can flag requiresRevisionRestart: true when a config change can't be picked up live (e.g., changing a JWT signing key while requests are in flight). For those, /install shows a "Restart Container App revision" button after Save. Always opt-in, never automatic.


v1 manifest item catalog

All items live in backend/src/lib/install/manifest.ts as one exported array. Below is the v1 set.

Section: Foundations (auto-provisioned, mostly green from start)

id required check fixedBy
bicep-deployed y Container App's revision exists and serving auto
migrations-current y Postgres pgmigrations table contains every migration in backend/migrations/ auto (runs job-migrate at deploy)
mi-client-id y Container App env AZURE_CLIENT_ID is set AND matches the UAMI's clientId returned by Microsoft.ManagedIdentity/userAssignedIdentities/<name> auto (Bicep threads mi.outputs.miClientId into the env)
kv-postgres-admin-password y KV secret postgres-admin-password set auto (Bicep generates)
kv-postgrest-jwt-secret y KV secret postgrest-jwt-secret set auto (Bicep generates)
kv-auth-state-secret y KV secret auth-state-secret set auto (Bicep generates)
kv-diagnostics-token y if /admin/diagnostics/* is enabled (see issue 025) KV secret diagnostics-token set, length ≥ 32 auto (Bicep generates if diagnostic routes are mounted)
kv-backend-public-url y KV secret backend-public-url matches detected Container App FQDN auto (set on first /install load)

Note on mi-client-id: DefaultAzureCredential probes IMDS without a client-id hint by default. With a user-assigned MI (no system-assigned), IMDS responds with "Unable to load the proper Managed Identity" because more than one identity could in theory be selected. The Azure SDK auto-reads AZURE_CLIENT_ID when constructing ManagedIdentityCredential, so the install flow MUST verify this env var is present and matches the UAMI Bicep attached to the Container App. If they drift (e.g., operator manually swaps the attached MI without updating env), all server-side Azure calls — Blob upload, KV reads from the cache, Postgres MI auth — fail. Live drift detection here catches that immediately rather than at next blob upload.

Note on kv-diagnostics-token: The diagnostic page at /admin/diagnostics/postgrest is gated by this token (issue 025). Required in any environment where the diagnostic route is registered; safe to omit if the route is removed entirely. Token must be ≥ 32 chars to resist online brute force (the route returns no-detail 401s on mismatch but is reachable from the public ingress).

Section: AI providers (at least one required)

id required check fixedBy
ai-anthropic-key one of these 4 required KV secret anthropic-api-key set AND value matches /^sk-ant-/ in-app paste form (with format hint in the placeholder)
ai-openai-key one of these 4 required KV secret openai-api-key set AND value matches /^sk-(proj-)?/ in-app paste form
ai-gemini-key one of these 4 required KV secret gemini-api-key set AND value matches /^AIza/ in-app paste form
ai-aoai-config one of these 4 required KV secrets azure-openai-endpoint (URL format) AND azure-openai-api-key set external-script setup-aoai.ps1 (provisions the resource AND a deployment if none exists; can also connect to an existing AOAI/Foundry resource)

The "one of these 4 required" is implemented as a virtual aggregator item: ai-providers-min that's required, with check: any of {anthropic, openai, gemini, aoai} green.

Note on validation, not just presence: The original Bicep deploy stored secrets unvalidated, so an AOAI key got pasted into the openai-api-key slot during initial setup and showed green until first call. Manifest checks must validate format (prefix patterns above), not just non-empty. Otherwise customers can light up green checks that fail at runtime.

Note on setup-aoai.ps1: Capable of two modes selected via flag:

  • -ConnectExisting -Endpoint <url> -ApiKey <key> — write existing AOAI/Foundry credentials to KV.
  • -Provision -ResourceGroup <rg> -Region <region> -Model <gpt-4o|gpt-4o-mini|...> -Capacity <units>az cognitiveservices account create for a new Foundry resource, then az cognitiveservices account deployment create for one or more deployments, then write endpoint/key to KV. Region picker should validate against AOAI capacity availability (some regions throttle, some have model gaps).
  • The Foundry data-plane deployments-listing API (/openai/deployments?api-version=...) only responds to 2023-03-15-preview on Foundry SKUs — pin the listing call to that version regardless of the user's stored apiVersion (the inference path can use whatever is current).

Section: Entra ID

id required requires check fixedBy
entra-tenant-id y KV secret entra-tenant-id set external-script create-entra-apps.ps1
entra-backend-app y entra-tenant-id KV secrets entra-backend-client-id, entra-client-secret set external-script create-entra-apps.ps1 (same script handles both apps)
entra-frontend-app y entra-tenant-id KV secret entra-client-id set external-script create-entra-apps.ps1
entra-frontend-web-redirect-uri y entra-frontend-app, kv-backend-public-url Graph: frontend app's web.redirectUris includes ${fqdn}/auth/openid-callback/microsoft external-script register-redirect-uris.ps1
entra-frontend-spa-redirect-uri y entra-frontend-app, kv-backend-public-url Graph: frontend app's spa.redirectUris includes ${fqdn}/login external-script register-redirect-uris.ps1
entra-backend-scope y entra-backend-app KV secret entra-backend-scope set, format api://<id>/access_as_user auto (set by create-entra-apps.ps1)
entra-backend-optional-claims y entra-backend-app Graph: backend app's optionalClaims.accessToken[] and optionalClaims.idToken[] include name, email, given_name, family_name, groups; groupMembershipClaims == "SecurityGroup" external-script create-entra-apps.ps1 (claims are stamped at app creation)
entra-frontend-optional-claims y entra-frontend-app Graph: same shape on frontend app external-script create-entra-apps.ps1

Two scripts only

Every Entra item is operator-script-fixable; no in-app forms touch Entra. The design is two scripts, not three:

  1. create-entra-apps.ps1 — single comprehensive script. Creates both app registrations, sets optionalClaims (name, email, given_name, family_name, groups), sets groupMembershipClaims = "SecurityGroup", registers initial web + SPA redirect URIs from the current FQDN, exposes the API scope on the backend app, declares the required Graph delegated permissions on the frontend app, writes all six KV secrets (entra-tenant-id, entra-backend-client-id, entra-client-id, entra-client-secret, entra-backend-scope, plus the redirect URIs). Idempotent — re-runs converge to the desired shape.

  2. register-redirect-uris.ps1 — exists only for FQDN changes after install (custom domain added, region migration). Reads kv:backend-public-url, registers any new redirect URIs not already present, leaves old ones alone. Does NOT re-create apps or touch claims. The original install never runs this script — create-entra-apps.ps1 already covers initial registration.

There is no separate register-entra-claims.ps1. Splitting claims into a third script during the AOAI/blob debug session created a real-world ordering trap (operator runs creation, hits sign-in, gets Hi, amorgan instead of Hi, Allen Morgan, doesn't realise a separate claims script is needed). Folding claims into the creation script eliminates that class of failure.

One operator action per Entra concern: initial setup = one script, ongoing FQDN drift = one script. Optional-claims drift, redirect-URI-drift-not-caused-by-FQDN, and scope drift are caught by the manifest's drift checks and fixed by re-running the relevant script. Operators never assemble Entra config piecemeal.

Note on which app gets redirect URIs: The frontend (client) app reg owns the redirect URIs because it's the OIDC client. Two platforms, two URIs:

  • Web platform for backend-driven OIDC code flow → callback at /auth/openid-callback/microsoft
  • SPA platform for MSAL token flows in the browser → callback at /login

The backend app reg is just a protected resource (used as the scope target). It does not need redirect URIs at all.

Note on Graph patching: az ad app update exposes --web-redirect-uris but not --spa-redirect-uris. The script must use az rest --method patch --uri https://graph.microsoft.com/v1.0/applications/{objectId} with both web.redirectUris and spa.redirectUris in the body. Both arrays must include any pre-existing URIs (e.g., localhost entries from local dev) — overwrite-don't-merge semantics.

Note on optional claims: v1.0 access tokens omit name by default — without the optional claim, entra.ts falls back to email-prefix and the user sees "Hi, amorgan" instead of "Hi, Allen Morgan." Both backend AND frontend app regs need the same claim list for the OIDC code flow + MSAL token paths to behave consistently. Patched via the same Graph PATCH idiom — full optionalClaims object replaces the existing one, so the script must merge in any pre-existing entries.

Section: Tenant policy

id required check fixedBy
entra-admin-group-id y KV secret entra-admin-group-ids set, GUID format, AND group exists in customer's tenant (verified via Graph) in-app group picker only — Graph-backed, no GUID-paste path
entra-member-group-id optional same shape as admin in-app group picker only
tenant-onboarding-mode y KV secret tenant-onboarding-mode is auto or manual in-app radio (default manual for prod, auto for dev)

Group selection — invariants

The group picker is the ONLY path. /install MUST NOT show a GUID-paste input as a fallback or as an "advanced" option. Reasons:

  • Operators don't know their group GUIDs by heart. Asking for one is asking them to leave /install, navigate Azure portal to AAD → Groups, find the right group, click into it, copy the objectId, switch back to /install, paste it. Then verify they got the right one. This is a multi-minute context-switch and a normal source of typos / pasting wrong GUIDs.
  • Pasted GUIDs are unverified. Even with our manifest check ("does this GUID exist in the tenant?"), wrong-but-valid GUIDs are accepted — operator pastes the GUID of "Marketing Team" thinking it's "Mike Admins," manifest goes green, six weeks later nobody can sign in as admin and we're debugging it backwards.
  • Marketplace customers expect picker UX. Every other Azure-native install flow (Storage account picker, KV picker, Subscription picker) is a searchable picker. A raw GUID input slot in our /install would scream "this is a developer tool" to enterprise reviewers.
  • Even competent operators make mistakes copying GUIDs. GUIDs are 36 chars of opaque hex with hyphens — almost designed to be misread.

Group picker implementation

The picker lives in /install itself, server-rendered, calls Microsoft Graph from the backend (using the operator's bootstrap session as the auth context, NOT the Container App's MI — the MI doesn't have user delegation). Behaviour:

  • Default view: lists groups the operator is a member of (GET /v1.0/me/memberOf filtered to security groups). Most operators want to pick from groups they already know — typically the same group they're using for "everyone who can administer the deployment."
  • Search: typed query expands the search to all groups in the tenant (GET /v1.0/groups?$filter=securityEnabled eq true and startswith(displayName,'<query>')). Requires the broader Group.Read.All delegated permission.
  • What's stored: the GUID + display name go into the KV secret value (e.g., 47fb0b45-... # Mike Admins) so future renders of /install can show the human label without re-querying Graph. The backend uses only the GUID portion for runtime auth checks.

When Graph access is blocked

Some tenant policies disable user-delegated Graph reads. If /install gets a 403 from Graph, DO NOT offer a GUID-paste fallback. Instead, the manifest item shows:

⚠ Admin group
  We can't list your tenant's groups — your Microsoft Entra tenant
  policy may be blocking Graph delegated reads from this app.

  To unblock:
   1. A Global Admin or Privileged Role Admin grants the "Mike
      Installer" SPA app the delegated permission "Group.Read.All"
      OR explicitly approves the consent prompt below.
   2. [Grant consent now]  ← link to the AAD consent endpoint
   3. After consent, refresh this page.

  If your tenant prohibits all delegated Graph reads from third-
  party apps, contact your AAD admin — they will need to set the
  group ID in Key Vault directly. /install does not accept GUIDs
  pasted into a form by an unprivileged user.

The "AAD admin sets it in KV directly" escape hatch only exists for the rare locked-down tenants — it's a documented manual path, not a UI-visible option in /install. If the rare customer hits this, they go through standard az keyvault secret set from a privileged terminal.

/install's server-side group picker requires:

  • User.Read (default — already granted at first sign-in)
  • GroupMember.Read.All (for /me/memberOf — usually grantable by the user themselves)
  • Group.Read.All (for tenant-wide search — typically requires admin consent)

The backend SPA app reg should declare these as required delegated permissions. On first /install use of the picker, AAD prompts for consent; admin consent grant button appears for higher-scope permissions.

create-entra-apps.ps1 configures these required permissions on the frontend SPA app reg as part of app creation, so the consent prompt fires correctly at first picker use.

Section: Lifecycle

id required check fixedBy
bootstrap-retired y KV secret install-bootstrap-token is empty/missing AND at least one Entra admin sign-in logged auto (flips on first admin sign-in)
installer-access-revoked optional Operator no longer has KV Secrets Officer on this KV external-script revoke-installer-access.ps1

Section: Optional

id required check fixedBy
download-signing-secret y in production KV secret download-signing-secret set, length ≥ 16 auto (Bicep generates if NODE_ENV=production)

v1 total: ~23 items. Sufficient to install Mike end-to-end with Entra auth + at least one AI provider + tenant policy gate.


Operator action principle: scripts first, manual docs as companion

Anything that can be done by script must be a downloadable script. No /install items where the operator's only path is "follow these steps in the Azure portal." The point of /install is that operators don't have to assemble multi-step Azure config in their heads.

For each operator-fixable item, the artefact set is:

  1. A script in scripts/install/ — the canonical, idempotent path. This is what /install offers as the action button. It encapsulates every API call (Graph, ARM, KV writes), every retry, every prerequisite check. CLI args parameterise it; everything else is hard-coded to the current target shape. Tested.

  2. A companion doc in docs/install/scripts.md — the optional manual fallback. For operators who want to inspect what the script does before running it, run a subset by hand, or operate inside a tenant whose policy bans running unsigned community scripts. The doc describes each step the script takes (which Graph endpoint, what payload, why), in operator-meaningful language — not a regenerated transcript of the script source.

Operators who don't read the doc just run the script — same outcome, faster. Operators who do read it can audit, adapt, or reproduce the steps in a more locked-down environment. The doc never becomes the primary path because it can't be tested as rigorously as the script itself; it's a reading aid.

Doc-and-script sync: when the script changes shape (new Graph call, new KV secret written, args renamed), the corresponding doc section is updated in the same commit. Treat them as a paired artefact like a function and its tests — drift between them is a bug.

Items currently in scope for this rule:

Action Script Doc section
Create Entra app registrations + claims + redirect URIs create-entra-apps.ps1 docs/install/scripts.md#create-entra-apps
Re-register redirect URIs after FQDN change register-redirect-uris.ps1 docs/install/scripts.md#register-redirect-uris
Provision or connect Azure OpenAI / Foundry setup-aoai.ps1 docs/install/scripts.md#setup-aoai
Revoke operator's installer access revoke-installer-access.ps1 docs/install/scripts.md#revoke-installer-access
Reset install state (destructive) reset-install.ps1 docs/install/scripts.md#reset-install

In-app forms still exist for items the operator can complete entirely by pasting a value: API keys, tenant onboarding mode. These go straight to KV via the Container App's MI; no script needed because there's no external API to call.

The decision tree:

  • Does this require the operator's own AAD/subscription scope (Graph, ARM, role assignments outside KV)? → Script (with companion doc).
  • Is it just a value that goes into KV? → In-app paste form.
  • Is it derived from observable runtime state? → auto (no operator action).

Operator workflow walkthroughs

A. First-time install (Marketplace customer)

  1. Customer browses Azure Marketplace, finds Mike, clicks Deploy.
  2. Azure portal renders the Marketplace "Create" form. Customer picks subscription, region, resource group name, env tag (prod). Clicks Create.
  3. Bicep deploys: Container App env, backend Container App, KV (with generated install-bootstrap-token, postgres-admin-password, postgrest-jwt-secret, auth-state-secret, download-signing-secret), MI (with Key Vault Secrets User for the app), grants the deployer Key Vault Secrets Officer. Bicep migration job runs once, schema gets to head.
  4. Deployment completes. Portal "Outputs" tab shows: installUrl, bootstrapToken. Customer opens installUrl.
  5. /install shows: paste-token form. Customer pastes the token. Now in bootstrap session.
  6. Checklist renders. Foundations are mostly green (auto-provisioned). AI providers, Entra, tenant policy are yellow.
  7. Customer pastes their Anthropic + OpenAI keys → checklist items go green within a request cycle (page calls /admin/reload-config after each KV write).
  8. Customer clicks "Download create-entra-apps.ps1." Page also shows the exact command line with their KV name + FQDN filled in. Customer copies command, runs it in a PowerShell window.
  9. Script creates two app registrations in their AAD tenant, writes 4 KV secrets. Script idempotency means re-runs are safe.
  10. Customer refreshes /install → Entra items green except redirect-URI-current (still yellow because we haven't told AAD about the FQDN yet).
  11. Customer downloads register-redirect-uris.ps1, runs it. Now redirect-URI items green.
  12. Customer pastes their admin group GUID. Item green.
  13. Customer clicks "Try sign-in." Browser redirects to Microsoft, customer signs in, lands authenticated.
  14. Backend's auth middleware notices the first valid Entra admin session. It deletes install-bootstrap-token from KV, flips bootstrap-retired green.
  15. Customer goes back to /install (now Entra-gated). Sees full green checklist.
  16. Optional: customer clicks "Revoke my installer access" — downloads one-line script that removes the KV Secrets Officer grant. Now the customer's identity has only the role assignments they had before deploying Mike.

Total operator time: ~15 minutes including waiting for AAD propagation.

B. Mid-life key rotation (six months later)

  1. Customer's Anthropic key has been compromised. They mint a new one in the Anthropic console.
  2. They sign in to /install via their existing Entra admin role.
  3. Page renders the same checklist. ai-anthropic-key is currently green.
  4. They click "Edit" on that row → paste the new key → save.
  5. /install writes to KV, calls /admin/reload-config. Backend cache flushed; next chat request reads the new key from KV.
  6. Done. No revision restart, no redeploy.

C. Endpoint change / FQDN swap

  1. Customer adds a custom domain to the Container App via Azure portal: mike.example.com now points at the same Container App alongside the original *.azurecontainerapps.io FQDN.
  2. Customer signs in to /install.
  3. kv-backend-public-url is currently set to the old FQDN. Customer edits it → saves new FQDN. /install writes KV.
  4. The entra-redirect-uri-current item now goes yellow (Graph check fails: AAD redirect URI doesn't match the new FQDN).
  5. Customer downloads register-redirect-uris.ps1, runs it. Script reads kv:backend-public-url, registers the new redirect URI on the Entra backend app, leaves the old one alone (idempotent).
  6. Refresh /install → all green.

What goes where

Layer Purpose Examples
Code defaults (compiled into image) Hard-coded fallbacks; behaviour the app's developers control DEFAULT_TITLE_MODEL, DEFAULT_TABULAR_MODEL, ALLOWED_MODEL_IDS regex
process.env (Container App) Bootstrap-only — points the app at its KV and identity KEY_VAULT_NAME, AZURE_CLIENT_ID (the UAMI clientId — required by DefaultAzureCredential for KV/Blob/Postgres MI auth), NODE_ENV. Nothing else. Local dev: .env files set whatever the developer wants for offline work.
Key Vault (kv-mike-XXX) All deployment-wide config and secrets that /install manages entra-tenant-id, entra-client-secret, anthropic-api-key, backend-public-url, entra-admin-group-ids, tenant-onboarding-mode, etc.
Postgres Per-tenant policy + per-user data + audit log tenants table, user_profiles, per-tenant feature flags, audit events. Not managed by /install — runtime admin UI / app endpoints.

The env→KV fallback in getConfig() lets local dev keep using .env files exactly as today; production paths read KV exclusively.


Deferred to v2

Feature Why deferred
Custom domain + SSL Requires DNS setup + certificate provisioning. Customer can do it post-install via standard Container Apps custom domain feature, then update kv:backend-public-url.
Multi-tenant /install One Container App per customer is the standard SaaS shape. Multi-tenant install would require tenant-scoped KV partitioning, multiple bootstrap tokens, way more complexity.
Secrets-rotation UI (auto-rotation schedule, expiry warnings) v1 supports manual rotation via /install edits. Auto-rotation is its own ticket.
Foundry deployment auto-provisioning v1: setup-aoai-deployment.ps1 is a thin wrapper around az cognitiveservices account deployment create. v2 could capture quota negotiation, model availability checks, multi-region failover.
Per-tenant config UI (the runtime admin surface) Different concern, different ticket. Lives on the runtime app, not in /install.
Health / synthetic test ("attempt a real sign-in and confirm it works end-to-end" before declaring install complete) Nice-to-have validation; not blocking.
Operator audit log surfacing in /install UI Logs go to container stdout in v1; UI surfacing (and a real telemetry sink) is v2 polish.

File layout when implemented

backend/
  src/
    lib/
      config.ts                     # env→KV helper + cache
      install/
        manifest.ts                 # the v1 catalog
        checks.ts                   # individual check() implementations
        scripts/                    # script-template helpers
        types.ts                    # ManifestItem, FixedBy, etc.
    routes/
      install.ts                    # GET /install, POST /install/auth,
                                    # POST /install/items/:id, GET /install/scripts/:name,
                                    # POST /admin/reload-config
    middleware/
      install-auth.ts               # bootstrap-or-entra gate, retire-on-first-admin

scripts/install/                    # downloadable from /install
  create-entra-apps.ps1
  register-redirect-uris.ps1
  setup-aoai-deployment.ps1
  revoke-installer-access.ps1
  reset-install.ps1                 # destructive; only via "Start over" flow

infra/modules/
  keyvault.bicep                    # add deployerPrincipalId param + role assignment
  install-bootstrap.bicep           # generates + stores bootstrap-token, exports as deployment output

docs/install/
  README.md                         # operator-facing walkthrough (links from /install header)
  scripts.md                        # what each downloadable script does, exact args

Implementation slicing (rough)

Not strict ordering — natural grouping for parallel work.

  1. config.ts + cache + reload endpoint ✅ shipped — backend/src/lib/config.ts, getConfig / setConfig / flushConfigCache. 5-min TTL, env override priority, lazy SecretClient.
  2. Move existing env-var reads to getConfig() in backend code ⏳ deferred — mechanical refactor across ~20 sites; tracked as a follow-up. Until done, /install writes don't take effect without a revision restart (the env-shadowing caveat below).
  3. Bicep changes ✅ committed, ⏳ not yet applied — keyvault.bicep gains deployerPrincipalId + bootstrapToken params, install-bootstrap-token KV secret, deployer KV-Secrets-Officer role assignment, bootstrapToken and installUrl outputs. Live state was applied via direct az commands; az deployment group create deferred per issue 029 (live env-var drift).
  4. /install skeleton ✅ shipped — paste form, HMAC-signed session cookie (1h, HttpOnly, Secure, SameSite=Strict, path=/install), constant-time bootstrap-token compare via getConfig.
  5. Manifest model + check runner ✅ shipped — backend/src/lib/install/types.ts + manifest.ts, sectioned renderer with pass/fail/info badges, action pills per FixedBy shape.
  6. First handful of in-app-form manifest items ✅ shipped — AI keys (Anthropic/OpenAI/Gemini), backend-public-url, tenant-onboarding-mode. Each writes to KV via the UAMI's Key Vault Secrets Officer grant; redirect-with-flash UX surfaces the env-shadowing caveat honestly.
  7. First downloadable script + remaining scripts ✅ shipped — five scripts under scripts/install/: create-entra-apps.ps1, register-redirect-uris.ps1, setup-aoai.ps1, revoke-installer-access.ps1, reset-install.ps1. Companion docs at docs/install/scripts.md. Backend GET /install/scripts/:name serves them with Content-Disposition: attachment after session check.
  8. Bootstrap → Entra handover middleware ✅ shipped — /install/auth/microsoft/{start,callback} runs OIDC code flow against the existing frontend app reg, parses id_token claims, checks groups against entra-admin-group-ids, issues an entra-source install session AND blanks install-bootstrap-token on first admin sign-in. Group picker (admin + member) is server-rendered HTML with embedded JS that calls Microsoft Graph via the main-app's MSAL token from localStorage — picker-only per the design invariant.
  9. Remaining scripts ✅ shipped (combined with slice 7).
  10. Marketplace createUiDefinition.json + listing prep ⏳ not yet — separate ticket when Marketplace publication starts.

Known transitional caveats

  • Env-shadowing: most KV secrets are also wired as Container App secret-ref env vars. getConfig prefers process.env.<UPPER_SNAKE> for backwards-compat, so /install writes go to KV but the backend keeps serving the env-cached value until the next revision restart. Slice 2 is the permanent fix; until then the in-app form's flash banner explains this and the operator runs az containerapp revision restart when they want the change live.
  • Trust-script checks: a few items (entra-frontend-redirect-uris, installer-access-revoked) currently report status based on the presence of a related KV secret rather than a Graph/ARM round-trip that would catch external drift. Slice 9 deepens these to real round-trips. Until then, items report info rather than pass/fail so the operator sees the gap honestly.
  • Bicep drift: live env vars added via az containerapp update over the dev session aren't all reflected in Bicep yet. Tracked in issue 029. The next clean az deployment group create requires that reconciliation to land first; until then, Bicep changes are committed and applied via direct az for one-off changes.

What this issue does NOT cover

  • The runtime per-tenant admin UI. Out of scope; separate ticket when needed.
  • The Marketplace listing's review process and publishing. Belongs in 016-marketplace-listing.md. This ticket produces the /install artefact that listing consumes.
  • Hard data-residency / sovereign-cloud variants. v1 targets Azure Commercial. Government cloud / national clouds are their own conversation.