feat: sp2 provisioning (keycloak, grist, wssync)

This commit is contained in:
electron-rare
2026-06-10 22:57:49 +02:00
parent cf552ebe7d
commit 0c8e3fb69a
2 changed files with 252 additions and 0 deletions
+127
View File
@@ -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=<Keycloak client secret, see below>
SESSION_SECRET=<openssl rand -hex 32>
APP_BASE_URL=https://formations.saillant.cc
GRIST_BASE_URL=https://grist.saillant.cc
GRIST_API_KEY=<Grist API key>
GRIST_DOC_ID=bPfitC7Poc4r2xGRx7FgHL
WSSYNC_PASSWORD=<generated on first provision run>
MOODLE_SYNC_TOKEN=<issued against the formations_sync service, see below>
```
**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":<STATE>,...,"overrideby":<wssync id>}
```
+125
View File
@@ -0,0 +1,125 @@
<?php
// Idempotent provisioning of the privileged sync web-service user (wssync)
// used by the app to mirror chapter progression into Moodle completion.
// Run: docker cp ops/provision-sync-role.php moodle:/tmp/ &&
// docker exec -e WSSYNC_PASSWORD=... moodle php /tmp/provision-sync-role.php
define('CLI_SCRIPT', true);
require('/var/www/html/config.php');
require_once($CFG->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";