OEIP — détails techniques
À quoi sert ce format, ce qu'il emporte et ce qu'il n'emporte pas : OEIP — échange entre ENT.
Positionnement
Le module interoperability ne remplace pas le module archive et ne modifie pas son format.
L'archive reste le format interne ; OEIP est le profil d'échange public, versionné.
Le module ne consomme pas le bus user.repository. Il pilote l'export d'archive par
entcore.export et réinjecte par entcore.import, ce qui lui donne la couverture de tous les
modules déjà gréés sans exiger une ligne de code par module.
Les trois niveaux de conformité
| Niveau | Statut | Contenu | Lu par |
|---|---|---|---|
| Core | obligatoire | oeip-manifest.json, identifiers.json, checksums.sha256, schemas/, directory/, resources/<serviceId>/ | n'importe quel ENT |
| CC | optionnel | imsmanifest.xml — projection Common Cartridge 1.3 | Moodle, autres LMS |
| Native | optionnel, déclaré | native/<serviceId>/ — charge utile d'archive verbatim | uniquement un Open ENT compatible |
Règle de précédence, normative. Un importeur ne lit native/ que si nativeFormat.product
correspond à son propre produit, que la version d'archive lui est compatible, et que
l'opérateur l'autorise. Sinon il lit resources/. Un ENT tiers ignore native/ par construction.
Chaque service déclare sa fidélité (full, partial, native-only, none). Le schéma impose
une notice dès que la valeur n'est pas full, et le validateur rejette un paquet qui s'en
dispense.
Chaque service déclare aussi nativeFolder : le nom que son dossier portait chez l'émetteur. Ce
nom est un libellé traduit, et le destinataire s'en sert pour rétablir le sien — sur le dossier,
et sur le fichier d'index homonyme lorsqu'un module en produit un à sa racine. C'est ce qui permet à
un paquet de circuler entre deux instances de langues différentes.
Structure du paquet
mon-export.oeip (zip ; type MIME application/vnd.oeip+zip)
├── imsmanifest.xml ← à la racine, exigence Common Cartridge
├── oeip-manifest.json ← manifeste normatif
├── checksums.sha256 ← format sha256sum, vérifiable sans clé
├── schemas/1.0/*.schema.json ← copie embarquée : paquet auto-descriptif
├── directory/{organizations,persons,groups,memberships}.json
├── resources/<serviceId>/
│ ├── resources.json | folders.json | attachments.json
│ └── content/<aa>/<localpart>/<nom-assaini.ext>
├── native/<serviceId>/ ← charge utile d'archive verbatim
├── relations.json
├── identifiers.json
└── META/{provenance.json, rgpd.json, signature.json}
Pas de dossier racine unique, contrairement à l'archive : Common Cartridge exige
imsmanifest.xml à la racine. Un .oeip est donc volontairement refusé par /archive/import.
Le zip est écrit par le module, pas par mod-zip : celui-ci n'accepte qu'un niveau de compression
global, alors qu'OEIP compresse le JSON et stocke les binaires tels quels. Les entrées sont
triées et horodatées à une date fixe — sans reproductibilité, aucune régression de format
n'est détectable par comparaison.
Les identifiants de service
Un serviceId est le préfixe de route rendu par Server.getPathPrefix() : la clé
path-prefix de ent-core.yaml si elle existe, sinon le dernier segment du verticle main en
minuscules.
app-address ni un libelléapp-address vaut par exemple /workspace/workspace, alors que le serviceId est workspace.
Et les dossiers d'une archive portent des libellés traduits : un paquet ne doit jamais
dépendre de la langue de l'instance qui l'a produit.
Vocabulaire fermé dans libs/entcore/interoperability/src/main/resources/oeip/vocab/service-ids.json.
Grammaire des identifiants
globalId : urn:oeip:1.0:<kind>:<authority>:<localpart>
sourceId : l'identifiant brut dans la plateforme émettrice
sourceSystem : l'autorité émettrice (nom d'hôte pleinement qualifié)
kind ∈ org | person | group | membership | folder | resource | file | relation.
Quand une ancre nationale existe, l'entité porte aussi un alias déclaré en sameAs :
{ "globalId": "urn:oeip:1.0:org:ent.exemple-a.fr:org-0001",
"sameAs": ["urn:oeip:1.0:org:fr.men.uai:0310000A"] }
Ce que cela corrige
À l'import d'une archive, toute chaîne contenant un UUID connu voit cet UUID remplacé — y compris dans du texte libre, y compris au milieu de HTML. C'est aveugle et non journalisé.
Au niveau Core :
- la substitution a lieu une seule fois, à l'export, là où la base est encore connue ;
- toute référence croisée est déjà un
globalId: rien à deviner à l'import ; - chaque réécriture est journalisée dans
identifiers.rewrites[]; - une référence non résolue est laissée telle quelle et signalée dans
identifiers.unresolvedReferences[]; - la résolution se fait par clé exacte, jamais par expression régulière.
URI réservée dans les corps HTML : oeip:file/<globalId>, oeip:resource/<globalId>.
Intégrité et signature
checksums.sha256 couvre chaque fichier au format sha256sum standard, vérifiable sans aucune
clé. Le relevé couvre tous les fichiers du paquet, oeip-manifest.json compris : seuls s'en
excluent le relevé lui-même et la signature, qui ne peuvent pas s'y contenir. Cette couverture du
manifeste est essentielle — c'est lui qui déclare la fidélité service par service, les niveaux
présents et la présence de personnes mineures.
L'empreinte du relevé n'est pas déclarée dans le manifeste : le destinataire la calcule sur le fichier qu'il a reçu. Une valeur lue dans ce que l'on vérifie ne prouve rien, et la déclarer obligerait à exclure le manifeste du relevé, par circularité.
META/signature.json est un JWS détaché optionnel (RS256), qui porte sur l'empreinte calculée
du relevé — donc, transitivement, sur tout le paquet. Si l'émetteur n'est pas dans les émetteurs de
confiance du destinataire, le paquet est accepté avec un avertissement, jamais rejeté.
Les deux mécanismes se complètent et couvrent des menaces différentes :
| Altération | Détectée par |
|---|---|
| un fichier de contenu modifié | le relevé, sans aucune clé |
| le manifeste réécrit, contenu intact | le relevé, sans aucune clé |
| le manifeste réécrit et le relevé recalculé | la signature seule |
| aucune signature, émetteur inconnu | rien — l'origine n'est alors pas établie, et l'analyse d'import le dit |
Un émetteur de confiance se déclare par sa clé publique, celle qu'il publie — jamais par un secret partagé. Les clés sont configurées localement sur le destinataire ; il n'y a pas de découverte distante.
La signature d'archive charge sa clé de vérification depuis le fichier de clé privée, donc exige la même paire des deux côtés : cela convient à une restauration sur la même plateforme, mais rend l'archive invérifiable dès qu'elle en sort. L'intégrité d'un paquet devait donc pouvoir être établie sans dépendre d'aucune clé, et la signature rester un mécanisme séparé, facultatif, et fondé sur la clé publique de l'émetteur.
Routes et niveaux d'accès
Module déclaré sur /interoperability, app-type: SYSTEM.
| Route | Accès |
|---|---|
GET /capabilities | authentifié |
GET /schemas, GET /schemas/:version/:name | authentifié |
POST /export | workflow interoperability.export.personal → 202 {jobId} |
GET /export/:jobId, /export/:jobId/package, DELETE /export/:jobId | propriétaire du travail |
POST /import | SuperAdminFilter + @MfaProtected |
GET /import/:jobId/analysis, POST /import/:jobId/apply, GET /import/:jobId | idem |
/capabilities est construit depuis le registre des mappers réellement enregistrés, jamais
depuis la liste des modules déployés. Un service sans mapper est refusé explicitement, plutôt que
de produire un dossier vide dans le paquet.
Corps de apply : {"mode": "dry-run"|"apply", "services": [...], "targetUserId": "..."}.
Le mode d'essai est le défaut.
/interoperability n'est pas connu du routeur frontal : un appel sur le port applicatif renvoie
404. Pendant une recette, appeler le module directement sur son port.
Accorder le workflow
Un workflow nouvellement déclaré n'est accordé à personne. Il faut créer un rôle et le rattacher au groupe de profil visé, puis rouvrir une session — les droits sont mis en cache.
MERGE (r:Role {name:'Interoperability - Export personnel'})
ON CREATE SET r.id = 'oeip-export-personal-role'
WITH r
MATCH (a:Action)
WHERE a.name STARTS WITH 'org.entcore.interoperability.controllers.OeipExportController'
MERGE (r)-[:AUTHORIZE]->(a)
WITH r
MATCH (g:Group {id:'<id du ProfileGroup>'})
MERGE (g)-[:AUTHORIZED]->(r)
Configuration
Bloc oeip du service dans ent-core.yaml :
| Clé | Rôle |
|---|---|
source-system | autorité émettrice inscrite dans chaque paquet |
archive-version | version d'archive déclarée dans nativeFormat |
services | services inclus dans un export par défaut |
signature.private-key | clé privée PKCS#8 utilisée pour signer les paquets produits ; absente, les paquets ne sont pas signés |
signature.keyId | identifiant de clé porté dans l'en-tête JWS (kid) |
trusted-issuers | liste de {sourceSystem, publicKey} — la clé publique PEM X.509 de chaque émetteur reconnu |
cc.emit | production de imsmanifest.xml |
cc.emitLtiFallback | désactivé : un lien de lancement n'est pas un transport de données |
Hors du bloc oeip : export-path, import-path, archive-import-path (doit suivre
l'import-path du module archive, c'est là que l'archive reconstruite doit atterrir),
max-package-size, package-ttl-hours, purge-cron, export-timeout-ms, import-timeout-ms.
oeip.servicesL'export d'archive attend une réponse de chaque module demandé et n'a pas de délai de garde propre. Réclamer un module absent fait patienter l'export jusqu'au délai OEIP, sans message utile.
archive.publicConf.apps conditionne la réinjection : une application absente de cette liste est
ignorée silencieusement à l'import — la restauration paraît réussir alors que rien n'a été
importé. La liste a été portée de 4 à 20 services.
Projection Common Cartridge 1.3
| Entité OEIP | Projection |
|---|---|
| Ressource à corps HTML | <resource type="webcontent"> + un <file> par image, plus un <item> |
| Ressource conteneur | <item> sans identifierref |
| Dossier | <item> imbriqué (structure="rooted-hierarchy") |
| Fichier | <resource type="webcontent"> |
Non représentable : tout l'annuaire (le standard n'a aucune notion d'utilisateur, de groupe ni d'inscription), les partages, l'auteur comme identité, les commentaires et versions, les fils de discussion, les exercices (QTI, visé en 1.2).
Aucune extension propriétaire dans le XML. Le standard ne sanctionne qu'un seul mécanisme d'extension, réservé aux types de ressources enregistrés auprès de 1EdTech : y injecter des éléments d'un autre espace de noms produirait un fichier qui ne passerait ni comme Common Cartridge ni comme OEIP. Le pont est double :
- une métadonnée LOM de ressource portant un identifiant catalogué —
<lom:catalog>OEIP</lom:catalog>; oeip-manifest.json.ccMapping, qui publie la table complète, avecccResourceIdentifier = "R_" + sha1(globalId)[0:12].
Une ressource webcontent déclare son fichier principal en href et tous les fichiers qui
l'accompagnent en <file>, où qu'ils se trouvent dans le paquet : un billet cite ainsi les
images dont son corps dépend, y compris quand elles proviennent d'un autre service. La contrainte
de hiérarchie propre ne s'applique qu'aux objets d'application pédagogique — devoirs, discussions,
évaluations — qui ne sont pas produits en 1.0.
Les liens internes d'un contenu sont résolus une fois tous les services décrits, jamais
service par service : un billet peut citer un document de l'espace documentaire, et aucun mapper
pris isolément ne dispose de la table complète. Un lien qui reste non résolu — parce que le
fichier n'était déjà plus disponible au départ — est laissé tel quel et inscrit dans
identifiers.unresolvedReferences. Un lien mort déclaré est admis ; un lien mort tu est
une erreur.
Validation
cd libs/entcore/interoperability
python3 tools/oeip_lint.py build src/test/resources/oeip/fixtures/minimal-1.0
python3 tools/oeip_lint.py lint src/test/resources/oeip/fixtures/minimal-1.0.oeip
tools/test-oeip-lint.sh
oeip_lint vérifie l'intégrité, la conformité aux schémas, l'intégrité référentielle des
identifiants, la cohérence de ccMapping avec imsmanifest.xml, l'existence de chaque
<file href>, l'absence d'identifiant brut résiduel dans les corps HTML, et la cohérence des
fidélités déclarées.
La suite de tests compte quatorze cas, dont treize négatifs : un validateur qui accepte un paquet correct ne prouve rien, ce qui compte est qu'il rejette les paquets fautifs.
La validation ne porte que sur le niveau Core. native/ n'est pas schématisé : c'est, par
définition, la charge utile d'un autre format.
Versionnage
Répertoire nommé <majeur>.<mineur>. Champ facultatif ajouté → version mineure ; retrait,
renommage ou changement de cardinalité → version majeure. Les schémas sont copiés dans le zip,
donc un paquet reste validable hors ligne.
oeipVersion vaut 1.0 indépendamment de la version de l'artefact qui produit le paquet.
Sécurité du dézippage
Un paquet vient de l'extérieur et est traité comme une entrée hostile : refus des chemins absolus et des remontées de répertoire, plafond du nombre d'entrées, et plafond de volume appliqué pendant la lecture du flux — une taille déclarée peut mentir. L'intégrité est vérifiée avant toute écriture métier, y compris avant de reconstruire l'archive de réinjection.
Voir aussi
- Formats d'archive — import & export
- Feeder — seule voie de création des comptes ; un import OEIP n'en crée jamais.