Compare commits

..

8 Commits

Author SHA1 Message Date
RaymondVerhoef
54137122a7 fix: read Entra claims from id_token + UPN e-mail fallback (#24)
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
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
RaymondVerhoef
897b46d4a1 fix: allow OAuth2 sign-up on team_members via @request.context rule (#22)
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 3m9s
Every first Microsoft login failed with 403 "Only superusers can perform
this action": PocketBase applies the collection createRule to the
automatic record creation during OAuth2 sign-up, and team_members was
created with createRule null (superuser-only) on the wrong assumption
that the OAuth2 flow bypasses it. Since the pre-Azure records were
deliberately dropped, every user was a first login and nobody could get
in. Reproduced locally against a mock OIDC provider (PocketBase v0.30.4).

createRule becomes '@request.context = "oauth2"': record creation is
allowed exclusively from within the OAuth2 flow. Anonymous REST creates
(which could otherwise pre-seed rogue admin profiles) remain rejected —
covered by an explicit test.

- pb_migrations/1781000002_allow_oauth2_signup.js: applies the rule on
  already-migrated environments (Labs); idempotent, guarded, with down
- pb_migrations/1781000000_team_members_to_auth.js: same rule for fresh
  environments (filename unchanged — ledger-safe)
- scripts/setup-pb-collections.mjs: fallback entry mirrored
- docs/auth-spec.md: ADR 009 + files table

Verified with a mock-OIDC matrix (9/9): baseline reproduces the 403 on
the old rule; upgrade path heals (first login 200, role/enrollment/name
defaults, second login no duplicate, anonymous create still rejected);
fresh chain + admin allow-list mapping OK. DoD harness regression 15/15,
npm test 112/112.

Closes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:15:54 +02:00
RaymondVerhoef
80f738ddcb fix: valid Caddyfile handle_errors + frontend health-gate in deploys (#20)
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 1m7s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m6s
The learning-platform (Caddy) container crash-looped since this morning's
89d3395: `Content-Type application/json` is not a valid subdirective of
`respond` — a Caddyfile parse error aborts Caddy at startup, taking the
whole app offline for authenticated users while every CI run stayed green.
Verified with caddy v2.10.0: "unrecognized subdirective 'Content-Type',
at Caddyfile:53"; the fixed file validates clean.

CI was blind to this twice over: test.yml swaps the real Caddyfile for
Caddyfile.test in the test image, and the deploy health-gate from #18
only covers PocketBase.

- Caddyfile: set Content-Type via a `header @api` directive before the
  `respond`; behaviour of the JSON error response is unchanged
- infra/*/site/deploy-playbook.yml: frontend health-gate (docker exec
  wget --spider on the container) with log dump + abort on failure, and
  an always-on post-deploy smoke report (compose ps, proxy health,
  team_members auth-methods, PocketBase log tail) for visibility behind
  the auth perimeter

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:20:15 +02:00
1a1351ddcb Merge pull request 'Fix #18: migratie-ledger-sync + deploy-proof Azure SSO' (#19) from fix/issue-18-migration-ledger-sso into main
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 1m8s
On Push to Main / deploy-dev (push) Successful in 3m0s
Reviewed-on: #19
2026-07-11 11:54:37 +00:00
RaymondVerhoef
d79e69aad2 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>
2026-07-11 11:14:09 +02:00
RaymondVerhoef
89d3395a62 feat: enhance OIDC configuration for Azure login and improve error handling in API responses
All checks were successful
On Push to Main / test (push) Successful in 56s
On Push to Main / publish (push) Successful in 1m24s
On Push to Main / deploy-dev (push) Successful in 2m58s
2026-07-11 10:19:54 +02:00
RaymondVerhoef
d5191073f0 feat: migrate team_members to OIDC-based auth collection and update docker-compose environment configuration
All checks were successful
On Push to Main / test (push) Successful in 57s
On Push to Main / publish (push) Successful in 1m16s
On Push to Main / deploy-dev (push) Successful in 3m0s
2026-06-25 18:05:39 +02:00
fab7a12f7b Merge pull request 'fix/issue-16-azure-login' (#17) from fix/issue-16-azure-login into main
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 1m15s
On Push to Main / deploy-dev (push) Successful in 2m54s
Reviewed-on: #17
2026-06-24 14:00:40 +00:00
13 changed files with 685 additions and 42 deletions

View File

@@ -137,6 +137,9 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable. * **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry. * **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`. * **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
* **PocketBase migrations & the `_migrations` ledger (issue #18).** The deployed Labs/prod databases were originally provisioned WITHOUT `--migrationsDir` (schema came from `scripts/setup-pb-collections.mjs`), so their ledger started empty. `pb_migrations/1000000000_baseline_ledger_sync.js` marks the historical files as applied on such databases — never remove it, never rename an applied migration file (the ledger is filename-based), and give new migrations a timestamp that sorts after the existing ones. A failed migration aborts `pocketbase serve` → container crash-loop → 502 on every `/api/*` call; the deploy playbooks gate on `/api/health` to catch this.
* **OIDC provider config lives in a hook, not a migration.** `pb_hooks/entra_oidc.pb.js` reconciles the Entra provider from `ENTRA_*` env on every bootstrap + cron tick. Don't move provider credentials back into a one-shot migration — an apply while the env is empty would permanently disable OAuth2.
* **JSVM hook scope.** PocketBase runs each hook callback as an isolated program: top-level functions in a `*.pb.js` file are NOT in scope inside callbacks. Shared helpers live in `pb_hooks/utils.js` and are pulled in per-callback via `require(`${__hooks}/utils.js`)`.
## 13. 26-Week Per-User Curriculum System ## 13. 26-Week Per-User Curriculum System
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**. The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.

View File

@@ -46,6 +46,16 @@
} }
handle_errors { handle_errors {
# Don't mask API errors with the SPA shell — return a proper
# JSON error so the frontend can display a meaningful message.
# NOTE: `respond` only accepts `body`/`close` subdirectives; the
# Content-Type must be set via a separate `header` directive. An
# invalid subdirective is a Caddyfile parse error and crash-loops
# the container at startup (issue #20).
@api path /api/*
header @api Content-Type application/json
respond @api `{"code":{err.status_code},"message":"Backend unavailable"}` {err.status_code}
rewrite * /index.html rewrite * /index.html
file_server file_server
} }

View File

@@ -12,7 +12,13 @@ services:
container_name: pocketbase-learning container_name: pocketbase-learning
restart: unless-stopped restart: unless-stopped
working_dir: /pb working_dir: /pb
env_file:
# Optional: absent .env.local must not block `docker compose up` (issue #18).
- path: .env.local
required: false
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"] command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
ports:
- "8090:8090"
volumes: volumes:
- pb_data:/pb/pb_data - pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations - ./pb_migrations:/pb/pb_migrations

View File

@@ -1,6 +1,7 @@
# Authentication — Azure (Entra ID) login # Authentication — Azure (Entra ID) login
> Implemented for issue #16. Replaces the previous client-side PIN login. > Implemented for issue #16. Replaces the previous client-side PIN login.
> Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).
## Doel ## Doel
@@ -40,13 +41,22 @@ Browser (SPA) PocketBase (auth) Entra ID
| 003 | Provisioning + admin-rol via `pb_hooks/team_members.pb.js` | Rol-logica server-side, niet in de client | | 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`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login | | 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
| 005 | API-rules → `@request.auth.id != ""` op alle app-collecties | Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd | | 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 |
## Bestanden ## Bestanden
| Bestand | Rol | | Bestand | Rol |
|---|---| |---|---|
| `pb_migrations/1781000000_team_members_to_auth.js` | Dropt de oude PIN-collection, maakt `team_members` als auth-collection met de OIDC-provider (creds uit env). | | `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/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_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. | | `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/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/store/AppContext.jsx` | `loginWithAzure()` / `logout()` op basis van `pb.authStore` + `authRefresh()`. |
@@ -100,6 +110,11 @@ Ansible-variabelen (vault): `entra_tenant_id`, `entra_client_id`,
eerste deploy. eerste deploy.
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel: - Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar. zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
- Credential-rotatie: update env + herconfigureer de provider via de PocketBase - Credential-rotatie: update de `ENTRA_*` secrets (Gitea → Ansible `.env`) en
admin-UI (Settings → Auth providers) of ship een follow-up migratie — de herstart/redeploy — `pb_hooks/entra_oidc.pb.js` reconcilieert de provider
create-migratie draait maar één keer. 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).

View File

@@ -72,3 +72,81 @@
state: present state: present
files: compose.yml files: compose.yml
recreate: always recreate: always
# A failed migration aborts `pocketbase serve` and the container crash-loops
# while `docker compose up` still reports success (issue #18). Gate the
# deploy on PocketBase actually serving its health endpoint.
- name: Wait for PocketBase to become healthy
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
register: pb_health
until: pb_health.rc == 0
retries: 18
delay: 5
changed_when: false
ignore_errors: yes
- name: Collect PocketBase logs for diagnosis
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
register: pb_logs
when: pb_health is failed
changed_when: false
failed_when: false
- name: Abort deploy — PocketBase is not healthy
ansible.builtin.fail:
msg: |
PocketBase did not become healthy after the deploy (health endpoint unreachable).
Recent container logs:
{{ pb_logs.stdout | default('') }}
{{ pb_logs.stderr | default('') }}
when: pb_health is failed
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
# crash-loops it at startup while the PocketBase gate stays green — that
# outage (issue #20) was invisible to CI because the test job swaps in
# Caddyfile.test. Gate on the real container actually serving.
- name: Wait for the frontend (Caddy) to become healthy
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
register: fe_health
until: fe_health.rc == 0
retries: 18
delay: 5
changed_when: false
ignore_errors: yes
- name: Collect frontend logs for diagnosis
ansible.builtin.command: docker logs --tail 80 learning-platform
register: fe_logs
when: fe_health is failed
changed_when: false
failed_when: false
- name: Abort deploy — frontend is not healthy
ansible.builtin.fail:
msg: |
The frontend (Caddy) container did not become healthy after the deploy.
Recent container logs:
{{ fe_logs.stdout | default('') }}
{{ fe_logs.stderr | default('') }}
when: fe_health is failed
# Observability behind the auth perimeter: surface the end-to-end state
# in the CI log on every deploy.
- name: Post-deploy smoke report
ansible.builtin.shell: |
cd /opt/learning-platform
echo '--- docker compose ps ---'
docker compose ps
echo '--- frontend -> pocketbase proxy health ---'
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
echo '--- team_members auth methods (OIDC provider present?) ---'
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
echo '--- pocketbase logs (tail 30) ---'
docker compose logs --tail=30 pocketbase-learning
register: smoke_report
changed_when: false
failed_when: false
- name: Show smoke report
ansible.builtin.debug:
msg: "{{ smoke_report.stdout_lines }}"

View File

@@ -72,3 +72,81 @@
state: present state: present
files: compose.yml files: compose.yml
recreate: always recreate: always
# A failed migration aborts `pocketbase serve` and the container crash-loops
# while `docker compose up` still reports success (issue #18). Gate the
# deploy on PocketBase actually serving its health endpoint.
- name: Wait for PocketBase to become healthy
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
register: pb_health
until: pb_health.rc == 0
retries: 18
delay: 5
changed_when: false
ignore_errors: yes
- name: Collect PocketBase logs for diagnosis
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
register: pb_logs
when: pb_health is failed
changed_when: false
failed_when: false
- name: Abort deploy — PocketBase is not healthy
ansible.builtin.fail:
msg: |
PocketBase did not become healthy after the deploy (health endpoint unreachable).
Recent container logs:
{{ pb_logs.stdout | default('') }}
{{ pb_logs.stderr | default('') }}
when: pb_health is failed
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
# crash-loops it at startup while the PocketBase gate stays green — that
# outage (issue #20) was invisible to CI because the test job swaps in
# Caddyfile.test. Gate on the real container actually serving.
- name: Wait for the frontend (Caddy) to become healthy
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
register: fe_health
until: fe_health.rc == 0
retries: 18
delay: 5
changed_when: false
ignore_errors: yes
- name: Collect frontend logs for diagnosis
ansible.builtin.command: docker logs --tail 80 learning-platform
register: fe_logs
when: fe_health is failed
changed_when: false
failed_when: false
- name: Abort deploy — frontend is not healthy
ansible.builtin.fail:
msg: |
The frontend (Caddy) container did not become healthy after the deploy.
Recent container logs:
{{ fe_logs.stdout | default('') }}
{{ fe_logs.stderr | default('') }}
when: fe_health is failed
# Observability behind the auth perimeter: surface the end-to-end state
# in the CI log on every deploy.
- name: Post-deploy smoke report
ansible.builtin.shell: |
cd /opt/learning-platform
echo '--- docker compose ps ---'
docker compose ps
echo '--- frontend -> pocketbase proxy health ---'
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
echo '--- team_members auth methods (OIDC provider present?) ---'
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
echo '--- pocketbase logs (tail 30) ---'
docker compose logs --tail=30 pocketbase-learning
register: smoke_report
changed_when: false
failed_when: false
- name: Show smoke report
ansible.builtin.debug:
msg: "{{ smoke_report.stdout_lines }}"

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

@@ -0,0 +1,62 @@
/// <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");
}
});
// Entra does not always supply an `email` claim: the id_token only carries it
// when the account has a mail attribute (issue #24). The UPN in
// `preferred_username` is always present and equals the primary e-mail for
// Respellion organisation accounts, so fall back to it when it looks like a
// real address. Guest UPNs ("user_ext#EXT#@tenant...") fail the regex on
// purpose — those still get a clear validation error instead of a bogus email.
// The catch also surfaces the underlying OAuth2 failure in the container log,
// which PocketBase otherwise only writes to its internal logs database.
onRecordAuthWithOAuth2Request((e) => {
const u = e.oAuth2User;
if (u && !u.email) {
const upn = String((u.rawUser && u.rawUser.preferred_username) || u.username || "").trim().toLowerCase();
// `#` is RFC-valid in an e-mail local part, so guest UPNs
// ("ext_user#EXT#@tenant.onmicrosoft.com") pass a naive e-mail check AND
// PocketBase's own validation — exclude them explicitly.
if (/^[^@\s#]+@[^@\s#]+\.[^@\s#]+$/.test(upn)) {
u.email = upn;
console.log("entra_oidc: email claim absent — falling back to UPN " + upn);
}
}
try {
e.next();
} catch (err) {
console.log("entra_oidc auth-with-oauth2 FAILED:", String(err));
throw err;
}
}, "team_members");

View File

@@ -15,17 +15,16 @@
// The allow-list is a comma-separated list of e-mail addresses, e.g.: // The allow-list is a comma-separated list of e-mail addresses, e.g.:
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl // ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
// //
// Keep this logic in sync with src/lib/azureAuth.js (resolveRole / parseAdminEmails), // resolveRole itself lives in pb_hooks/utils.js and is pulled in per-callback
// which carries the canonical, unit-tested version for the frontend/tests. // 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
function resolveRole(email) { // at call time. See pb_hooks/utils.js for details. Keep the logic in sync with
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase(); // src/lib/azureAuth.js (resolveRole / parseAdminEmails), which carries the
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean); // canonical, unit-tested version for the frontend/tests.
return allow.indexOf((email || "").toLowerCase()) !== -1 ? "admin" : "user";
}
// On creation (first login): set defaults before the record is persisted. // On creation (first login): set defaults before the record is persisted.
onRecordCreate(function (e) { onRecordCreate(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || ""; const email = e.record.get("email") || "";
e.record.set("role", resolveRole(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 // 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. // allow-list, so promoting/demoting an admin only requires an env change.
onRecordAuthRequest(function (e) { onRecordAuthRequest(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || ""; const email = e.record.get("email") || "";
const want = resolveRole(email); const want = resolveRole(email);
if (e.record.get("role") !== want) { if (e.record.get("role") !== want) {

109
pb_hooks/utils.js Normal file
View File

@@ -0,0 +1,109 @@
/// <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",
// Deliberately empty: claims are read from the Entra id_token instead of
// Graph's /oidc/userinfo. The userinfo endpoint omits `email` for
// accounts without a mail attribute, while the id_token always carries
// `preferred_username` (UPN) which entra_oidc.pb.js uses as an e-mail
// fallback (issue #24).
userInfoURL: "",
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";
},
};

View File

@@ -0,0 +1,138 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #18 — Baseline ledger sync for out-of-band provisioned databases.
//
// The Labs/production PocketBase originally ran WITHOUT --migrationsDir: its
// schema was created by scripts/setup-pb-collections.mjs / the admin UI, so its
// _migrations ledger is empty. When the migrations dir was mounted for the
// first time (Azure SSO, PR #17), PocketBase tried to replay the entire
// migration history against that existing schema and crash-looped on the very
// first file ("Collection name must be unique").
//
// This migration sorts before every other file. When it detects that the
// legacy schema already exists but the ledger has never tracked it, it marks
// the historical migrations below as applied so the replay is skipped and the
// chain continues with the genuinely new migrations (team_members_to_auth,
// tighten_api_rules). On fresh databases and on databases that already have a
// populated ledger it is a no-op.
//
// NOTE for future agents: never rename a migration file after it has been
// applied anywhere — the ledger is filename-based. New migrations must sort
// after 1781100001 and be appended to environments through a normal deploy.
const HISTORICAL_MIGRATIONS = [
"1778948471_created_content.js",
"1778948471_created_quiz_banks.js",
"1778948471_created_relations.js",
"1778948471_created_sources.js",
"1778948471_created_team_members.js",
"1778948471_created_topics.js",
"1778948472_created_leaderboard.js",
"1778948472_created_learn_progress.js",
"1778948472_created_quiz_cache.js",
"1778948472_created_quiz_results.js",
"1778948472_created_settings.js",
"1778954289_created_test_col2.js",
"1778954310_deleted_relations.js",
"1778954310_deleted_topics.js",
"1778954310_deleted_users.js",
"1778954311_deleted_content.js",
"1778954311_deleted_leaderboard.js",
"1778954311_deleted_learn_progress.js",
"1778954311_deleted_quiz_banks.js",
"1778954311_deleted_quiz_cache.js",
"1778954311_deleted_quiz_results.js",
"1778954311_deleted_settings.js",
"1778954311_deleted_sources.js",
"1778954311_deleted_team_members.js",
"1778954311_deleted_test_col2.js",
"1778954317_created_content.js",
"1778954317_created_leaderboard.js",
"1778954317_created_learn_progress.js",
"1778954317_created_quiz_banks.js",
"1778954317_created_quiz_cache.js",
"1778954317_created_quiz_results.js",
"1778954317_created_relations.js",
"1778954317_created_settings.js",
"1778954317_created_sources.js",
"1778954317_created_team_members.js",
"1778954317_created_topics.js",
"1779005271_created_test_col3.js",
"1779005289_created_test_col4.js",
"1779005309_deleted_content.js",
"1779005309_deleted_learn_progress.js",
"1779005309_deleted_quiz_banks.js",
"1779005309_deleted_quiz_cache.js",
"1779005309_deleted_quiz_results.js",
"1779005309_deleted_relations.js",
"1779005309_deleted_sources.js",
"1779005309_deleted_team_members.js",
"1779005309_deleted_topics.js",
"1779005310_deleted_leaderboard.js",
"1779005310_deleted_settings.js",
"1779005310_deleted_test_col3.js",
"1779005310_deleted_test_col4.js",
"1779005316_created_content.js",
"1779005316_created_quiz_banks.js",
"1779005316_created_relations.js",
"1779005316_created_sources.js",
"1779005316_created_topics.js",
"1779005317_created_leaderboard.js",
"1779005317_created_learn_progress.js",
"1779005317_created_quiz_cache.js",
"1779005317_created_quiz_results.js",
"1779005317_created_settings.js",
"1779005317_created_team_members.js",
"1779127586_created_curriculum.js",
"1779127759_collections_snapshot.js",
"1779200000_updated_sources.js",
"1780000001_updated_topics.js",
"1780500000_updated_topics_relevance_locked.js",
"1780500001_normalize_relation_types.js",
"1780500002_created_llm_calls.js",
"1780600000_curriculum_v2.js",
"1780700000_sources_progress.js",
"1780800000_created_micro_learnings.js",
"1780800001_deleted_legacy_collections.js",
"1780800002_update_micro_learnings_rules.js",
"1780900000_team_members_enrollment.js",
"1780900001_created_test_results.js",
"1781000000_created_theme_sessions.js",
"1781100000_created_question_bank.js",
"1781100001_created_on_demand_attempts.js",
];
migrate((app) => {
// Fresh database? (no legacy schema) -> let the normal chain run.
try {
app.findCollectionByNameOrId("content");
} catch (_) {
return;
}
// Ledger already tracks the history? -> normally migrated DB, nothing to do.
const probe = new DynamicModel({ count: 0 });
app.db()
.newQuery("SELECT COUNT(*) AS count FROM _migrations WHERE file = {:file}")
.bind({ file: "1778948471_created_content.js" })
.one(probe);
if (probe.count > 0) {
return;
}
// Out-of-band provisioned database: schema exists, ledger is empty.
// Mark the historical migrations as applied so they are not replayed.
const applied = Date.now();
for (const file of HISTORICAL_MIGRATIONS) {
app.db()
.newQuery("INSERT OR IGNORE INTO _migrations (file, applied) VALUES ({:file}, {:applied})")
.bind({ file: file, applied: applied })
.execute();
}
console.log(
"baseline_ledger_sync: detected out-of-band provisioned database — marked " +
HISTORICAL_MIGRATIONS.length + " historical migrations as applied"
);
}, (_app) => {
// Down: intentionally a no-op. The ledger rows describe environment-specific
// bookkeeping; removing them would re-trigger the replay this fix prevents.
});

View File

@@ -1,6 +1,6 @@
/// <reference path="../pb_data/types.d.ts" /> /// <reference path="../pb_data/types.d.ts" />
// //
// Issue #16 — Azure (Entra ID) login. // Issue #16 / #18 — Azure (Entra ID) login.
// //
// Replaces the PIN-based `base` collection `team_members` with a PocketBase // Replaces the PIN-based `base` collection `team_members` with a PocketBase
// `auth` collection that authenticates against Azure Entra ID via OIDC. // `auth` collection that authenticates against Azure Entra ID via OIDC.
@@ -10,26 +10,128 @@
// base, generated tests and micro-learnings live in OTHER collections and are // base, generated tests and micro-learnings live in OTHER collections and are
// left completely untouched by this migration. // left completely untouched by this migration.
// //
// OIDC provider credentials are read from the environment at apply time, so no // This migration is STRUCTURE ONLY and does not depend on the environment:
// secrets are committed to git: // the OIDC provider credentials (ENTRA_TENANT_ID / ENTRA_CLIENT_ID /
// ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET // ENTRA_CLIENT_SECRET) are reconciled into the collection on every startup by
// (set via the Ansible-rendered .env, see infra/*/site/compose.yml). // 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
// To rotate credentials or change the tenant later, update the env and either // OAuth2 (issue #18). As a fast path the provider is also attached here when
// re-configure the provider from the PocketBase admin UI (Settings → Auth // the env vars are already present at apply time.
// providers) or ship a follow-up migration.
migrate((app) => { migrate((app) => {
// 1. Drop the old PIN-based collection (takes the PIN records with it). // 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 { try {
const old = app.findCollectionByNameOrId("team_members"); existing = app.findCollectionByNameOrId("team_members");
app.delete(old);
} catch (_) { } catch (_) {
// Collection did not exist — nothing to drop. 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;
} }
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common"; // 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();
// 2. Recreate `team_members` as an auth collection (same name → all existing // 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, // `user_id` references in test_results, on_demand_attempts,
// micro_learning_completions, leaderboard, theme_session_completions keep // micro_learning_completions, leaderboard, theme_session_completions keep
// working unchanged). // working unchanged).
@@ -41,10 +143,14 @@ migrate((app) => {
// Access rules: every legitimate user is authenticated (Azure-gated + // Access rules: every legitimate user is authenticated (Azure-gated +
// OIDC). Profiles are readable by any authenticated user (leaderboard, // OIDC). Profiles are readable by any authenticated user (leaderboard,
// dashboard); a user may only update their own record (onboarding flips // dashboard); a user may only update their own record (onboarding flips
// enrollment_status). Creation/deletion happen via OAuth2 / superuser only. // 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 != ""', listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""', viewRule: '@request.auth.id != ""',
createRule: null, createRule: '@request.context = "oauth2"',
updateRule: '@request.auth.id = id', updateRule: '@request.auth.id = id',
deleteRule: null, deleteRule: null,
authRule: "", authRule: "",
@@ -56,7 +162,7 @@ migrate((app) => {
mfa: { enabled: false, duration: 600, rule: "" }, mfa: { enabled: false, duration: 600, rule: "" },
oauth2: { oauth2: {
enabled: true, enabled: oidcProviders.length > 0,
// Map the OIDC userinfo claims onto our fields. // Map the OIDC userinfo claims onto our fields.
mappedFields: { mappedFields: {
id: "", id: "",
@@ -64,18 +170,7 @@ migrate((app) => {
username: "", username: "",
avatarURL: "", avatarURL: "",
}, },
providers: [ providers: oidcProviders,
{
name: "oidc",
clientId: $os.getenv("ENTRA_CLIENT_ID"),
clientSecret: $os.getenv("ENTRA_CLIENT_SECRET"),
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,
},
],
}, },
fields: [ fields: [

View File

@@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #22 — Allow OAuth2 sign-up on team_members.
//
// The collection was created with `createRule: null` on the assumption that
// the OAuth2 flow bypasses it. It does not: PocketBase applies the createRule
// to the automatic record creation on a first OIDC login, so every first
// login failed with 403 "Only superusers can perform this action". Since the
// pre-Azure records were intentionally dropped, EVERY user was a first login
// and nobody could sign in.
//
// `@request.context = "oauth2"` scopes record creation to the OAuth2 flow
// only — plain REST creates (e.g. anonymous POST /records, which could
// otherwise pre-seed rogue admin profiles) keep being rejected.
//
// Environments that have not applied 1781000000 yet get this rule directly
// from that (updated) migration; this follow-up exists for databases where it
// already ran with the old `null` rule (Labs). Applying it twice is harmless.
migrate((app) => {
let col;
try {
col = app.findCollectionByNameOrId("team_members");
} catch (_) {
console.log("allow_oauth2_signup: team_members does not exist — skipping.");
return;
}
if (col.type !== "auth") {
console.log("allow_oauth2_signup: team_members is not an auth collection — skipping.");
return;
}
col.createRule = '@request.context = "oauth2"';
app.save(col);
}, (app) => {
// Down: restore the (broken) superuser-only rule.
let col;
try {
col = app.findCollectionByNameOrId("team_members");
} catch (_) {
return;
}
if (col.type !== "auth") {
return;
}
col.createRule = null;
app.save(col);
});

View File

@@ -111,6 +111,9 @@ const COLLECTIONS = [
name: 'team_members', name: 'team_members',
type: 'auth', type: 'auth',
...OPEN_RULES, ...OPEN_RULES,
// Mirror of pb_migrations/1781000002: creation only via the OAuth2
// sign-up flow (issue #22).
createRule: '@request.context = "oauth2"',
passwordAuth: { enabled: false, identityFields: ['email'] }, passwordAuth: { enabled: false, identityFields: ['email'] },
fields: [ fields: [
{ name: 'name', type: 'text', required: false }, { name: 'name', type: 'text', required: false },