Skip to main content

OEIP — détails techniques

Fiche fonctionnelle

À 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é

NiveauStatutContenuLu par
Coreobligatoireoeip-manifest.json, identifiers.json, checksums.sha256, schemas/, directory/, resources/<serviceId>/n'importe quel ENT
CCoptionnelimsmanifest.xml — projection Common Cartridge 1.3Moodle, autres LMS
Nativeoptionnel, déclarénative/<serviceId>/ — charge utile d'archive verbatimuniquement 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.

Ce n'est ni l'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é)

kindorg | 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 :

  1. la substitution a lieu une seule fois, à l'export, là où la base est encore connue ;
  2. toute référence croisée est déjà un globalId : rien à deviner à l'import ;
  3. chaque réécriture est journalisée dans identifiers.rewrites[] ;
  4. une référence non résolue est laissée telle quelle et signalée dans identifiers.unresolvedReferences[] ;
  5. 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érationDétectée par
un fichier de contenu modifiéle relevé, sans aucune clé
le manifeste réécrit, contenu intactle relevé, sans aucune clé
le manifeste réécrit et le relevé recalculéla signature seule
aucune signature, émetteur inconnurien — 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.

Pourquoi cette séparation

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.

RouteAccès
GET /capabilitiesauthentifié
GET /schemas, GET /schemas/:version/:nameauthentifié
POST /exportworkflow interoperability.export.personal202 {jobId}
GET /export/:jobId, /export/:jobId/package, DELETE /export/:jobIdpropriétaire du travail
POST /importSuperAdminFilter + @MfaProtected
GET /import/:jobId/analysis, POST /import/:jobId/apply, GET /import/:jobIdidem

/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.

Le préfixe n'est pas routé sur le front

/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-systemautorité émettrice inscrite dans chaque paquet
archive-versionversion d'archive déclarée dans nativeFormat
servicesservices inclus dans un export par défaut
signature.private-keyclé privée PKCS#8 utilisée pour signer les paquets produits ; absente, les paquets ne sont pas signés
signature.keyIdidentifiant de clé porté dans l'en-tête JWS (kid)
trusted-issuersliste de {sourceSystem, publicKey} — la clé publique PEM X.509 de chaque émetteur reconnu
cc.emitproduction de imsmanifest.xml
cc.emitLtiFallbackdé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.

Ne lister que des modules déployés dans oeip.services

L'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é OEIPProjection
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 :

  1. une métadonnée LOM de ressource portant un identifiant catalogué — <lom:catalog>OEIP</lom:catalog> ;
  2. oeip-manifest.json.ccMapping, qui publie la table complète, avec ccResourceIdentifier = "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.

La version du format n'est pas celle du produit

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