feat: sp2 provisioning (keycloak, grist, wssync)
This commit is contained in:
+127
@@ -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>}
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user