From 0c8e3fb69a2aa8d9ac54584c925c56885e9b14ec Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:57:49 +0200 Subject: [PATCH] feat: sp2 provisioning (keycloak, grist, wssync) --- ops/README.md | 127 ++++++++++++++++++++++++++++++++++++ ops/provision-sync-role.php | 125 +++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 ops/provision-sync-role.php diff --git a/ops/README.md b/ops/README.md index 18791d1..e68f258 100644 --- a/ops/README.md +++ b/ops/README.md @@ -139,3 +139,130 @@ Example (from module "Module 1 : Découverte de KiCad"): | /243/ | 1.1 Introduction et installation | …/pluginfile.php/69/mod_book/chapter/243/index.html | | /244/ | Pourquoi KiCad plutôt qu'Eagle, … | …/pluginfile.php/69/mod_book/chapter/244/index.html | | /254/ | Récapitulatif | …/pluginfile.php/69/mod_book/chapter/254/index.html | + +## SP2 — Keycloak SSO, Grist progression, Moodle wssync + +### New env vars — Tower: `/home/clems/formations-app.env` + +``` +OIDC_ISSUER=https://auth.saillant.cc/realms/electron_rare +OIDC_CLIENT_ID=formations-app +OIDC_CLIENT_SECRET= +SESSION_SECRET= +APP_BASE_URL=https://formations.saillant.cc +GRIST_BASE_URL=https://grist.saillant.cc +GRIST_API_KEY= +GRIST_DOC_ID=bPfitC7Poc4r2xGRx7FgHL +WSSYNC_PASSWORD= +MOODLE_SYNC_TOKEN= +``` + +**Never commit this file.** It lives on Tower only. + +### Keycloak client (realm `electron_rare`) + +Provisioned 2026-06-10 with kcadm inside the `suite-keycloak` container on +electron-server (admin creds come from the container env +`KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD`): + +```bash +docker exec suite-keycloak bash -c ' + /opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 \ + --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" + /opt/keycloak/bin/kcadm.sh create clients -r electron_rare \ + -s clientId=formations-app -s protocol=openid-connect \ + -s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \ + -s "redirectUris=[\"https://formations.saillant.cc/auth/callback\"]" \ + -s "webOrigins=[\"https://formations.saillant.cc\"]" + # client uuid: 120bb8ff-5dc5-4790-be0e-724a5f485da7 + /opt/keycloak/bin/kcadm.sh get clients/120bb8ff-5dc5-4790-be0e-724a5f485da7/client-secret -r electron_rare' +``` + +Discovery (verified): `https://auth.saillant.cc/realms/electron_rare/.well-known/openid-configuration` + +| endpoint | URL | +|---|---| +| authorization | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/auth | +| token | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/token | +| userinfo | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/userinfo | +| end_session | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/logout | + +### Grist progression doc + +- Doc `formations-progression`, id **`bPfitC7Poc4r2xGRx7FgHL`**, org + `electron-rare` (id 3), workspace `Home` (id 3) on grist.saillant.cc. +- Table `Progression`: `user_sub` Text, `email` Text, `course_slug` Text, + `module_n` Int, `chapitre_n` Int, `lu_le` DateTime (epoch seconds), + `moodle_synced` Bool. +- Smoke-tested add/list/delete via + `/api/docs/bPfitC7Poc4r2xGRx7FgHL/tables/Progression/records`. +- CAUTION: Grist Bool filters are quirky — filter booleans in app code; only + simple equality filters on Text columns are reliable as API filter params. + +### provision-sync-role.php (Moodle wssync) + +Idempotent script that provisions the privileged sync side: + +1. Role `wssync` (system context, assignable at system level) with caps: + `moodle/user:create`, `moodle/user:viewdetails`, `moodle/user:viewalldetails`, + `moodle/site:viewuseridentity`, `moodle/user:update`, `enrol/manual:enrol`, + `moodle/course:overridecompletion`, `moodle/course:viewparticipants`, + `webservice/rest:use`, `moodle/webservice:createtoken`. + - `viewalldetails` is required or `core_user_get_users_by_field` silently + returns `[]` (the WS only returns users whose queried field is visible). + - `site:viewuseridentity` is required for `field=email` lookups (outside a + course, email is only exposed through the site identity fields). + - `webservice:createtoken` is required by `login/token.php` for any + non-mobile service. +2. User `wssync` (password from env `WSSYNC_PASSWORD`), role assigned at + system context. +3. Dedicated external service **`formations_sync`** (restricted users, + wssync authorised) with functions: `core_user_get_users_by_field`, + `core_user_create_users`, `core_user_update_users`, + `enrol_manual_enrol_users`, `core_course_get_contents`, + `core_completion_override_activity_completion_status`. + The stock `moodle_mobile_app` service does NOT include the completion + override function — tokens must be issued against `formations_sync`. + +**How to run (from Tower):** + +```bash +WSPASS=$(grep WSSYNC_PASSWORD ~/formations-app.env | cut -d= -f2-) +docker cp ops/provision-sync-role.php moodle:/tmp/provision-sync-role.php +docker exec -e WSSYNC_PASSWORD="$WSPASS" moodle php /tmp/provision-sync-role.php +``` + +### MOODLE_SYNC_TOKEN issue / renewal + +```bash +WSPASS=$(grep WSSYNC_PASSWORD ~/formations-app.env | cut -d= -f2-) +TOKEN=$(curl -s "https://moodle.saillant.cc/login/token.php" \ + --data-urlencode "username=wssync" \ + --data-urlencode "password=$WSPASS" \ + --data-urlencode "service=formations_sync" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') +sed -i "s/^MOODLE_SYNC_TOKEN=.*/MOODLE_SYNC_TOKEN=$TOKEN/" ~/formations-app.env +``` + +### Capability proofs (run after provisioning; both verified 2026-06-10) + +```bash +TOKEN=$(grep MOODLE_SYNC_TOKEN ~/formations-app.env | cut -d= -f2-) + +# 1) user lookup (must return the user, not [] nor an exception) +curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \ + --data-urlencode "wstoken=$TOKEN" \ + --data-urlencode "wsfunction=core_user_get_users_by_field" \ + --data-urlencode "moodlewsrestformat=json" \ + --data-urlencode "field=username" --data-urlencode "values[0]=wsreader" + +# 2) completion override on course 3's first book (cmid 27), then revert +for STATE in 1 0; do +curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \ + --data-urlencode "wstoken=$TOKEN" \ + --data-urlencode "wsfunction=core_completion_override_activity_completion_status" \ + --data-urlencode "moodlewsrestformat=json" \ + --data-urlencode "cmid=27" --data-urlencode "userid=3" \ + --data-urlencode "newstate=$STATE"; echo; done +# expected: {"cmid":27,"userid":3,"state":,...,"overrideby":} +``` diff --git a/ops/provision-sync-role.php b/ops/provision-sync-role.php new file mode 100644 index 0000000..0a7c4bb --- /dev/null +++ b/ops/provision-sync-role.php @@ -0,0 +1,125 @@ +dirroot.'/user/lib.php'); +require_once($CFG->libdir.'/accesslib.php'); + +$systemcontext = context_system::instance(); + +// 1) Role `wssync` at system context with the exact caps the sync needs. +$capabilities = [ + 'moodle/user:create', + 'moodle/user:viewdetails', + // Required so core_user_get_users_by_field returns the username/email + // fields it was queried by (user_get_user_details strips them otherwise + // and the WS silently returns []). + 'moodle/user:viewalldetails', + // Required so lookups by field=email get the email field back: + // outside a course user_get_user_details() only exposes email through + // the site identity fields ($CFG->showuseridentity includes email), + // gated by this cap — Task 4 ensureMoodleUser depends on it. + 'moodle/site:viewuseridentity', + 'moodle/user:update', + 'enrol/manual:enrol', + 'moodle/course:overridecompletion', + 'moodle/course:viewparticipants', + 'webservice/rest:use', + // login/token.php for a non-mobile service requires createtoken (the + // mobile service only needs createmobiletoken, allowed by default). + 'moodle/webservice:createtoken', +]; +$role = $DB->get_record('role', ['shortname' => 'wssync']); +if (!$role) { + $roleid = create_role('WS Sync', 'wssync', + 'Privileged web-service role for formations-app progression sync', ''); + $role = $DB->get_record('role', ['id' => $roleid], '*', MUST_EXIST); + echo "role created: {$role->id}\n"; +} else { + echo "role exists: {$role->id}\n"; +} +set_role_contextlevels($role->id, [CONTEXT_SYSTEM]); +foreach ($capabilities as $cap) { + assign_capability($cap, CAP_ALLOW, $role->id, $systemcontext->id, true); + echo "cap allowed: {$cap}\n"; +} +$systemcontext->mark_dirty(); + +// 2) Service user `wssync` +$username = 'wssync'; +$password = getenv('WSSYNC_PASSWORD'); +if (!$password) { fwrite(STDERR, "WSSYNC_PASSWORD required\n"); exit(1); } +$user = core_user::get_user_by_username($username); +if (!$user) { + $u = new stdClass(); + $u->username = $username; + $u->password = $password; + $u->firstname = 'WS'; + $u->lastname = 'Sync'; + $u->email = 'wssync@saillant.cc'; + $u->confirmed = 1; + $u->mnethostid = $CFG->mnet_localhost_id; + $uid = user_create_user($u, true, false); + $user = core_user::get_user($uid); + echo "user created: {$user->id}\n"; +} else { + echo "user exists: {$user->id}\n"; +} + +// 3) Assign the role at system context (idempotent: role_assign checks dups). +role_assign($role->id, $user->id, $systemcontext->id); +echo "role assigned at system context\n"; + +// 4) Dedicated external service. The stock moodle_mobile_app service does +// NOT expose core_completion_override_activity_completion_status, so the +// sync token must be issued against this service instead +// (login/token.php?service=formations_sync). +require_once($CFG->dirroot.'/webservice/lib.php'); +$wsmanager = new webservice(); +$servicefunctions = [ + 'core_user_get_users_by_field', + 'core_user_create_users', + 'core_user_update_users', + 'enrol_manual_enrol_users', + 'core_course_get_contents', + 'core_completion_override_activity_completion_status', +]; +$service = $DB->get_record('external_services', ['shortname' => 'formations_sync']); +if (!$service) { + $serviceid = $wsmanager->add_external_service((object)[ + 'name' => 'Formations Sync', + 'shortname' => 'formations_sync', + 'enabled' => 1, + 'restrictedusers' => 1, + 'downloadfiles' => 0, + 'uploadfiles' => 0, + ]); + $service = $DB->get_record('external_services', ['id' => $serviceid], '*', MUST_EXIST); + echo "service created: {$service->id}\n"; +} else { + echo "service exists: {$service->id}\n"; +} +foreach ($servicefunctions as $fn) { + if (!$DB->record_exists('external_services_functions', + ['externalserviceid' => $service->id, 'functionname' => $fn])) { + $wsmanager->add_external_function_to_service($fn, $service->id); + echo "service fn added: {$fn}\n"; + } else { + echo "service fn exists: {$fn}\n"; + } +} +if (!$DB->record_exists('external_services_users', + ['externalserviceid' => $service->id, 'userid' => $user->id])) { + $wsmanager->add_ws_authorised_user((object)[ + 'externalserviceid' => $service->id, + 'userid' => $user->id, + ]); + echo "user authorised on service\n"; +} else { + echo "user already authorised on service\n"; +} + +echo "OK\n";