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.ioFQDN. - 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./installonly 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:
- Bicep /
deploy.ps1— provisions resources + KV + initial bootstrap token. Auto-grants the deployer "Key Vault Secrets Officer" on the deployed KV. /installin 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.- 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;/installpicks 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, orauto(system handles it). - What it depends on (
requires) — used to grey out actions when prerequisites aren't met.
/install renders the manifest by:
- Walking every item and calling
check. - Grouping by section.
- Rendering each item as a checklist row with status icon + action affordance.
- Greyed-out actions on items whose
requiresaren'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), ORaz 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 createfor a new Foundry resource, thenaz cognitiveservices account deployment createfor 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 to2023-03-15-previewon 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:
-
create-entra-apps.ps1— single comprehensive script. Creates both app registrations, setsoptionalClaims(name,email,given_name,family_name,groups), setsgroupMembershipClaims = "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. -
register-redirect-uris.ps1— exists only for FQDN changes after install (custom domain added, region migration). Readskv: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.ps1already 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
/installwould 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/memberOffiltered 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 broaderGroup.Read.Alldelegated permission. - What's stored: the GUID + display name go into the KV secret value (e.g.,
47fb0b45-... # Mike Admins) so future renders of/installcan 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.
Consent flow for Graph permissions
/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:
-
A script in scripts/install/ — the canonical, idempotent path. This is what
/installoffers 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. -
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)
- Customer browses Azure Marketplace, finds Mike, clicks Deploy.
- Azure portal renders the Marketplace "Create" form. Customer picks subscription, region, resource group name, env tag (
prod). Clicks Create. - 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 (withKey Vault Secrets Userfor the app), grants the deployerKey Vault Secrets Officer. Bicep migration job runs once, schema gets to head. - Deployment completes. Portal "Outputs" tab shows:
installUrl,bootstrapToken. Customer opensinstallUrl. /installshows: paste-token form. Customer pastes the token. Now in bootstrap session.- Checklist renders. Foundations are mostly green (auto-provisioned). AI providers, Entra, tenant policy are yellow.
- Customer pastes their Anthropic + OpenAI keys → checklist items go green within a request cycle (page calls
/admin/reload-configafter each KV write). - 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.
- Script creates two app registrations in their AAD tenant, writes 4 KV secrets. Script idempotency means re-runs are safe.
- Customer refreshes
/install→ Entra items green except redirect-URI-current (still yellow because we haven't told AAD about the FQDN yet). - Customer downloads
register-redirect-uris.ps1, runs it. Now redirect-URI items green. - Customer pastes their admin group GUID. Item green.
- Customer clicks "Try sign-in." Browser redirects to Microsoft, customer signs in, lands authenticated.
- Backend's auth middleware notices the first valid Entra admin session. It deletes
install-bootstrap-tokenfrom KV, flipsbootstrap-retiredgreen. - Customer goes back to
/install(now Entra-gated). Sees full green checklist. - 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)
- Customer's Anthropic key has been compromised. They mint a new one in the Anthropic console.
- They sign in to
/installvia their existing Entra admin role. - Page renders the same checklist.
ai-anthropic-keyis currently green. - They click "Edit" on that row → paste the new key → save.
/installwrites to KV, calls/admin/reload-config. Backend cache flushed; next chat request reads the new key from KV.- Done. No revision restart, no redeploy.
C. Endpoint change / FQDN swap
- Customer adds a custom domain to the Container App via Azure portal:
mike.example.comnow points at the same Container App alongside the original*.azurecontainerapps.ioFQDN. - Customer signs in to
/install. kv-backend-public-urlis currently set to the old FQDN. Customer edits it → saves new FQDN./installwrites KV.- The
entra-redirect-uri-currentitem now goes yellow (Graph check fails: AAD redirect URI doesn't match the new FQDN). - Customer downloads
register-redirect-uris.ps1, runs it. Script readskv:backend-public-url, registers the new redirect URI on the Entra backend app, leaves the old one alone (idempotent). - 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.
config.ts+ cache + reload endpoint ✅ shipped — backend/src/lib/config.ts,getConfig/setConfig/flushConfigCache. 5-min TTL, env override priority, lazySecretClient.- Move existing env-var reads to
getConfig()in backend code ⏳ deferred — mechanical refactor across ~20 sites; tracked as a follow-up. Until done,/installwrites don't take effect without a revision restart (the env-shadowing caveat below). - Bicep changes ✅ committed, ⏳ not yet applied — keyvault.bicep gains
deployerPrincipalId+bootstrapTokenparams,install-bootstrap-tokenKV secret, deployer KV-Secrets-Officer role assignment,bootstrapTokenandinstallUrloutputs. Live state was applied via directazcommands;az deployment group createdeferred per issue 029 (live env-var drift). /installskeleton ✅ shipped — paste form, HMAC-signed session cookie (1h, HttpOnly, Secure, SameSite=Strict, path=/install), constant-time bootstrap-token compare viagetConfig.- 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. - 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. - 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. BackendGET /install/scripts/:nameserves them withContent-Disposition: attachmentafter session check. - Bootstrap → Entra handover middleware ✅ shipped —
/install/auth/microsoft/{start,callback}runs OIDC code flow against the existing frontend app reg, parses id_token claims, checksgroupsagainstentra-admin-group-ids, issues anentra-source install session AND blanksinstall-bootstrap-tokenon 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. - Remaining scripts ✅ shipped (combined with slice 7).
- 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.
getConfigprefersprocess.env.<UPPER_SNAKE>for backwards-compat, so/installwrites 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 runsaz containerapp revision restartwhen 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 reportinforather thanpass/failso the operator sees the gap honestly. - Bicep drift: live env vars added via
az containerapp updateover the dev session aren't all reflected in Bicep yet. Tracked in issue 029. The next cleanaz deployment group createrequires that reconciliation to land first; until then, Bicep changes are committed and applied via directazfor 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/installartefact that listing consumes. - Hard data-residency / sovereign-cloud variants. v1 targets Azure Commercial. Government cloud / national clouds are their own conversation.