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>
123 lines
9.3 KiB
Markdown
123 lines
9.3 KiB
Markdown
# 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).
|