Aller au contenu

Architecture

Comprendre comment Repod est structuré, et pourquoi il est conçu ainsi, vous aide à raisonner sur ses garanties de sécurité, ses limites opérationnelles et ses modes de défaillance. Cette page explique les décisions de conception derrière la stack — non pas comment l'exploiter, mais pourquoi elle fonctionne de cette manière.

Un backend unique, agnostique du format

Repod est un backend FastAPI unique, une base PostgreSQL unique et un frontend React unique — il n'existe pas de scission « édition APT » vs « édition RPM ». La variable d'environnement REPO_FORMAT (lue une seule fois au démarrage par services/format_router.py) contrôle quel(s) format(s) de paquet le backend gère :

REPO_FORMAT .deb (reprepro) .rpm (createrepo_c) .apk (apk index)
apt (par défaut)
rpm
apk
both
all

Tous les validateurs, gestionnaires de distribution et importateurs sont dispatchés au runtime via les helpers is_apt() / is_rpm() / is_apk() dans format_router.py. Le pipeline d'upload, le modèle RBAC, le journal d'audit, l'export SBOM et le workflow CVE sont identiques quels que soient les formats actifs.

Community vs Enterprise

Repod est distribué en deux éditions qui partagent le même code source et la même topologie de conteneurs — il n'existe pas de « build Enterprise » séparé à installer.

Community Edition (AGPL-3.0) inclut : - Pipeline d'upload complet (scan antivirus, analyse CVE, signature GPG, vérification des dépendances) - Gestion des paquets (upload, import depuis un dépôt amont, suppression, gestion des distributions) - Authentification JWT + token API avec RBAC à 5 rôles - Journal d'audit (JSONL en append-only) - Authentification LDAP / Active Directory - Tableau de bord et endpoints de santé

Enterprise Edition ajoute, en plus de la Community : - File de revue CVE avec workflow d'approbation RSSI (enrichissement EPSS + CISA KEV) - Export SBOM au format CycloneDX 1.5 et SPDX 2.3 - Authentification SSO/OIDC, tokens API pour CI/CD - Politique CVE configurable par sévérité (block / review / warn / allow) - Suivi de SLA et alertes SLA automatisées - Synchronisation de sécurité quotidienne automatisée, sauvegardes planifiées avec auto-vérification - Snapshots multi-versions historiques - Politiques de rétention des paquets, cache amont pour sites air-gap - API de paramètres avancés (webhook, notifications SMTP, LDAP, rétention, politique CVE) - Endpoint de rapport de sécurité pour preuves d'audit NIS2 / ISO 27001 - Formats de paquets Maven, PyPI, npm et registre de conteneurs OCI - Scan de profils de conformité CIS/STIG et détection de dérive de configuration - Contrôle d'accès par distribution et par machine, filtres de contenu - Tableau de bord exécutif (tendances d'exposition CVE à l'échelle du parc) - Inventaire et scan de machines via SSH, jobs d'installation à distance - Haute disponibilité multi-réplique / actif-passif

Basculer d'édition consiste simplement à activer une clé de licence Enterprise signée — la surface d'API et la topologie de conteneurs sont identiques dans les deux cas.


Vue d'ensemble

Repod est construit sur trois principes : la sécurité dès la conception, la séparation des responsabilités, et le privilège minimal.

Le principe de séparation des responsabilités est visible dans le découpage des conteneurs. Les serveurs de dépôt (depot-apt, depot-rpm) sont de purs instances Nginx qui servent des fichiers statiques — ils ne savent rien des utilisateurs, de l'authentification, ni de la validité d'un paquet. Le backend API (backend-api) gère toute la logique métier : validation, indexation, RBAC, journal d'audit, planification. Le frontend (frontend-ui) est une application React compilée livrée par son propre processus Nginx, sans code côté serveur. PostgreSQL (db) détient tout l'état relationnel. Chaque conteneur a une responsabilité unique et bien définie, et aucun ne peut se substituer à un autre.

Le principe de sécurité dès la conception signifie que le modèle de menace a été pris au sérieux dès la phase de conception. La conséquence la plus visible est l'absence du socket Docker. Le backend ne monte jamais /var/run/docker.sock. Il invoque à la place reprepro/createrepo_c directement contre un volume partagé — services/reprepro.py pour APT (add_package()/remove_package()), add-rpm.sh pour RPM — et les opérations GPG utilisent un volume partagé /repos/gnupg. Le backend ne parle jamais au daemon Docker.

Une troisième décision structurelle est que les serveurs de dépôt sont délibérément « bêtes ». depot-apt sert dists/, pool/ et apk/ ; depot-rpm sert les arborescences gérées par createrepo_c. Aucun des deux n'a de logique applicative, de contenu dynamique ou de credentials. Un client qui atteint ces ports obtient exactement l'expérience qu'il obtiendrait d'un miroir public — car c'est exactement ce que sont ces serveurs. L'intelligence réside entièrement dans le backend.

Diagramme des composants

graph TD
    Browser["Browser / curl"] -->|":3003 (HTTP)"| Frontend["frontend-ui\nNginx + React SPA"]
    Browser -->|":80 (APT/APK)"| AptRepo["depot-apt\nNginx — static repo"]
    Browser -->|":8080 (RPM)"| RpmRepo["depot-rpm\nNginx — static repo"]
    Frontend -->|"REST API :8000"| Backend["backend-api\nFastAPI (Python)"]
    AptCli["apt / apk"] -->|":80"| AptRepo
    RpmCli["dnf / zypper"] -->|":8080"| RpmRepo

    Backend -->|"clamscan subprocess"| ClamAV["ClamAV\n(in-container binary)"]
    Backend -->|"grype subprocess"| Grype["Grype\n(in-container binary)"]
    Backend -->|"gpg subprocess"| GnupgVol[("/repos/gnupg\nShared GPG keyring")]
    Backend -->|"SQL (SQLAlchemy Core)"| PG[("PostgreSQL 16\nusers, manifests index,\ninventory, ssh_known_hosts")]
    Backend -->|"JSON / JSONL"| ReposVol[("/repos/\nPackage artifacts, manifests,\naudit logs, security caches")]

    AptRepo -->|"read-only"| PoolDists["pool/ + dists/ + apk/\n(served over HTTP)"]
    RpmRepo -->|"read-only"| RpmTrees["rpm/<distro>/<arch>/\n(served over HTTP)"]
    Backend -->|"read/write"| PoolDists
    Backend -->|"read/write"| RpmTrees
    Backend -->|"reprepro.add_package() / apk index"| AptRepo
    Backend -->|"add-rpm.sh (createrepo_c)"| RpmTrees

    GnupgVol -.->|"shared volume"| AptRepo
    GnupgVol -.->|"shared volume"| RpmRepo

    style ClamAV fill:#f9f,stroke:#333
    style Grype fill:#f9f,stroke:#333
    style GnupgVol fill:#ffe,stroke:#999
    style PG fill:#ffe,stroke:#999
    style ReposVol fill:#ffe,stroke:#999

ClamAV et Grype s'exécutent comme des invocations subprocess à l'intérieur du conteneur backend-api — ce ne sont pas des conteneurs séparés. C'est un compromis délibéré : cela simplifie le déploiement (pas de réseau inter-conteneurs pour les outils de sécurité) au prix de partager le budget CPU/mémoire du backend. Les limites de ressources dans docker-compose.yaml (2,5 Go de RAM, 1,5 CPU) reflètent cela — clamd seul a besoin d'environ 800 Mo pour charger sa base de signatures.

Découpage des conteneurs

Le docker-compose.yaml par défaut est livré avec REPO_FORMAT=all et démarre cinq conteneurs :

Conteneur Image Port par défaut Rôle Montages clés
repod-db postgres:16-alpine (interne uniquement) Base de données applicative Volume postgres_data
depot-apt Custom (Nginx) :80 Sert les dépôts .deb (APT) et .apk (Alpine) /repos/dists, /repos/pool, /repos/apk, /repos/gnupg, /repos/logs
depot-rpm Custom (Nginx) :8080 Sert les dépôts .rpm /repos/rpm, /repos/gnupg, /repos/logs
backend-api Custom (Python 3.12 + FastAPI) :8000 Toute la logique métier : pipeline d'upload, revue CVE, RBAC, audit, scheduler /repos/*, /repos/gnupg, /var/lib/clamav, /repos/grype-db, clé SSH pour l'inventaire
frontend-ui Custom (Node build + Nginx) :3003 Sert la SPA React compilée, proxy /api/ vers backend-api Aucun (intégré à l'image au moment du build)

Si REPO_FORMAT est défini sur apt, rpm ou apk, le conteneur de dépôt inutilisé (depot-rpm ou les volumes spécifiques .apk sur depot-apt) est simplement inutilisé — vous pouvez le retirer de docker-compose.yaml ou le laisser tourner inutilisé.

Le frontend est entièrement sans état à l'exécution. Sa configuration — REACT_APP_API_URL et REACT_APP_REPO_URL — est intégrée au moment du build Docker. REACT_APP_API_URL doit être vide pour que tous les appels /api/v1/... restent relatifs et soient proxyfiés par le Nginx du frontend vers backend-api.

Flux de données : chemin d'upload

Cette séquence décrit ce qui se passe depuis le moment où un utilisateur ou un pipeline CI/CD envoie un paquet à l'API jusqu'à ce qu'il soit disponible pour les clients, quel que soit le format.

  1. Le client envoie une requête multipart POST /upload/ (ou /upload/stream pour le SSE) avec le fichier du paquet et une distribution cible (ex. jammy, almalinux9, alpine3.20). Un JWT ou un token API est requis ; le rôle doit être uploader, maintainer ou admin.
  2. Le backend écrit le fichier dans /repos/staging/incoming/ — une zone de dépôt temporaire jamais servie en HTTP.
  3. Le pipeline de validation en 6 étapes s'exécute de manière synchrone via asyncio.to_thread() (voir Le pipeline de sécurité pour le détail). Le pipeline lit le fichier depuis le staging mais ne le modifie jamais.
  4. Si la validation échoue (erreur de format, incohérence SHA-256, virus ClamAV, ou politique CVE bloquante) : le fichier est déplacé vers /repos/staging/quarantine/, un événement FAILURE est écrit dans le journal d'audit JSONL, et l'API retourne 422 avec le résultat détaillé étape par étape. Le paquet est inaccessible aux clients.
  5. Si la validation passe mais qu'une CVE déclenche une politique de revue : le fichier est déplacé vers /repos/pool/, un manifeste est généré à /repos/manifests/<name>_<version>_<arch>.manifest.json avec status: pending_review, et le paquet est ajouté à l'index central (/repos/manifests/index.json, également reflété dans PostgreSQL) mais pas promu dans l'arborescence du dépôt. Il est stocké mais non installable.
  6. Si la validation passe sans réserve : le backend appelle l'outil de dépôt spécifique au format —
    • APT : reprepro includedeb <distribution> <path> (via services/reprepro.py:add_package()), mettant à jour dists/<distribution>/ et re-signant InRelease
    • RPM : createrepo_c --update <distrib>/<arch>/ (via add-rpm.sh), régénérant repodata/ et signant repomd.xml
    • APK : reconstruction de APKINDEX.tar.gz pour apk/<distrib>/main/<arch>/ et signature

Le manifeste est mis à jour à status: indexed. 7. Le journal d'audit enregistre un événement UPLOAD / SUCCESS avec le hash SHA-256, le nom d'utilisateur de l'uploader, et les résultats complets de validation étape par étape intégrés dans l'entrée.

Flux de données : chemin d'installation client

  1. Le client lit /etc/apt/sources.list.d/repod.list, pointant vers http://<host>:80 <distribution> main.
  2. apt update récupère dists/<distribution>/InRelease et vérifie sa signature GPG contre la clé de confiance. Si la vérification échoue, APT refuse le dépôt. depot-apt n'a aucun rôle au-delà de servir le fichier.
  3. APT parse Packages.gz. Seuls les paquets avec status: indexed existent dans cette arborescence ; les paquets pending_review ou quarantined sont invisibles.
  4. apt install <package> télécharge les fichiers .deb depuis pool/ et vérifie leur SHA-256 contre Packages.gz — comportement APT standard.
  1. Le client lit /etc/yum.repos.d/repod.repo, pointant vers http://<host>:8080/repos/<distribution>/<arch>/.
  2. dnf/zypper récupère repodata/repomd.xml et vérifie sa signature GPG (gpgcheck=1). depot-rpm ne fait que servir les fichiers.
  3. La liste des paquets et les métadonnées de dépendances proviennent de repodata/, générée par createrepo_c. Seuls les paquets indexés apparaissent.
  4. dnf install <package> télécharge les fichiers .rpm et vérifie les checksums depuis repodata/ — comportement DNF/Zypper standard.
  1. Le client a http://<host>:80/apk/<distrib>/main dans /etc/apk/repositories et la clé de signature dans /etc/apk/keys/.
  2. apk update récupère APKINDEX.tar.gz et vérifie sa signature embarquée contre la clé de confiance.
  3. apk add <package> télécharge les fichiers .apk et vérifie les checksums enregistrés dans APKINDEX — comportement apk-tools standard.

Organisation du stockage

Les données relationnelles — utilisateurs, l'index de manifestes, les clients/paquets d'inventaire, les jobs d'installation, la recherche full-text de l'index de paquets, et les empreintes TOFU ssh_known_hosts — vivent entièrement dans PostgreSQL (DATABASE_URL, géré via SQLAlchemy Core + Alembic). Le système de fichiers sous /repos/ contient tout ce qui n'est pas de la donnée relationnelle : artefacts de paquets, manifestes JSON, arborescences de métadonnées de dépôt, le trousseau GPG, les caches et les journaux.

/repos/
├── pool/                   # Canonical package store — all uploads land here
│   └── main/<initial>/<package>/<name>_<version>_<arch>.{deb,rpm}
├── dists/                  # APT index tree — managed by reprepro
│   └── <distribution>/main/binary-amd64/{Packages,Packages.gz}
├── rpm/                    # RPM index trees — managed by createrepo_c
│   └── <distribution>/<arch>/repodata/
├── apk/                    # Alpine index trees — managed by apk index
│   └── <distribution>/main/<arch>/APKINDEX.tar.gz
├── manifests/              # One JSON manifest per package version
│   ├── index.json          # Aggregated index (atomic writes via os.replace)
│   └── <name>_<version>_<arch>.manifest.json
├── conf/                   # reprepro config (distributions file)
├── db/                     # reprepro internal database (APT repo metadata only)
├── audit/                  # Append-only audit log, one file per day
│   └── YYYY-MM-DD.jsonl
├── gnupg/                  # Shared GPG keyring (backend + depot-apt + depot-rpm)
├── staging/                # Transient area — never served over HTTP
│   ├── incoming/           # Files arrive here before validation
│   └── quarantine/         # Failed or rejected packages
├── imports/                # Packages fetched by sync/mirror jobs
├── security/               # CVE decisions and threat intelligence caches
│   ├── kev_cache.json      # CISA KEV cache (TTL 24h)
│   └── epss_cache.json     # EPSS scores cache (TTL 24h)
├── settings.json           # Runtime config (scheduler, LDAP, sync, CVE policy)
├── grype-db/               # Grype vulnerability database cache
├── clamav-db/              # ClamAV signature database (daily.cld, main.cvd)
├── logs/                   # Nginx access logs (shared with depot-* containers)
└── package-index/          # Full-text search working files

Les données PostgreSQL vivent dans le volume Docker postgres_data (voir docker-compose.yaml), pas sous /repos/. /repos/ (partagé en RWX entre les répliques) et le endpoint PostgreSQL doivent tous deux être accessibles depuis chaque réplique du backend dans un déploiement multi-réplique.

La séparation entre pool//rpm//apk/ (binaires + index) et manifests/ (métadonnées) est importante. Le manifeste contient l'intégralité de l'historique de validation — chaque résultat d'étape du pipeline, tous les résultats CVE, l'analyse des dépendances, et l'enregistrement complet d'intégrité SHA-256/SHA-512. La posture de sécurité de n'importe quel paquet peut être reconstituée à partir des seuls manifestes, sans relancer le scanner.

Frontières de sécurité

Ce à quoi depot-apt / depot-rpm peuvent accéder : Leurs arborescences de dépôt respectives (lecture/écriture, gérées par reprepro / createrepo_c / apk index) et le trousseau GPG partagé (pour signer les fichiers d'index). Ils ne peuvent pas lire les manifestes, les journaux d'audit, la base PostgreSQL, les fichiers de staging, ni les décisions de sécurité.

Ce à quoi backend-api peut accéder : Tout ce qui se trouve sous /repos/ et la base PostgreSQL. Il écrit dans les arborescences de dépôt en invoquant reprepro/createrepo_c/apk index directement (APT via services/reprepro.py, RPM via add-rpm.sh, APK via services/distributions_apk.py) contre le volume partagé — jamais via le daemon Docker, auquel il n'a pas accès.

Ce à quoi frontend-ui peut accéder : Rien sur le système de fichiers au runtime. C'est un serveur de fichiers statiques. Toutes les données sont récupérées depuis l'API backend par le navigateur de l'utilisateur.

Pourquoi la suppression du socket Docker est importante : Si le backend montait /var/run/docker.sock, toute exploitation de path-traversal, tout bug de désérialisation, ou toute vulnérabilité de dépendance dans le processus FastAPI pourrait être escaladée jusqu'à un accès root complet à l'hôte via l'API Docker. L'approche par volume partagé limite le rayon d'impact : un backend compromis peut modifier les fichiers auxquels il a accès via le volume, mais il ne peut pas lancer de conteneurs, exécuter des commandes dans d'autres conteneurs, ni modifier l'hôte.

GPG via volume partagé : /repos/gnupg est monté dans le backend et dans chaque conteneur de dépôt. Le backend signe les fichiers d'index en utilisant gpg --homedir /repos/gnupg, et l'outillage des conteneurs de dépôt (reprepro/createrepo_c/apk) utilise le même trousseau. La clé privée ne quitte jamais le volume ; il n'y a ni appel API, ni saut réseau.

Réseau

Tous les conteneurs partagent un unique réseau bridge Docker (repod_network dans le docker-compose.yaml fourni). Aucun trafic inter-conteneurs n'est chiffré — c'est approprié car ils tournent sur le même hôte et le réseau n'est pas exposé à l'extérieur. La seule exposition externe se fait via les ports publiés.

Ports par défaut

Port Conteneur Exposé à Objet
:80 depot-apt Configurable via BIND_HOST Dépôts APT + APK (HTTP en clair, contenu signé)
:8080 depot-rpm Configurable via BIND_HOST Dépôts RPM (HTTP en clair, contenu signé)
:8000 backend-api Configurable via BIND_HOST API REST (navigateur, CI/CD, curl) — ne jamais exposer directement en production
:3003 frontend-ui Configurable via BIND_HOST Interface web

BIND_HOST a pour valeur par défaut 0.0.0.0, qui écoute sur toutes les interfaces. En production, derrière un reverse proxy, définissez BIND_HOST=127.0.0.1 pour empêcher l'accès externe direct à ces ports et laisser le proxy gérer la terminaison TLS.

Les protocoles de dépôt utilisent délibérément du HTTP en clair. L'intégrité du contenu est garantie par la vérification de signature GPG (InRelease, repomd.xml, APKINDEX), pas par TLS. Un attaquant en position d'homme-du-milieu peut observer quels paquets sont téléchargés mais ne peut pas substituer un paquet malveillant sans la clé GPG privée. TLS ajoute la confidentialité pour la liste de téléchargement (utile dans certains modèles de menace) mais ne renforce pas la garantie d'intégrité. Si TLS est requis pour la conformité, terminez-le au niveau du reverse proxy — voir docker-compose.tls.yml.

Placement du reverse proxy

Lors du placement de Repod derrière Nginx ou Caddy, configurez TRUSTED_PROXIES dans backend.env pour y inclure la plage d'adresses du proxy. Le backend utilise cette liste pour extraire correctement les IP clientes depuis les en-têtes X-Forwarded-For pour la limitation de débit et le journal d'audit. La valeur par défaut couvre 127.0.0.1 et les plages privées RFC 1918.

Haute disponibilité (actif-passif)

Pour les déploiements multi-réplique, backend/services/leader_election.py fournit une élection de leader basée sur des verrous advisory PostgreSQL, permettant à plusieurs répliques backend-api de tourner contre la même base de données et un /repos partagé (NFS/EFS ou équivalent). Au démarrage, chaque réplique tente un pg_try_advisory_lock() ; la réplique qui l'obtient devient leader, les autres restent passives. Le verrou est scopé à la session, donc si le processus leader meurt, PostgreSQL le libère automatiquement et une autre réplique peut l'acquérir à son prochain redémarrage.

Seul le leader exécute les jobs cron APScheduler — scheduler_state.scheduler reste non défini sur les répliques passives. Les endpoints qui démarrent un job en arrière-plan suivi en mémoire (scan d'inventaire, job d'installation, job de mirror, job de sync) sont protégés derrière une dépendance require_leader et retournent 503 sur une réplique passive, car la progression, l'annulation et les logs du job ne vivent que dans la mémoire processus de la réplique qui les a créés — un client interrogeant une autre réplique ne verrait rien.

GET /health expose checks.info.ha.is_leader/instance_id/ scheduler_active afin qu'un load balancer ou un opérateur puisse identifier le leader actuel. Voir docker-compose.ha.yml pour un exemple d'overlay documenté.

Sur tout dialecte de base de données non-PostgreSQL (ex. SQLite dans les tests) ou en cas d'erreur pendant l'élection, le leadership est par défaut True — les déploiements mono-instance et de test ne sont pas affectés par ce mécanisme.

Haute disponibilité (actif-actif)

Le leadership actif-passif résout la duplication des jobs cron, mais il ne rend pas, à lui seul, les jobs en arrière-plan propres à une réplique visibles pour les autres répliques. Quatre trackers de jobs (scan d'inventaire, installation, mirror, sync) plus le flux d'événements live du dashboard (Server-Sent Events) plus le tail de logs backend en direct sont, par défaut, un état en mémoire par processus — invisible pour toute réplique autre que celle qui les a démarrés. C'est la véritable raison pour laquelle la création de job doit être protégée par le leader ci-dessus : rien dans l'exécution d'un scan ou d'une installation ne requiert réellement d'être le leader, seul le fait que son état n'a nulle part ailleurs où vivre.

Définir JOB_STATE_BACKEND=redis (et le pointer vers une instance Redis accessible via JOB_STATE_REDIS_URL ou REDIS_URL) déplace cet état vers Redis au lieu de la mémoire processus : la progression, les signaux d'annulation/confirmation, les créneaux de concurrence et les logs sont tous écrits dans des clés Redis au lieu de variables locales, de sorte que toute réplique peut lire ou mettre à jour un job quelle que soit la réplique qui l'a créé. Une fois qu'un flux de job est backend-vérifié comme distribué, la protection require_leader pour créer ce type de job est levée — création, polling, annulation et confirmation de job deviennent tous sûrs à router vers n'importe quelle réplique.

Ceci est opt-in et désactivé par défaut (JOB_STATE_BACKEND=local), ce qui correspond exactement au comportement actuel en mémoire — un déploiement qui ne définit jamais cette variable ne voit aucun changement. Une fois activé, cela couvre l'intégralité de la surface actif-actif, pas un sous-ensemble partiel :

  • Les quatre trackers de jobs — scan d'inventaire, installation, mirror, sync
  • Le bus d'événements SSE du dashboard (GET /dashboard/events)
  • Le flux de logs backend en direct (GET /logs/stream)

Fail-soft, jamais une régression HA silencieuse : si JOB_STATE_BACKEND=redis est défini mais que Redis est injoignable, chaque composant retombe sur un comportement local uniquement, journalise ce repli bruyamment, et continue d'appliquer la protection du leader exactement comme si aucun Redis n'était configuré du tout — une réplique passive ne commence jamais silencieusement à accepter la création de jobs simplement parce que le backend distribué prévu se trouvait indisponible à ce moment. Le bus d'événements SSE et le flux de logs n'ont pas de protection leader au départ (toute réplique accepte déjà des abonnés), donc leur repli signifie seulement que la livraison redevient mono-réplique, pas que quoi que ce soit commence à rejeter des requêtes.

GET /health expose checks.info.ha.job_state_backend.{scan,install,mirror,sync,sse,logs}, chacun rapportant "redis" ou "local" — le backend réellement utilisé en ce moment, après tout repli, pas seulement l'intention configurée. Cela permet à un opérateur de voir exactement quels flux tournent réellement en actif-actif à un instant donné.

L'état de job actif-actif et l'élection de leader actif-passif sont complémentaires, pas alternatifs : l'élection de leader décide toujours qui exécute le scheduler et (pour tout flux pas encore backend-vérifié comme distribué) qui peut créer un nouveau job ; la couche d'état backée par Redis décide si l'état en cours de ce job est visible à l'échelle du parc une fois qu'il existe.

Mode SaaS multi-tenant

Repod peut fonctionner en mode multi-tenant (DEPLOYMENT_MODE=saas) où un déploiement unique sert plusieurs organisations indépendantes, chacune avec ses propres utilisateurs, paquets, distributions et paramètres, sans aucune visibilité sur les données d'un autre tenant. Ce mode est orthogonal aux mécanismes de haute disponibilité ci-dessus — l'un ou l'autre peut être activé indépendamment, et les deux peuvent être combinés dans le même déploiement. Il est désactivé par défaut : une installation on-premise standard ou Community Edition tourne en mode standalone, où rien de tout cela ne s'applique.

Résolution du tenant. Le tenant d'une requête est dérivé du sous-domaine de son en-tête Hostacme.repod.io résout vers le tenant de slug acme. Cette résolution se produit une fois par requête, dans le middleware, avant l'exécution de tout handler de route, et le tenant résolu est exposé au reste de la requête via une variable de contexte plutôt que d'être propagé à travers chaque signature de fonction — toute fonction de service qui lit déjà le contexte de tenant courant devient tenant-aware gratuitement, y compris du code sans requête HTTP en cours (voir les jobs cron ci-dessous). Un sous-domaine qui ne résout vers aucun tenant actif est rejeté avant d'atteindre la logique applicative. Les requêtes sans sous-domaine (une IP brute ou localhost) résolvent vers aucun tenant, ce qui est exactement à quoi ressemble le mode standalone depuis le même chemin de code.

Isolation de la base de données : schéma par tenant. Les données relationnelles de chaque tenant vivent dans son propre schéma PostgreSQL au sein de la même base de données, plutôt que dans des bases séparées ou dans des tables partagées partitionnées par une colonne de tenant. Une fois le tenant d'une requête résolu, les connexions à la base de données pour cette requête ciblent de manière transparente le propre schéma du tenant — le code applicatif qui lit et écrit via la couche d'accès à la base de données normale n'a besoin d'aucun filtrage de tenant par requête, car la frontière de schéma fait ce travail. Un petit ensemble de données réellement cross-tenant (le registre des tenants lui-même, l'état de facturation/abonnement) vit dans un schéma partagé séparé, en dehors du schéma propre à chaque tenant.

L'isolation du système de fichiers suit le même modèle. Les artefacts de paquets, les manifestes, les journaux d'audit et les paramètres runtime sous /repos/ sont résolus vers des sous-répertoires scopés par tenant de la même manière que le schéma de base de données — via le même contexte de tenant par requête, de sorte qu'une fonction qui résout déjà un chemin comme /repos/pool/ résout de manière transparente vers le propre sous-répertoire de ce tenant une fois le contexte de tenant actif, sans chemin de code séparé pour le cas SaaS.

Les jobs planifiés se déploient par tenant. Les jobs cron d'APScheduler (nettoyage de rétention, synchronisation de l'index de sécurité amont, imports de mirror) lisent une planification globale unique, mais leur travail réel porte sur des données scopées par tenant — donc en mode SaaS ils bouclent sur chaque tenant actif et s'exécutent une fois par tenant, plutôt qu'une fois globalement, en utilisant le même mécanisme de variable de contexte que le code cron utilise pour tourner sous un tenant spécifique sans requête HTTP impliquée. Le travail réellement tenant-agnostique — une sauvegarde complète de base de données, ou le téléchargement d'un index de paquets amont public dont le contenu ne diffère pas selon le tenant — s'exécute une seule fois quel que soit le nombre de tenants ; seule l'application de la politique par tenant (quelles CVE signaler, quels paquets retenir) est répétée par tenant.

Un tenant dont l'abonnement expire n'est pas supprimé : ses données restent isolées et intactes, mais l'API est restreinte aux endpoints d'authentification, de facturation et de santé jusqu'à résolution de l'abonnement.