Three classes of drift cleaned up across 85 .md files:
- Em-dashes: replaced ` — ` with the contextually correct separator
(colon for `term: explanation`, period to split clauses across
sentences/source lines, parentheses for asides, semicolon for
tight-linked continuations). Two intentional exceptions remain
in `docs/contributing/documentation.md` (the rule literal) and
inside a fenced ```sh block in `docs/contributing/actions/debugging/ci.md`.
- SPOT self-labels: rewrote "This is the SPOT for X" to describe
the subject directly ("This directory holds X" / "This file
describes X"). Cross-references to other artefacts' canonical
role remain unchanged.
- Heading-emoji position: moved leading-emoji headings (`# 🧩 Title`)
to trailing position (`# Title 🧩`) per the headline rule that
emojis MUST come after the title text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
41 KiB
015: Self-built Moodle image (replaces bitnamilegacy/moodle)
User Story
As an operator deploying web-app-moodle, I want the role to build
its own Moodle container image from upstream Moodle source on top of
the official PHP-FPM container, so that the deployment no longer
depends on the discontinued bitnamilegacy/moodle image and stays on
the latest supported Moodle version with a transparent, auditable
build pipeline.
Background
web-app-moodle currently builds on bitnamilegacy/moodle:4.5. The
image lineage is the legacy mirror of Bitnami's discontinued Moodle
container line. It pins the runtime to Bitnami's opaque
/opt/bitnami/moodle layout, the daemon user, the
install_packages helper, and a bespoke env-driven entrypoint that
generates config.php. The legacy mirror does not receive new tags
beyond the snapshot Bitnami left behind, so newer Moodle releases
(4.6, 5.x) are unreachable through it. The role needs to migrate to
an image we build ourselves from upstream Moodle source.
Moodle's own developer documentation lists official container images
at https://moodledev.io/general/app/development/setup/docker-images.
The "Moodle App" images published there are scoped to the React
Native mobile client, NOT the PHP web application this role deploys.
Upstream therefore does not ship a maintained PHP-FPM Moodle web
image we can consume directly; the closest reference Dockerfiles live
under https://github.com/moodlehq/moodle-docker and are scoped to
core development workflows (PHPUnit, Behat, plugin testing) rather
than production-style deployment. This requirement bases the new
image on the official php:<latest>-fpm image and adapts the
relevant pieces from moodlehq/moodle-docker (extension list, Apache
↔ nginx vhost shape, cron expectations) instead of vendoring the full
moodle-docker layout, which carries dev-only extras the production
deployment does not need.
The role keeps everything the operator already relies on:
- shared-service wiring (oidc, logout, dashboard, matomo, email, mariadb, prometheus) via meta/services.yml,
- the OIDC plugin install path (microsoft/moodle-auth_oidc) baked into the build,
- the post-deploy CLI configuration tasks under tasks/03_oidc.yml.
Identity-integration variants
The role MUST ship a meta/variants.yml with two variants exercising
the two supported identity-integration shapes. The matrix-deploy
mechanism described in variants.md
runs them in order during a full-cycle deploy.
| Variant | Login auth | Live attribute sync | Use case |
|---|---|---|---|
| 0 | auth_oidc (Keycloak SSO) |
auth_ldap sync-only (cron-driven) + auth_oidc per-login refresh |
Default deployment. SSO via Keycloak; LDAP cron sync keeps mdl_user live without requiring re-login. |
| 1 | auth_ldap (direct LDAP bind) |
auth_ldap sync-only (cron-driven) |
LDAP-only deployment for tenants that intentionally do NOT run Keycloak/OIDC against Moodle. Validates that the role works end-to-end without the OIDC shared service. |
In both variants, LDAP is the canonical attribute store and the
Moodle ↔ LDAP plumbing is identical: Moodle's auth_ldap plugin runs
its sync task on Moodle cron and pulls every mapped attribute
listed in Profile-field mapping.
The variants differ only in the login path and in whether
Keycloak is part of the topology at all.
Variant 0: auth_oidc + auth_ldap sync-only (hybrid)
auth_oidcis enabled and set as the primary login method (config_plugin auth_oidc, plusauth = 'oidc,ldap'global setting withoidclisted first).auth_ldapis enabled but NOT as a login method visible to users; it loads only so its sync task runs on cron. The role MUST NOT show "LDAP server" as a login option in the Moodle login page (auth_ldap.config.removecontexts/loginpage_idp_listMUST suppress it).auth_ldap's sync task (\auth_ldap\task\sync_task) MUST be registered in Moodle's scheduled tasks at an interval ≤ 15 minutes by default; the role MUST set the interval viaauth_ldap.config.task_minutes(or the equivalent cron-task override) so a fresh deploy converges to the documented cadence without operator intervention.auth_oidcfield-mapping is configured per Moodleauth_oidcfield mapping: every claim in the mapping table updatesmdl_user/ Custom Profile Fields on every login.auth_ldapfield-mapping mirrors the same Moodle-↔-LDAP column pairing: every "LDAP attribute" cell in the mapping table is bound to its Moodle field viaauth_ldap.config.field_map_<moodle-col>=<ldap-attribute>andauth_ldap.config.field_lock_<moodle-col>=lockedso Moodle stays read-only for the attribute (per the source-of-truth hierarchy; the only write surface is the Keycloak Account Console).- Conflict resolution between the two plugins MUST be
"last-update-wins" against the same canonical LDAP source,
and the role MUST document this in the Moodle
Administration.mdso an operator knows why both plugins mutate the same row. - On idempotent re-deploy, both
auth_oidcandauth_ldapconfiguration MUST reportchanged=0.
Variant 1: auth_ldap only (no OIDC)
auth_oidcis disabled (disable_plugin auth_oidc), andservices.oidc.enabledin this variant'smeta/variants.ymlblock MUST befalseso the shared-service plumbing does NOT add the role to Keycloak's client list.auth_ldapis the primary login method (auth = 'ldap'). Users authenticate by submitting their LDAPuid+ password to Moodle's login form; Moodle binds against the LDAP server with the supplied credentials.- LDAP server connection settings (
auth_ldap.config.host_url,bind_dn,bind_pw,contexts,user_attribute,objectclass) MUST be rendered from the same shared LDAP service definition that variant 0 federates from Keycloak, so both variants point at the same backend without duplicating the connection contract. auth_ldapfield-mapping uses the same Moodle-↔-LDAP column pairing as variant 0; sync_task runs on the same cadence.web-svc-logoutintegration MUST still apply (Moodle's logout flow ends the user's session and redirects to the shared logout endpoint), even though the upstream IdP is not Keycloak. The role MUST document the post-logout redirect path that applies in this variant.- On idempotent re-deploy, the LDAP-only configuration MUST
report
changed=0.
Naming
- Image build context lives in
roles/web-app-moodle/templates/Dockerfile.j2. - Built image tag follows the existing custom-image convention:
{{ entity_name }}_custom. - Container name stays
moodle(per meta/services.yml).
Acceptance Criteria
Image base
- The Dockerfile starts from
php:8.3-fpm(the highest Moodle-supported PHP minor at the time of writing; PHP 8.4 is candidate-status for the targeted Moodle line and is explicitly NOT used). When upstream Moodle promotes a newer PHP minor to "fully supported", the role MAY be bumped in a follow-up; this requirement does NOT auto-track PHP-version drift. - The Dockerfile MUST NOT rely on Bitnami helpers
(
install_packages,BITNAMI_*paths,daemonuser). System packages MUST be installed viaapt-get install --no-install-recommendswithapt-get clean && rm -rf /var/lib/apt/lists/*to keep the image lean. - The PHP extension set required by the targeted Moodle release
MUST be installed via
docker-php-ext-install/docker-php-ext-enable(or equivalent for ext-pgsql, ext-intl, ext-gd, ext-xml, ext-mbstring, ext-mysqli, ext-zip, ext-soap, ext-curl, ext-exif, ext-opcache, ext-fileinfo, ext-ldap). The exact set MUST match the upstream Moodle "Server requirements" page for the pinned version. - OPcache is configured with Moodle's recommended production
settings (
opcache.memory_consumption,interned_strings_buffer,max_accelerated_files,revalidate_freq=60,validate_timestamps=1).
Moodle source
- The Moodle source MUST be fetched at build time from the
floating stable endpoint
https://download.moodle.org/download.php/direct/stable<MAJOR><MINOR>/moodle-latest-<MAJOR><MINOR>.tgzagainst the latest stable major.minor that Moodle officially publishes (e.g.stable50/moodle-latest-50.tgzfor the 5.0 stable line). The tarball is intentionally floating, NOT pinned to an exact patch version; this means amake buildalways lands the most recent stable patch in that branch. - The download MUST be checksum-verified against the
moodle-latest-<MAJOR><MINOR>.tgz.sha256file published next to the tarball, so a corrupted or substituted tarball aborts the build deterministically. The SHA file is re-fetched on every build (no caching) so the floating tarball cannot drift from its published hash. - Moodle's source tree MUST be extracted to
/var/www/html(standard php-fpm web root). The role MUST NOT introduce a Bitnami-style/opt/bitnami/moodle↔/bitnami/moodlesymlink. - The
moodledatadirectory MUST live at/var/www/moodledata(matches Moodle's documented "outside the web root" convention) and MUST be writable by the runtime user. - Ownership of
/var/www/htmland/var/www/moodledataiswww-data:www-dataafter build.
OIDC plugin baked in
- The Microsoft
moodle-auth_oidcplugin MUST be installed underauth/oidc/of the Moodle code tree at build time whenMOODLE_OIDC_ENABLEDis true. - The plugin tag MUST be selected from the GitHub releases of
microsoft/moodle-auth_oidcmatching the Moodle major.minor (existing logic in Dockerfilecurl https://api.github.com/repos/.../tags). The selection logic stays, but its install path moves fromBITNAMI_OIDC_PLUGIN_DIRto<moodle_code>/auth/oidc/.
Cron sidecar
Moodle relies on a cron loop (php admin/cli/cron.php) for
scheduled tasks (auth_ldap sync, session cleanup, message
queue, course completion, etc.). The PHP-FPM image does NOT run
cron natively, so the role MUST add a dedicated sidecar.
- A
cronservice in compose.yml.j2 MUST run alongside the PHP-FPM and nginx containers. It reuses the same custom-built image ({{ entity_name }}_custom) with acommand:that invokesphp /var/www/html/admin/cli/cron.phpin a loop with a sleep interval bounded by Moodle's shortest scheduled-task cadence (≤ 60s). - The cron container MUST mount the same
codeanddatanamed volumes as the FPM container, read-write, so it sees the same Moodle source tree andmoodledatawrites. - The cron container MUST run as
www-data:www-dataand MUST NOT publish any port. - On startup, the cron container MUST wait for the FPM
container's healthcheck before invoking
cron.phpfor the first time, so a freshly initialised database is not hit by an unfinished install. The composedepends_onwithcondition: service_healthycovers this.
Web server
- The role MUST add a sidecar
nginxservice in compose.yml.j2 that fronts the PHP-FPM container. The nginx container uses the officialnginx:<X>-alpineimage. - The nginx vhost configuration is rendered from a Jinja2
template under
roles/web-app-moodle/templates/nginx-moodle.conf.j2and mounted into the nginx container at/etc/nginx/conf.d/default.conf. The vhost MUST implement Moodle's official nginx recipe (https://docs.moodle.org/en/Nginx):try_files $uri =404,fastcgi_split_path_info,fastcgi_pass <fpm-container>:9000,fastcgi_index index.php, and aclient_max_body_sizethat matchesupload_max_filesizein the PHP container. - The PHP-FPM container exposes port 9000 only on the compose internal network; only the nginx container publishes the HTTP port that the front-proxy consumes.
- The published HTTP port (
{{ ports.local.http }}) MUST stay bound toDOCKER_BIND_HOST(no change vs. the existing role).
Runtime configuration via env-driven entrypoint
- The image ships a custom entrypoint
roles/web-app-moodle/templates/entrypoint.sh.j2(rendered into the build context, ADDed by the Dockerfile, marked executable, set as the FPM container'sENTRYPOINT). - On every container start, the entrypoint MUST render
<moodle_code>/config.phpfrom environment variables passed in via env.j2. Variables MUST cover:- site URL (
wwwroot), - DB type / host / port / name / user / password / prefix,
- data root (
dataroot), - admin email (
supportemail,siteadmins), - SMTP (host, port, user, password, secure mode),
- reverse-proxy + SSL flags (
sslproxy=true,reverseproxy=true), - debug mode (
debug=DEBUG_DEVELOPERonly whenMODE_DEBUG=true,debug=DEBUG_NONEotherwise).
- site URL (
- After rendering, the entrypoint MUST chown
<moodle_code>/config.phpand/var/www/moodledatatowww-data:www-dataand execphp-fpmas the DockerCMD. - If
<moodle_code>/config.phpalready exists from a previous run (persisted via the code volume), the entrypoint MUST regenerate it from env (so env changes always apply) but MUST NOT clobber pre-existing site state under/var/www/moodledata.
Volumes & persistence
- meta/services.yml
declares two named volumes:
code(Moodle code tree) anddata(moodledata). Thevolumes:block in compose.yml.j2 MUST mount them at/var/www/htmland/var/www/moodledatarespectively. - Volume names MUST be fresh under the new role
(e.g.
web-app-moodle_code,web-app-moodle_data) and MUST NOT reuse the legacybitnami_*volume names from the old role. Operators upgrading from the Bitnami image MUST start from a fresh deploy ofweb-app-moodle; the role MUST NOT ship a migration path that reads from the legacybitnami_moodle_*volumes. The README MUST document this fresh-deploy precondition explicitly. - On first start, the entrypoint copies the baked code tree
(
/var/www/htmlfrom the image) into the persistent code volume only when the volume is empty, so subsequent restarts keep operator-installed plugins.
Role tasks
- vars/main.yml
drops every
BITNAMI_*variable. Replacements live underMOODLE_CODE_DIR=/var/www/html,MOODLE_DATA_DIR=/var/www/moodledata,MOODLE_OIDC_PLUGIN_DIR=/var/www/html/auth/oidc,MOODLE_RUNTIME_USER=www-data:www-data. - tasks/02_ownership.yml
uses the new paths and the
www-data:www-datauser. - tasks/03_oidc.yml
replaces
/opt/bitnami/moodle/admin/cli/...invocations with/var/www/html/admin/cli/...and runs them aswww-data(e.g.container exec --user www-data {{ MOODLE_CONTAINER }} php /var/www/html/admin/cli/cfg.php …). - The legacy
tasks/01_patch_config.yml(which sed-patched Bitnami's pre-bakedconfig.php) is removed: the new env-driven entrypoint regeneratesconfig.phpon every container start, so no post-deploy patching is required.
Documentation
- README.md updates the Source / Image section: the Bitnami reference is replaced with a link to this requirement and to https://moodledev.io/general/app/development/setup/docker-images.
- The role's
Administration.mdis removed (no Bitnami paths to retarget; the operator-facing CLI lives atMOODLE_CLI_DIRdefined in vars/main.yml). - The role's
TODO.mdis removed: the Bitnami-issue link is gone, and the only residual concern (sendmail availability formail()) is now covered by the msmtp install + healthcheck. No follow-up is outstanding against the self-built layout.
OIDC end-to-end coverage
- OIDC sign-in via Keycloak MUST work for Moodle's
auth/oidcplugin against the realm provisioned byweb-app-keycloak, withemailclaim verified and the persona auto-provisioned on first login. - OIDC sign-out MUST log the user out of both Moodle and the
Keycloak session (post-logout redirect honours
web-svc-logout). - The CLI configuration in
tasks/03_oidc.yml
MUST install, enable, and persist the
auth_oidcplugin against the new code path; idempotent re-deploy MUST reportchanged=0on the second run.
Profile-field mapping (Keycloak ↔ LDAP ↔ Moodle)
Source-of-truth hierarchy
The role MUST enforce the following layered contract:
- Persistence / canonical store: the LDAP server is the sole canonical store for every user-profile attribute listed in the mapping table. Every other component in the system reads from LDAP (directly or transitively) and never holds an authoritative copy that can drift from it.
- Definition of attribute correspondence: the
Keycloak LDAP user-federation mapper owns the binding from
"which LDAP attribute backs which Keycloak user-
profile attribute". All other consumers MUST derive their
field-name pairing from this mapper:
auth_ldapin Moodle MUST bind each Moodle column to the same LDAP attribute name that the Keycloak federation mapper uses for the equivalent Keycloak attribute.- The Keycloak OIDC client mappers for the Moodle RP MUST emit each claim from the same Keycloak attribute that the federation mapper feeds from LDAP. The mapping table in this document MUST stay in sync with the Keycloak federation mapper definition; the table is the documented expression of the same mapping.
- Edit surface: end-user edits MUST happen exclusively via
the Keycloak Account Console (or its admin equivalent for
admin-managed attributes). Moodle MUST NOT expose editable
profile fields for any attribute backed by LDAP; the role
MUST lock the corresponding Moodle columns
(
auth_ldap.config.field_lock_<col>=locked) so edits made inside Moodle cannot diverge from LDAP. - Propagation: a Keycloak Account Console edit MUST flow
into LDAP through the WRITABLE federation provider, then back
into Moodle either via the next
auth_ldapsync cycle (cron- driven, both variants) or via the next OIDC token mint (per-login refresh, variant 0 only).
This hierarchy means: Moodle is read-only for LDAP-backed attributes, the Account Console is the only write surface, and the Keycloak federation mapper is the configuration document that everything else MUST match.
Mapping table
Goal: every Moodle user-profile field that the standard
mdl_user table or a Moodle custom profile field can hold MUST
flow from the LDAP canonical store transparently into Moodle,
through Keycloak's federation mapper for the OIDC path, and
through Moodle's auth_ldap field-mapping for the LDAP-sync
path, both bound to the same LDAP attribute names.
The mapped surface MUST cover at minimum the following Moodle
fields, all of which are exposed as configurable claim mappings
by auth_oidc ("Plugins → Authentication → OIDC → Field
mapping"):
| Moodle field | OIDC claim | LDAP attribute (cn=schema) |
|---|---|---|
username |
preferred_username |
uid |
firstname |
given_name |
givenName |
lastname |
family_name |
sn |
middlename |
middle_name |
initials |
alternatename |
nickname |
displayName |
firstnamephonetic |
custom claim name_phonetic_given |
infinitoNamePhoneticGiven (custom) |
lastnamephonetic |
custom claim name_phonetic_family |
infinitoNamePhoneticFamily (custom) |
email |
email |
mail |
phone1 |
phone_number |
telephoneNumber |
phone2 |
custom claim phone_number_alt |
mobile |
address |
address.street_address |
street |
city |
address.locality |
l |
country |
address.country (ISO-3166-1 alpha-2) |
c |
institution |
custom claim institution |
o |
department |
custom claim department |
ou |
description (bio) |
custom claim description |
description |
idnumber |
custom claim id_number |
employeeNumber |
url (web page) |
website |
labeledURI |
lang |
locale |
preferredLanguage |
timezone |
zoneinfo |
infinitoTimezone (custom) |
Keycloak user profile (single source of truth)
- The realm provisioned by
web-app-keycloakMUST enable the declarative user profile (userProfileEnabled=true). - The user profile JSON MUST declare every attribute in the
table above as a top-level attribute with appropriate
permissions.view = ['admin','user']andpermissions.edit = ['admin','user'](or admin-only foridnumber,institution,departmentif the tenant requires it; the role MUST default to user-editable and explicitly document any admin-only narrowing). - Validation rules on each attribute MUST match Moodle's
column constraints (e.g. ISO-3166-1 alpha-2 for
country, RFC 5322 foremail).
LDAP schema extension (custom attributes)
The mapping table above introduces three attributes that are not
in the core LDAP schemas (inetOrgPerson /
organizationalPerson / person / posixAccount):
infinitoNamePhoneticGiven, infinitoNamePhoneticFamily, and
infinitoTimezone. These MUST be defined in a custom LDAP schema
shipped by the project's LDAP role rather than overloaded onto
existing attributes.
- A new schema file (e.g.
roles/svc-db-openldap/tasks/schemas/moodle.yml) MUST register the three attribute types and an auxiliary object classinfinitoMoodleUserthat lists them underMAY (...). The schema MUST follow the conventions of the existing nextcloud schema. - The OID prefix MUST stay under the project's existing
1.3.6.1.4.1.99999.xprivate-enterprise placeholder namespace (same root the Nextcloud schema uses); concrete OIDs MUST NOT collide with any existing assignment in theroles/svc-db-openldap/tasks/schemas/tree. - Each attribute uses standard syntax:
infinitoNamePhoneticGiven,infinitoNamePhoneticFamily: DirectoryString, SINGLE-VALUE.infinitoTimezone: IA5String, SINGLE-VALUE (IANA timezone name likeEurope/Berlin).
- User entries created or migrated by the project MUST add
the
infinitoMoodleUserauxiliary class so the three attributes are storable on every user. - On idempotent re-deploy of the LDAP role, the schema
registration MUST report
changed=0(i.e. thecn=schemaentry is not re-pushed if it already matches).
Keycloak ↔ LDAP user federation
- The Keycloak realm MUST configure an LDAP user-federation provider against the project LDAP server provisioned by the RBAC/LDAP roles (per 004-generic-rbac-ldap-auto-provisioning.md).
- An LDAP attribute mapper MUST exist for every row of the
table above whose "LDAP attribute" column is populated. The
mapper MUST be created by the role idempotently (e.g. via
kcadm.sh create user-federation/<id>/mappers/...); a second deploy MUST reportchanged=0. - The federation mode MUST be
WRITABLE(or, if the LDAP server cannot accept writes from Keycloak, the role MUST pre-populate every mapped attribute on user creation via a separate path and document whyWRITABLEwas rejected). - Edits made to a mapped attribute via the Keycloak
Account Console (e.g. user updates
phone1) MUST be written back to LDAP within the next sync interval (or synchronously, depending on the federation mode chosen).
Custom-claim namespace
The four custom OIDC claims emitted by Keycloak for Moodle
(name_phonetic_given, name_phonetic_family,
phone_number_alt, id_number, institution, department,
description) MUST follow a flat snake_case naming convention
without a URN namespace. Rationale: auth_oidc's field-mapping
UI lists every claim verbatim, so short names keep the operator-
facing config readable, and Keycloak emits them flat next to the
standard claims (given_name, family_name, …) which already
do not use URNs. The role MUST NOT introduce a project-specific
URN prefix for these claims.
Keycloak OIDC client mapping (Moodle as RP), variant 0 only
The Keycloak OIDC client representing Moodle MUST be provisioned
exclusively through the existing
web-app-keycloak auto-provisioning
path that consumes the per-app services.oidc.enabled flag
in meta/services.yml.
The Moodle role MUST NOT call kcadm.sh create clients itself.
-
In variant 0,
services.oidc.enabledistrue, which triggersweb-app-keycloak's usual auto-provisioning of the client, the redirect URIs, and the role/scope mappings. -
In variant 1,
services.oidc.enabledisfalse. The Moodle client MUST NOT be created in Keycloak in this variant (auto-provisioning skips it). Operators that switch between variants MUST be free to do so without orphan-client cleanup beyond whatweb-app-keycloak's pruning already does. -
The Keycloak OIDC client representing Moodle MUST emit every claim listed in the table above on the access token and on the userinfo endpoint. Standard claims (
given_name,family_name,email,phone_number,address,website,locale,zoneinfo,preferred_username,middle_name,nickname) MUST be provided by built-in OIDC scopes (profile,email,phone,address). -
Custom claims (
name_phonetic_given,name_phonetic_family,phone_number_alt,institution,department,description,id_number) MUST be emitted via dedicated OIDC user-attribute mappers on the Moodle client. The mappers MUST be created idempotently by the role.
Moodle auth_oidc field mapping (variant 0 only)
- The CLI configuration in
tasks/03_oidc.yml
MUST set the
auth_oidcfield-mapping table to consume every claim listed above and write it to the corresponding coremdl_usercolumn. - Every Moodle column in the
mapping table is part of core
mdl_user, so the role MUST NOT create Moodle Custom Profile Fields for the canonical attribute set. If a future tenant requires an attribute that is NOT in coremdl_user, that attribute MUST be added in a follow-up requirement, not as a side-effect of this one. - The mapping update mode for every field MUST be set to
"Update on every login" (
oncreateupdate⇒2/oneveryloginupdate⇒2perauth_oidcschema), so an attribute change in Keycloak is reflected at the user's next sign-in without manual re-provisioning. - On idempotent re-deploy, the field-mapping MUST report
changed=0.
Moodle auth_ldap sync-only mode (both variants)
The auth_ldap plugin runs in sync-only mode in variant 0
(login is delegated to auth_oidc) and as the primary login
plugin in variant 1; the field-mapping configuration is
otherwise identical between the two variants and lives in a
single shared task file so neither variant drifts. Per the
source-of-truth hierarchy, every
Moodle column MUST be configured field_lock_<col>=locked
(NOT unlocked) so Moodle remains read-only for LDAP-backed
attributes; the only write surface is the Keycloak Account
Console.
- A new task file
tasks/04_ldap_sync.yml(or equivalent) MUST configure theauth_ldapplugin with:- LDAP server URL, bind DN, bind password, user contexts,
user attribute (
uid/sAMAccountName/ per-tenant), and object class, all sourced from the same shared LDAP service definition that other apps consume, so the connection contract is not duplicated per role. - field-mapping for every Moodle column whose row in the mapping table has a non-empty "LDAP attribute" cell.
field_lock_<col>=lockedfor every mapped column, so Moodle's profile UI MUST NOT allow edits to LDAP-backed attributes; the only write surface is the Keycloak Account Console (per the source-of-truth hierarchy).- the scheduled task
\auth_ldap\task\sync_taskenabled at an interval ≤ 15 minutes, so an attribute change in Keycloak/LDAP propagates to Moodle within one cron cycle even when the affected user does not log in again.
- LDAP server URL, bind DN, bind password, user contexts,
user attribute (
auth_ldapuser creation MUST be enabled (auth_ldap.config.creators=*for the relevant attribute-equivalence rule) so users that exist in LDAP but have never logged into Moodle are pre-provisioned by the sync task.auth_ldapuser-removal action MUST be set to "Suspend" (NOT "Delete"), so a user removed from LDAP loses access but their Moodle history (forum posts, course enrolments) is preserved.- In variant 0, the
auth_ldapplugin MUST be hidden from the login page so users cannot bypass OIDC by entering LDAP credentials directly; the configuration that achieves this MUST be applied idempotently. - On idempotent re-deploy, the
auth_ldapconfiguration MUST reportchanged=0.
End-to-end verification (Playwright)
The Playwright spec for web-app-moodle MUST cover both identity
variants and exercise three distinct propagation paths for every
mapped attribute: OIDC at login (variant 0), auth_ldap sync
without login (both variants), and direct LDAP-bind login
(variant 1).
Common (both variants)
- Seed: the spec MUST seed a known persona (default:
biber) with every attribute listed in the mapping table through the Keycloak Admin REST API (variant 0) or directly via LDAP modify (variant 1) before invoking the first Moodle login. Seed values MUST be fixture constants in the spec, NOT Faker-style randomness, so the assertion is deterministic. - LDAP-sync without login: the spec MUST mutate biber's
phone1server-side (variant 0: via Keycloak; variant 1: via direct LDAP modify), wait for the nextauth_ldapsync cycle (or trigger it explicitly viaphp /var/www/html/admin/cli/scheduled_task.php --execute='\auth_ldap\task\sync_task'throughmake exec), and assert that biber's Moodle profile reflects the newphone1without biber logging in again. This proves the LDAP cron-sync path is wired correctly. - The spec MUST be gated per
006-playwright-service-gated-tests.md.
It MUST skip the variant-0-only cases cleanly (not fail)
when the OIDC shared service is disabled via
SERVICES_DISABLED, and skip the LDAP cases cleanly when the LDAP shared service is disabled.
Variant 0: OIDC + LDAP sync (hybrid)
- After biber signs into Moodle via Keycloak OIDC, the
spec MUST navigate to biber's Moodle profile page (or
query Moodle's
/user/edit.php?id=<id>rendered form) and assert that every field listed in the mapping table renders the exact value seeded in Keycloak. - OIDC per-login refresh: the spec MUST mutate biber's
phone2in Keycloak, log biber out and back in, and assert the new value is reflected on biber's Moodle profile (validates the "Update on every login" semantic ofauth_oidc). - LDAP read-through: the spec MUST also assert that the
mutated
phone2is present in LDAP. The assertion path MUST be one of:- an
ldapsearch(or equivalent) query executed inside the runner viamake exec, OR - a Keycloak Account Console fetch that round-trips the
attribute from LDAP (only acceptable if the federation
provider is configured
READ_ONLYand Keycloak's LDAP read-through proves provenance).
- an
- The login page MUST NOT expose an LDAP-credential entry field; the spec MUST assert that only the SSO entry point is visible.
- Read-only enforcement: the spec MUST assert that
Moodle's profile-edit form (
/user/edit.php?id=<biber-id>) renders every LDAP-backed field as disabled / read-only (e.g.<input … disabled>or absence of the input altogether). Only the Account Console may edit; Moodle MUST refuse local edits.
Variant 1: LDAP-only
- biber MUST sign into Moodle by submitting his LDAP
uidand password directly to Moodle's login form. The spec MUST drive that login form (no OIDC redirect, no Keycloak round-trip). - After login, the spec MUST navigate to biber's Moodle profile page and assert that every field listed in the mapping table whose "LDAP attribute" cell is non-empty renders the value seeded directly in LDAP.
- The spec MUST mutate biber's
phone1directly in LDAP (viamake execandldapmodifyagainst the project LDAP service), wait for the nextauth_ldapsync cycle (or trigger it explicitly), and assert the new value appears on biber's Moodle profile without biber logging in again. - The Moodle login page MUST NOT show an OIDC entry button
in this variant; the spec MUST assert that only the
username+passwordform is rendered.
Playwright coverage
roles/web-app-moodle/files/playwright.spec.jsis created (or replaces any existing stub) and follows 006-playwright-service-gated-tests.md: every shared-service-dependent test MUST gate on the matching env flag.- The spec MUST cover at minimum:
- Baseline reachability:
GET /returns < 400, response carries the canonical domain fromlookup('domain', application_id). - CSP: response headers include the role's CSP, no inline- script violations during landing.
- administrator OIDC login: dashboard → Moodle login button →
Keycloak SSO with
ADMIN_USERNAME/ADMIN_PASSWORD→ authenticated Moodle UI. Logout returns to dashboard with no Keycloak session left behind. - biber OIDC login: same as (3) for
BIBER_USERNAME/BIBER_PASSWORD. - administrator + biber together: admin and biber both sign in via OIDC, biber's persona is auto-provisioned on first login (no manual user creation required), and admin can see biber listed under Site administration → Users → Browse list of users.
- Baseline reachability:
- Spec credentials MUST be sourced exclusively from the
Playwright env injection (
ADMIN_USERNAME,ADMIN_PASSWORD,BIBER_USERNAME,BIBER_PASSWORD); no hard-coded credentials in the spec file. - The spec MUST install
installCspViolationObserver(page)intest.beforeEachper the existing project convention. - The spec MUST run green via the project's standard
Playwright entry point in CI (
test-e2e-playwrightrole).
CI matrix
- Both variants MUST run in the project's CI matrix-deploy
job (
test-deploy-server) so a regression on either identity-integration shape is caught before merge. The doubled CI cost (≈ 2× the single-variant baseline for this role) is accepted as the cost of validating both supported tenants. - If CI capacity becomes a hard constraint in a follow-up, the role MAY be split per a future requirement; this requirement does NOT permit dropping a variant from CI as a unilateral cost-saving move.
Iteration procedure
The implementing agent MUST iterate on this role per role.md, with the matrix- variant guidance from variants.md applied because the role ships two identity-integration variants.
- Propose
SERVICES_DISABLED="matomo,email"once at the start and persist that decision for every deploy in the iteration. - First deploy MUST be a FULL-matrix baseline:
make deploy-fresh-purged-apps APPS=web-app-moodle FULL_CYCLE=true. This iterates rounds 0 and 1 against both variants in order; the inter-round purge runs because the variant changes between them. - Edit-fix-redeploy iterations MUST be done per variant.
The agent MUST pin
VARIANT=0(orVARIANT=1) on every command for the variant it is currently iterating, and MUST state the choice explicitly before the first command:VARIANT=<idx> make deploy-reuse-kept-apps APPS=web-app-moodleis the default reuse-deploy.VARIANT=<idx> make deploy-reuse-purged-apps APPS=web-app-moodleMAY be used once when reuse-kept reproduces a failure and entity-state involvement is suspected, then the agent MUST return to reuse-kept.
- When debugging cross-variant interactions (e.g. variant 1
breaks because variant-0 LDAP/OIDC state was not purged),
the agent MUST first reproduce with the FULL matrix
(omit
VARIANT=), then pin to the failing variant for the focused fix loop, and re-run the FULL matrix once the fix is believed complete. make testMUST pass before every deploy.- When both variants' deploys are green, the agent MUST run
Playwright Spec Loop
against
roles/web-app-moodle/files/playwright.spec.jspinned to each variant in turn, and confirm every spec scenario passes on the local stack for both variants. - All inspection (live logs, browser,
make exec) MUST happen before each redeploy, per role.md.
Autonomy & commit policy
- The agent MUST work the full requirement to completion without intermediate operator intervention. Questions for the operator are deferred until after every Acceptance Criterion above is checked off.
- The agent MUST NOT create any git commit during implementation. No partial commits, no checkpoint commits.
- When all Acceptance Criteria are checked,
make testis green, the role iteration loop reports a clean reuse-deploy, and Playwright is green, the agent lands the entire change set as a single commit onfeature/moodle-self-builtand then instructs the operator to rungit-sign-pushoutside the sandbox per CLAUDE.md. - Only after the single commit is in place, the agent MAY surface any open questions or design follow-ups for the operator (collected in batch, not asked mid-iteration).
Prerequisites
Before starting any implementation work, the agent MUST read
AGENTS.md and follow all instructions in it. The
existing bitnamilegacy/moodle-based role is the structural
reference for the shared-service wiring; deviating from those
conventions requires explicit justification.