Files
learning-platform/docs/auth-spec.md
RaymondVerhoef cbce4555ff
Some checks failed
On Pull Request to Main / test (pull_request) Failing after 24s
On Pull Request to Main / publish (pull_request) Has been skipped
On Pull Request to Main / deploy-dev (pull_request) Has been skipped
fix: TeamManager admin rules, close role self-escalation, escalate-only resync (#27)
TeamManager could not manage the roster: updateRule allowed self-update
only and deleteRule was superuser-only. The same self-update rule also
left a privilege escalation open — it did not restrict fields, so any
authenticated user could PATCH their own role to "admin".

- team_members rules (migration 1781000003 + base migration + setup
  script mirror):
    update: (@request.auth.id = id && @request.body.role:isset = false)
            || @request.auth.role = "admin"
    delete: @request.auth.role = "admin"
  Self-service onboarding keeps working (it never touches role); the
  role field is admin-only; deletion is admin-only.
- pb_hooks/team_members.pb.js: the ENTRA_ADMIN_EMAILS resync is now
  escalate-only — it guarantees admin for allow-list members on every
  login but no longer demotes, otherwise every TeamManager promotion
  would revert on the member's next sign-in (ADR-004 amended).
- TeamManager.jsx: info text updated to the new behaviour.

Verified with a two-identity mock-OIDC matrix (11/11): allow-list vs
regular login, self-escalation blocked while onboarding self-update
still works, cross-user update/delete blocked, admin promote/demote/
delete work, promotion survives the member's next login, allow-list
admin stays admin. Regression: #22 matrix 9/9, #24 matrix 8/8, #18
harness 15/15, npm test 112/112, eslint clean.

Closes #27

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:01:28 +02:00

9.3 KiB

Authentication — Azure (Entra ID) login

Implemented for issue #16. Replaces the previous client-side PIN login. Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).

Doel

Elke gebruiker logt in met zijn Respellion Microsoft (Azure Entra ID)-account. Bij de eerste login wordt automatisch een team_members-record aangemaakt. Er is geen PIN, geen handmatige user-aanmaak en geen client-side wachtwoordcontrole meer.

Architectuur in één oogopslag

Browser (SPA)                PocketBase (auth)                 Entra ID
─────────────                ─────────────────                 ────────
"Sign in with Microsoft"
   └─ pb.collection('team_members')
        .authWithOAuth2({provider:'oidc'})
                      ──────────────────►  opens OIDC popup ───► login.microsoftonline.com
                                           token exchange   ◄───  (server-side, client secret)
                      ◄──────────────────  auth record + token
   pb.authStore (token in localStorage)
  • PocketBase is de OIDC-client. De token-exchange en het client-secret blijven server-side in PocketBase — er is geen aparte backend en geen client-side key.
  • team_members is een PocketBase auth-collection. De record-id blijft de user-identifier die alle voortgangscollecties (test_results, on_demand_attempts, micro_learning_completions, leaderboard, theme_session_completions) als user_id referencen.
  • Sessie = pb.authStore (token in localStorage), niet langer een team_members.id in sessionStorage.

Belangrijke beslissingen (ADR)

ADR Beslissing Reden
001 OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie
002 team_members hergebruikt als auth-collection (zelfde naam) Alle user_id-referenties blijven werken; minimale blast-radius
003 Provisioning + admin-rol via pb_hooks/team_members.pb.js Rol-logica server-side, niet in de client
004 Admin-rol via e-mail-allowlist (ENTRA_ADMIN_EMAILS), escalate-only bij elke login (aangepast in #27) Allow-list garandeert admin (promotie werkt bij volgende login) maar demoteert niet meer — anders zou elke TeamManager-promotie bij de volgende login worden teruggedraaid. Demotie via TeamManager; allow-list-leden demoteer je door ze van de lijst te halen
005 API-rules → @request.auth.id != "" op alle app-collecties Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd
006 Baseline-ledger-sync migratie voor out-of-band geprovisionede DB's Labs/prod draaide historisch zonder --migrationsDir; replay van de historie crashte PB (issue #18). De vroegst-sorterende migratie markeert de historie als applied wanneer schema bestaat maar de ledger leeg is
007 Migratie = structuur, hook = configuratie De OIDC-provider wordt bij elke start (en per cron-minuut) gereconcilieerd vanuit ENTRA_* env door pb_hooks/entra_oidc.pb.js; een one-shot migratie die van deploy-time env afhangt kan OAuth2 anders permanent uitschakelen
008 Hook-helpers via require(${__hooks}/utils.js) De JSVM draait elke hook-callback als geïsoleerd programma; top-level functies zijn níet in scope op call-time
009 createRule: '@request.context = "oauth2"' op team_members PocketBase past de createRule toe op de OAuth2-sign-up (eerste login maakt het record aan); null blokkeerde élke eerste login met 403 (issue #22). De context-rule staat alléén de OAuth2-flow toe — anonieme REST-creates blijven geweigerd
010 Claims uit het Entra id_token (userInfoURL: "") + UPN-fallback voor e-mail Graph /oidc/userinfo geeft géén email-claim voor accounts zonder mail-attribuut → 400 bij eerste login (issue #24). Het id_token bevat altijd preferred_username (UPN = primair e-mailadres bij Respellion); entra_oidc.pb.js gebruikt die als fallback (regex-guard tegen guest-UPN's) en logt gefaalde OAuth2-pogingen naar de containerlog
011 updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"', deleteRule: '@request.auth.role = "admin"' TeamManager-rosterbeheer werkt nu voor app-admins (issue #27). De role:isset-guard sluit tegelijk de privilege-escalatie waarbij een gebruiker zijn eigen role naar admin kon patchen; onboarding raakt role niet en blijft self-service

Bestanden

Bestand Rol
pb_migrations/1000000000_baseline_ledger_sync.js Detecteert een out-of-band geprovisionede DB (schema bestaat, _migrations-ledger leeg) en markeert de historische migraties als applied, zodat de replay niet crasht (issue #18).
pb_migrations/1781000000_team_members_to_auth.js Converteert relation-velden naar text (data blijft behouden), dropt de oude PIN-collection en maakt team_members als auth-collection. Idempotent; provider wordt alleen als fast-path meegenomen als de env al aanwezig is.
pb_migrations/1781000001_tighten_api_rules.js Zet alle niet-systeem-collecties op @request.auth.id != "".
pb_migrations/1781000002_allow_oauth2_signup.js Zet createRule = '@request.context = "oauth2"' op reeds-gemigreerde omgevingen (issue #22).
pb_migrations/1781000003_team_members_admin_rules.js Admin-rosterbeheer + role-escalatie-guard op reeds-gemigreerde omgevingen (issue #27).
pb_hooks/entra_oidc.pb.js Reconcilieert de OIDC-provider vanuit ENTRA_* env bij elke start + cron-tick (compare-before-save).
pb_hooks/utils.js Gedeelde helpers (resolveRole, reconcileEntraOidc) — per callback binnenhalen via require().
pb_hooks/team_members.pb.js Auto-provisioning (role, enrollment_status, naam-fallback) + admin-rol re-sync.
src/lib/azureAuth.js Canonieke, unit-geteste helpers (OIDC_PROVIDER, resolveRole, parseAdminEmails, deriveName).
src/store/AppContext.jsx loginWithAzure() / logout() op basis van pb.authStore + authRefresh().
src/pages/Login.jsx "Sign in with Microsoft"-scherm (geen dropdown/PIN).
src/components/admin/TeamManager.jsx Roster-beheer: rol wijzigen + verwijderen (geen aanmaken/PIN).

De rol-/allowlist-logica bestaat tweemaal: canoniek in src/lib/azureAuth.js (met tests) en herhaald in pb_hooks/team_members.pb.js (PocketBase JSVM kan de module niet importeren). Houd ze in sync.

Configuratie (environment)

Gerenderd in .env door de Ansible deploy-playbooks en doorgegeven aan de pocketbase-learning-container:

Variabele Betekenis
ENTRA_TENANT_ID Entra tenant-id (of common).
ENTRA_CLIENT_ID App-registration client-id.
ENTRA_CLIENT_SECRET App-registration client-secret.
ENTRA_ADMIN_EMAILS Komma-gescheiden e-mail-allowlist die role=admin krijgt, bv. rve@respellion.nl,admin@respellion.nl.

Ansible-variabelen (vault): entra_tenant_id, entra_client_id, entra_client_secret, entra_admin_emails.

Entra App Registration (eenmalig, buiten de codebase)

  1. Redirect-URI (Web): https://<host>/api/oauth2-redirect
    • Labs: https://learning-platform.labs.respellion.tech/api/oauth2-redirect
    • Lokaal dev: http://localhost:8090/api/oauth2-redirect (PB-origin)
  2. API permissions / scopes: openid, email, profile.
  3. Genereer een client-secret en zet de waarden in de Ansible-vault.

Uitbreidingspunten

  • Silent SSO (geen tweede klik): als bevestigd is dat de perimeter veilige, niet-spoofbare identity-headers doorstuurt, kan een PocketBase-route die headers vertrouwen en direct een token minten (ADR-004-alternatief).
  • Least-privilege rules: schrijf/verwijder op de knowledge-authoring-collecties (topics, relations, content, sources, question_bank, curriculum_versions) verder beperken tot @request.auth.role = "admin". Let op: micro_learnings en theme_sessions worden door reguliere users geschreven (generate-then-cache). Vereist runtime-verificatie.
  • Rol op Entra group-claims i.p.v. e-mail-allowlist (aanpassing in pb_hooks/team_members.pb.js + src/lib/azureAuth.js).

Bekende aandachtspunten / go-live

  • De OIDC-popup is een top-level navigatie naar login.microsoftonline.com; de bestaande Caddy-CSP (connect-src) raakt dit niet. Verifieer alsnog bij de eerste deploy.
  • Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel: zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
  • Credential-rotatie: update de ENTRA_* secrets (Gitea → Ansible .env) en herstart/redeploy — pb_hooks/entra_oidc.pb.js reconcilieert de provider automatisch bij elke start en elke cron-minuut. Geen handmatige re-apply meer.
  • Migratiebestanden nooit hernoemen nadat ze ergens applied zijn — de _migrations-ledger is filename-based; hernoemen triggert een re-apply.
  • De deploy-playbooks bevatten een health-gate: als PocketBase na de deploy niet healthy wordt, faalt de pipeline zichtbaar en print hij de containerlogs (issue #18 bleef 2 weken onzichtbaar doordat de deploy "groen" was).