fix: heal migration-ledger mismatch and make Azure SSO deploy-proof (#18)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 39s
On Pull Request to Main / publish (pull_request) Successful in 1m9s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m1s

PocketBase on Labs crash-looped since the SSO deploy (PR #17): mounting
--migrationsDir for the first time replayed the entire migration history
against a database that was provisioned out-of-band (empty _migrations
ledger) and died on 1778948471_created_content.js. On top of that the
team_members->auth migration had its own crash paths and trapped OAuth2
config inside a one-shot, env-dependent migration.

- pb_migrations/1000000000_baseline_ledger_sync.js: detects an
  out-of-band provisioned DB (schema exists, ledger empty) and marks the
  79 historical migrations as applied; no-op on fresh or already-synced DBs
- pb_migrations/1781000000_team_members_to_auth.js: idempotency guard,
  relation->text conversion WITH data preservation (PocketBase diffs
  fields by id, so the column is backed up and restored via SQL), unique
  index rebuild, no silent catches, env only as fast-path
- pb_hooks/entra_oidc.pb.js + pb_hooks/utils.js: reconcile the Entra OIDC
  provider from ENTRA_* env on every bootstrap + cron tick
  (compare-before-save, warn-once); heals environments that migrated
  without secrets and supports secret rotation without re-apply
- pb_hooks/team_members.pb.js: require() pattern — JSVM runs callbacks as
  isolated programs, top-level helpers are not in scope (adopts Leroy's
  fix from the fix-sso branch)
- infra/*/site/deploy-playbook.yml: health-gate after compose up — the
  deploy fails loudly with container logs when PocketBase does not become
  healthy (runs #83-#88 were green while PB crash-looped)
- docker-compose.yml: .env.local is optional again
- docs/auth-spec.md + AI_AGENT.md: ledger/reconciler documentation, ADRs
  006-008, never-rename-applied-migrations warning

Verified locally against PocketBase v0.30.4 with a 7-scenario DoD matrix
(fresh DB +/- env, out-of-band DB with data incl. data preservation,
populated-ledger upgrade, late-secrets healing, re-run guard, hook
provisioning): 15/15 pass. npm test 112/112.

Closes #18

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-07-11 11:14:09 +02:00
parent 89d3395a62
commit d79e69aad2
10 changed files with 478 additions and 84 deletions

34
pb_hooks/entra_oidc.pb.js Normal file
View File

@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #18 — Reconcile the Entra (Azure) OIDC provider from the environment.
//
// The team_members→auth migration (pb_migrations/1781000000) is structure-only
// and runs exactly once. Provider CONFIGURATION lives here instead, so that:
//
// * an environment whose migration applied while the ENTRA_* secrets were
// absent gets its provider enabled on the next startup (no re-apply),
// * rotating ENTRA_CLIENT_SECRET / changing the tenant only requires new env
// values and a container restart,
// * a fresh database gets its provider on the first cron tick right after
// the migrations created the collection (onBootstrap fires BEFORE the
// migrations run — there is no post-migration lifecycle hook in the JSVM,
// hence the cron fallback).
//
// The reconciler compares before saving, so both hooks are cheap no-ops when
// everything is already in sync.
onBootstrap((e) => {
e.next();
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
// reconcileEntraOidc logs a warn-once itself when the ENTRA_* env is absent.
console.log("entra_oidc reconcile (bootstrap): " + reconcileEntraOidc(e.app));
});
cronAdd("entraOidcReconcile", "* * * * *", () => {
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
const result = reconcileEntraOidc($app);
// Only log state changes — this tick runs every minute.
if (result === "updated") {
console.log("entra_oidc reconcile (cron): OIDC provider (re)configured from ENTRA_* env");
}
});

View File

@@ -15,17 +15,16 @@
// The allow-list is a comma-separated list of e-mail addresses, e.g.:
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
//
// Keep this logic in sync with src/lib/azureAuth.js (resolveRole / parseAdminEmails),
// which carries the canonical, unit-tested version for the frontend/tests.
function resolveRole(email) {
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
return allow.indexOf((email || "").toLowerCase()) !== -1 ? "admin" : "user";
}
// resolveRole itself lives in pb_hooks/utils.js and is pulled in per-callback
// via require() — PocketBase's JSVM runs each hook callback below as its own
// isolated program, so a shared top-level function here would not be in scope
// at call time. See pb_hooks/utils.js for details. Keep the logic in sync with
// src/lib/azureAuth.js (resolveRole / parseAdminEmails), which carries the
// canonical, unit-tested version for the frontend/tests.
// On creation (first login): set defaults before the record is persisted.
onRecordCreate(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || "";
e.record.set("role", resolveRole(email));
@@ -45,6 +44,7 @@ onRecordCreate(function (e) {
// On every successful auth (incl. OIDC): keep the admin role in sync with the
// allow-list, so promoting/demoting an admin only requires an env change.
onRecordAuthRequest(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || "";
const want = resolveRole(email);
if (e.record.get("role") !== want) {

104
pb_hooks/utils.js Normal file
View File

@@ -0,0 +1,104 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Shared helpers for pb_hooks/*.pb.js callbacks.
//
// PocketBase's JSVM serializes and runs each registered hook callback as its
// own isolated program, so a plain top-level function declared in a *.pb.js
// file is NOT in scope inside a callback at call time. Reusable helpers must
// instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook)
// module and be pulled in per-callback via require(`${__hooks}/utils.js`).
// See: https://github.com/pocketbase/pocketbase/discussions/3599
//
// Loaded modules use a shared registry across callback invocations, so keep
// this file stateless. (Deliberate exception: the warn-once latch below, which
// exists precisely BECAUSE the registry is shared — it dedupes the env-missing
// warning across the bootstrap hook and the per-minute cron tick.)
//
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
// canonical, unit-tested version used by the frontend).
let warnedEnvMissing = false;
module.exports = {
/**
* Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list.
* @param {string} email
* @returns {"admin"|"user"}
*/
resolveRole: function (email) {
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user";
},
/**
* Reconcile the Entra (Azure) OIDC provider on the team_members auth
* collection from the ENTRA_* environment variables (issue #18).
*
* Idempotent: compares the desired provider config against the stored one
* and only saves when something actually changed, so it is safe to call on
* every bootstrap and cron tick. Keeping this OUT of the one-shot migration
* means late or rotated secrets are picked up on the next start/tick without
* any manual re-apply.
*
* @param {core.App} app
* @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"}
*/
reconcileEntraOidc: function (app) {
let col;
try {
col = app.findCollectionByNameOrId("team_members");
} catch (_) {
return "collection-missing"; // migration has not created it yet
}
if (col.type !== "auth") {
return "not-auth-yet"; // pre-conversion PIN-era collection
}
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
if (!clientId || !clientSecret) {
if (!warnedEnvMissing) {
warnedEnvMissing = true;
console.log(
"WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " +
"disabled until the env vars are provided (they are reconciled automatically on startup)."
);
}
return "env-missing";
}
warnedEnvMissing = false; // env restored — re-arm the warning for future rotations
const desired = {
name: "oidc",
clientId: clientId,
clientSecret: clientSecret,
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
displayName: "Microsoft",
pkce: true,
};
const providers = (col.oauth2 && col.oauth2.providers) || [];
const current = providers.length === 1 ? providers[0] : null;
const inSync = !!current &&
col.oauth2.enabled === true &&
current.name === desired.name &&
current.clientId === desired.clientId &&
current.clientSecret === desired.clientSecret &&
current.authURL === desired.authURL &&
current.tokenURL === desired.tokenURL &&
current.userInfoURL === desired.userInfoURL &&
current.displayName === desired.displayName;
if (inSync) {
return "in-sync";
}
col.oauth2.enabled = true;
col.oauth2.providers = [desired];
app.save(col);
return "updated";
},
};