Files
learning-platform/pb_migrations/1781000000_team_members_to_auth.js
RaymondVerhoef 54137122a7
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 38s
On Pull Request to Main / publish (pull_request) Successful in 1m10s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m7s
fix: read Entra claims from id_token + UPN e-mail fallback (#24)
First Microsoft logins failed with 400 "email: Cannot be blank": Graph's
/oidc/userinfo endpoint omits the email claim for accounts without a
mail attribute, and team_members requires an e-mail (it also drives the
ENTRA_ADMIN_EMAILS role mapping). Reproduced against a mock Entra with
PocketBase v0.30.4; the user-supplied response body matched the
missing-email fingerprint exactly.

- provider config (reconciler + migration fast-path): userInfoURL is now
  empty, so PocketBase reads the claims from the Entra id_token, which
  always carries preferred_username (UPN) and email when available. The
  #18 reconciler flips the already-deployed Labs provider automatically
  on the next boot — no migration needed.
- pb_hooks/entra_oidc.pb.js: onRecordAuthWithOAuth2Request hook falls
  back to the lowercased UPN when the email claim is absent. Guest UPNs
  (ext_user#EXT#@tenant...) are excluded explicitly — "#" is RFC-valid
  in an e-mail local part, so both a naive regex AND PocketBase's own
  validation would accept them (caught by test V5). The hook also logs
  every failed OAuth2 attempt with the underlying error to the container
  log, which PocketBase otherwise only writes to its internal logs db.

Verified with a switchable mock-Entra matrix (8/8): no-email→UPN e-mail
+ allow-list role, email claim wins when present, no duplicate on
re-login, guest UPN yields a clean validation error, anonymous REST
create stays rejected, both log lines present. Regression: issue-22
matrix 9/9 (baseline pinned to 1a1351d now that #23 is merged), DoD
harness 15/15, npm test 112/112.

Closes #24

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:26:25 +02:00

358 lines
13 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
//
// Issue #16 / #18 — Azure (Entra ID) login.
//
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
// `auth` collection that authenticates against Azure Entra ID via OIDC.
//
// The existing PIN-based team_members are intentionally dropped (sign-off from
// the product owner — no migration of current users required). The knowledge
// base, generated tests and micro-learnings live in OTHER collections and are
// left completely untouched by this migration.
//
// This migration is STRUCTURE ONLY and does not depend on the environment:
// the OIDC provider credentials (ENTRA_TENANT_ID / ENTRA_CLIENT_ID /
// ENTRA_CLIENT_SECRET) are reconciled into the collection on every startup by
// pb_hooks/entra_oidc.pb.js. That keeps the one-shot migration deterministic:
// applying it while the secrets are absent can no longer permanently disable
// OAuth2 (issue #18). As a fast path the provider is also attached here when
// the env vars are already present at apply time.
migrate((app) => {
// Idempotency guard: if team_members is already an auth collection (e.g. the
// conversion happened through an earlier partial rollout), leave it alone.
let existing = null;
try {
existing = app.findCollectionByNameOrId("team_members");
} catch (_) {
existing = null; // collection absent — nothing to convert, only to create
}
if (existing && existing.type === "auth") {
console.log("team_members_to_auth: team_members is already an auth collection — skipping conversion.");
return;
}
// 1. Detach relation fields that point at team_members. PocketBase refuses
// to delete a collection that other collections relate to, so before we
// can drop the old PIN-based team_members we convert those relations into
// plain text fields (the id is stored as text — consistent with how
// user_id is stored in test_results / on_demand_attempts). The column
// values are preserved by the conversion.
const deps = [
{ name: "micro_learning_completions", relId: "rel_team_member_id", textId: "txt_mlc_team_member" },
{ name: "theme_session_completions", relId: "rel_tsc_team_member", textId: "txt_tsc_team_member" },
];
for (const dep of deps) {
let c = null;
try {
c = app.findCollectionByNameOrId(dep.name);
} catch (_) {
console.log("team_members_to_auth: " + dep.name + " does not exist yet — nothing to detach.");
continue;
}
if (!c.fields.getById(dep.relId)) {
console.log("team_members_to_auth: " + dep.name + "." + dep.relId + " already detached — skipping.");
continue;
}
// PocketBase diffs fields by ID, so swapping the relation field for a text
// field drops and recreates the underlying column. Back the values up and
// restore them after the schema save — the ids must survive (issue #18).
const backup = "_issue18_tm_" + dep.name;
app.db().newQuery("DROP TABLE IF EXISTS `" + backup + "`").execute();
app.db().newQuery(
"CREATE TABLE `" + backup + "` AS SELECT `id`, `team_member_id` AS `v` FROM `" + dep.name + "`"
).execute();
// Drop any index that references the team_member_id column (it is rebuilt
// on the text column below).
c.indexes = (c.indexes || []).filter((idx) => idx.indexOf("team_member_id") === -1);
// Replace the relation field with a text field of the same name.
c.fields.removeById(dep.relId);
c.fields.add(new Field({
type: "text",
id: dep.textId,
name: "team_member_id",
required: false,
min: 0,
max: 0,
}));
app.save(c);
app.db().newQuery(
"UPDATE `" + dep.name + "` SET `team_member_id` = COALESCE(" +
"(SELECT `v` FROM `" + backup + "` WHERE `" + backup + "`.`id` = `" + dep.name + "`.`id`), '')"
).execute();
app.db().newQuery("DROP TABLE `" + backup + "`").execute();
}
// Recreate the unique (team_member_id, session_week) guard on the text column.
try {
const tsc = app.findCollectionByNameOrId("theme_session_completions");
const idx = "CREATE UNIQUE INDEX `idx_theme_session_completions_user_week` ON `theme_session_completions` (`team_member_id`, `session_week`)";
if ((tsc.indexes || []).indexOf(idx) === -1) {
tsc.indexes.push(idx);
app.save(tsc);
}
} catch (_) {
console.log("team_members_to_auth: theme_session_completions does not exist yet — no index to rebuild.");
}
// 2. Drop the old PIN-based team_members (now unreferenced). NOT wrapped in
// a try/catch: if this fails there is an unknown relation left and the
// migration must fail loudly instead of masking the error (issue #18).
if (existing) {
app.delete(existing);
}
// Fast path: attach the OIDC provider right away when the credentials are
// already present at apply time. When they are absent the collection is
// created without a provider and pb_hooks/entra_oidc.pb.js configures it on
// the next startup / cron tick once the ENTRA_* env vars are supplied.
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
const oidcProviders = (clientId && clientSecret) ? [
{
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",
// Empty on purpose: claims come from the id_token (see utils.js, issue #24).
userInfoURL: "",
displayName: "Microsoft",
pkce: true,
},
] : [];
if (oidcProviders.length === 0) {
console.log(
"team_members_to_auth: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — creating the auth " +
"collection without an OIDC provider. pb_hooks/entra_oidc.pb.js will configure the provider " +
"automatically once the env vars are present (no re-apply needed)."
);
}
// 3. Recreate `team_members` as an auth collection (same name → all existing
// `user_id` references in test_results, on_demand_attempts,
// micro_learning_completions, leaderboard, theme_session_completions keep
// working unchanged).
const collection = new Collection({
type: "auth",
name: "team_members",
system: false,
// Access rules: every legitimate user is authenticated (Azure-gated +
// OIDC). Profiles are readable by any authenticated user (leaderboard,
// dashboard); a user may only update their own record (onboarding flips
// enrollment_status). Record creation is ONLY allowed from within the
// OAuth2 sign-up flow: PocketBase applies the createRule to the automatic
// record creation on a first OIDC login, so `null` would make every first
// login fail with 403 "Only superusers can perform this action" (issue
// #22). Plain REST creates keep being rejected for non-superusers.
listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""',
createRule: '@request.context = "oauth2"',
updateRule: '@request.auth.id = id',
deleteRule: null,
authRule: "",
manageRule: null,
// Disable password / OTP / MFA — login is exclusively via Entra OIDC.
passwordAuth: { enabled: false, identityFields: ["email"] },
otp: { enabled: false, duration: 180, length: 8 },
mfa: { enabled: false, duration: 600, rule: "" },
oauth2: {
enabled: oidcProviders.length > 0,
// Map the OIDC userinfo claims onto our fields.
mappedFields: {
id: "",
name: "name",
username: "",
avatarURL: "",
},
providers: oidcProviders,
},
fields: [
// ── System auth fields ──────────────────────────────────────────────
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password901924565",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text2504183744",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool1547992806",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
// ── Application fields ───────────────────────────────────────────────
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 255,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1466534506",
"max": 0,
"min": 0,
"name": "role",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "date_curriculum_started_at",
"name": "curriculum_started_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_enrollment_status",
"max": 0,
"min": 0,
"name": "enrollment_status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
indexes: [
"CREATE UNIQUE INDEX `idx_tokenKey_team_members` ON `team_members` (`tokenKey`)",
"CREATE UNIQUE INDEX `idx_email_team_members` ON `team_members` (`email`) WHERE `email` != ''"
],
});
app.save(collection);
}, (app) => {
// Down: drop the auth collection and recreate the original PIN-based base
// collection (without data — the original records are gone).
try {
const c = app.findCollectionByNameOrId("team_members");
app.delete(c);
} catch (_) { /* already gone */ }
const base = new Collection({
type: "base",
name: "team_members",
listRule: "",
viewRule: "",
createRule: "",
updateRule: "",
deleteRule: "",
fields: [
{ "type": "text", "name": "id", "system": true, "primaryKey": true, "required": true,
"min": 15, "max": 15, "pattern": "^[a-z0-9]+$", "autogeneratePattern": "[a-z0-9]{15}",
"id": "text3208210256" },
{ "type": "text", "name": "name", "required": true, "min": 0, "max": 0, "id": "text1579384326" },
{ "type": "text", "name": "pin", "required": false, "min": 0, "max": 0, "id": "text3045404147" },
{ "type": "text", "name": "role", "required": false, "min": 0, "max": 0, "id": "text1466534506" },
{ "type": "date", "name": "curriculum_started_at", "required": false, "id": "date_curriculum_started_at" },
{ "type": "text", "name": "enrollment_status", "required": false, "min": 0, "max": 0, "id": "text_enrollment_status" },
],
});
app.save(base);
});