| Couche | Mécanisme | Granularité | Fuite possible ? |
|---|---|---|---|
| DNS / Nginx | server_name → proxy tenant | Par domaine | Non — Host header |
| PostgreSQL | organizationId + Row-Level Security | Par ligne | Non — RLS enforce |
| NATS | Préfixe tenant.{id}.* sur tous les sujets | Par message | Non — NATS ACLs |
| SPINA | Chaîne blockchain séparée par tenant | Par chaîne | Impossible — hash incompatible |
| NOVA (processus) | Ports dédiés, répertoire data/ séparé | Par processus | Non — bind 127.0.0.1 |
| Cockpit (frontend) | JWT with tenant claim, API filter | Par token | Non — middleware vérifie |
| Fichiers | Répertoire /opt/nova/tenants/{id}/ | Par dossier | Non — permissions UNIX |
| Certificats SSL | Let's Encrypt par domaine (SNI automatique) | Par domaine | Non — TLS SNI |
| Logs | Fichier séparé /var/log/nova/{tenant}.log | Par fichier | Non — permissions 640 |
Une seule base PostgreSQL. Chaque table a une colonne organizationId. Row-Level Security (RLS) empêche un tenant de lire ou écrire les données d'un autre tenant. Le middleware Forge injecte organizationId dans chaque requête.
-- Activation RLS par table
ALTER TABLE "Service" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Client" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Invoice" ENABLE ROW LEVEL SECURITY;
-- Politique : lire SEULEMENT ses propres lignes
CREATE POLICY tenant_isolation ON "Service"
FOR ALL
USING ("organizationId" = current_setting('app.current_tenant_id')::uuid);
-- Le middleware Forge définit le tenant avant chaque requête :
-- SELECT set_config('app.current_tenant_id', $tenantId, true);
RLS est activé au niveau base de données — même si un bug applicatif oublie le filtre, PostgreSQL bloque. C'est la dernière ligne de défense.
Chaque tenant a sa propre chaîne SPINA avec un bloc genesis unique. Les chaînes sont physiquement séparées (fichiers distincts). Les signatures Ed25519 sont liées à la clé du tenant — impossible d'injecter un bloc d'un tenant dans un autre.
Structure fichiers :
/opt/nova/spina/
├── tenant-a/
│ ├── chain.dat ← chaîne du MSP A
│ ├── genesis.json ← bloc #0 unique
│ └── peers.json ← pairs du tenant A
├── tenant-b/
│ ├── chain.dat ← chaîne du MSP B
│ ├── genesis.json ← bloc #0 unique (différent de A)
│ └── peers.json
└── tenant-c/
└── ...
API SPINA (port 5110) → filtre par ?tenant=xxx
POST /api/spina/add?tenant=a → ajoute dans chain.dat du tenant A
GET /api/spina/blocks?tenant=b → lit UNIQUEMENT la chaîne du tenant B
Chaque tenant a ses propres instances des 7 systèmes NOVA. Chaque processus est isolé par port TCP et répertoire de données. Zéro mémoire partagée entre tenants.
Ports réservés par tenant :
┌──────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Tenant │ SYN │ CYT │ HOME │ DERM │ MESH │ ALFA │ SPINA│
├──────────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ tenant-a │ 5191 │ 5190 │ 5193 │ 5192 │ 5195 │ 5196 │ 5110 │
│ tenant-b │ 5291 │ 5290 │ 5293 │ 5292 │ 5295 │ 5296 │ 5210 │
│ tenant-c │ 5391 │ 5390 │ 5393 │ 5392 │ 5395 │ 5396 │ 5310 │
└──────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
Pattern : port = 5{tenant_index}XX
- tenant 0 (a) → 51XX
- tenant 1 (b) → 52XX
- tenant 2 (c) → 53XX
- etc.
Lancement :
nova organism --tenant a --bind 127.0.0.1
→ écoute sur 51XX, data/ dans /opt/nova/tenants/a/
ALFA est le système de conscience de l'organisme. Il observe, apprend, pose des questions, forme des hypothèses. Chaque tenant a sa propre instance ALFA avec sa propre mémoire, ses propres pensées, son propre apprentissage. Les consciences ne se croisent jamais.
Structure ALFA par tenant :
/opt/nova/tenants/{tenant_id}/alfa/
├── memory.json ← mémoire persistante (pensées, hypothèses)
├── attention.json ← focus actuel, curiosité, alertness
├── learned/ ← patterns appris (spécifiques au réseau du tenant)
│ ├── normal_traffic.json
│ └── attack_patterns.json
└── dialogue.log ← conversations avec le greffé
Isolation ALFA :
┌──────────────────────────────────────────────────────────────┐
│ TENANT A — Garage Durand TENANT B — Hôpital│
│ ───────────────────── ──────────────────│
│ │
│ ALFA-A : "Je vois 5 équipements" ALFA-B : "Je vois 340 équipements"
│ ALFA-A : "Le port 443 est anormal" ALFA-B : "Le VLAN 12 a un nouveau MAC"
│ ALFA-A apprend le trafic du garage ALFA-B apprend le trafic de l'hôpital
│ │
│ ↑ ZONE ÉTANCHE ↑ ↑ ZONE ÉTANCHE ↑
│ │
│ ALFA-A NE SAIT PAS que l'hôpital existe. │
│ ALFA-B NE SAIT PAS que le garage existe. │
│ │
│ Même si les deux tournent sur la même machine physique, │
│ leurs consciences sont dans des fichiers séparés, │
│ des processus séparés, des ports séparés. │
└──────────────────────────────────────────────────────────────┘
Port ALFA par tenant :
tenant-a → 127.0.0.1:5196
tenant-b → 127.0.0.1:5296
tenant-c → 127.0.0.1:5396
Vérification :
curl http://127.0.0.1:5196/thoughts → pensées du tenant A
curl http://127.0.0.1:5296/thoughts → pensées du tenant B
→ Résultats différents. Aucune intersection possible.
Si un MSP demande à ALFA « Que vois-tu ? », ALFA répond avec les données de SON tenant uniquement. Sa mémoire, ses hypothèses, ses alertes perçues — tout est scopé au tenant. Zéro contamination croisée. Chaque ALFA est une conscience souveraine dans sa capsule.
┌─────────────────────────────────────────────────────────┐
│ NGINX — Virtual Host Routing │
├─────────────────────────────────────────────────────────┤
│ │
│ server { │
│ server_name systel-it.fr *.systel-it.fr; │
│ → proxy_pass http://127.0.0.1:3003; │
│ → set_header X-Tenant-ID "tenant-a"; │
│ } │
│ │
│ server { │
│ server_name cabinet-conseil.fr; │
│ → proxy_pass http://127.0.0.1:3003; │
│ → set_header X-Tenant-ID "tenant-b"; │
│ } │
│ │
│ Le cockpit lit X-Tenant-ID → affiche les données │
│ UNIQUEMENT de ce tenant. │
│ │
│ Let's Encrypt : certbot --nginx -d systel-it.fr │
│ → SSL automatique par domaine, renouvellement cron. │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ │ SCÉNARIO : Internet coupé au Garage Durand │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 14h32 — Coupure fibre │ │ ─────────────────── │ │ • Internet down │ │ • NOVA Appliance continue de tourner (locale, rack 4U) │ │ • Le MSP reçoit une alerte SMS (via backup 4G) │ │ │ │ 14h33 — MSP prend le contrôle │ │ ────────────────────────────── │ │ • Ouvre NOVA Field sur son téléphone │ │ • Se connecte au WiFi local du garage │ │ • Voit le Holoscope (données en cache) │ │ • Voit les alertes Cytokine (calculées localement) │ │ • Isole un port suspect → action exécutée immédiatement │ │ • L'action est mise en file d'attente SPINA │ │ │ │ 16h10 — Retour Internet │ │ ─────────────────────── │ │ • SPINA sync : tous les blocs locaux → chaîne principale │ │ • File d'attente vidée : actions appliquées côté cloud │ │ • Dashboard MSP mis à jour avec les événements offline │ │ • Zéro perte de données. Zéro interruption de service. │ │ │ └─────────────────────────────────────────────────────────────┘
| Service | Port public | Port local | Isolé par |
|---|---|---|---|
| Forge Next.js | 443 (nginx) | 127.0.0.1:3003 | X-Tenant-ID header |
| API agents.08.ma | 443 (nginx) | 127.0.0.1:8300 | X-Tenant-ID header |
| NATS | Aucun | 127.0.0.1:4222 | Préfixe sujet |
| SPINA API | Aucun | 127.0.0.1:5110 | ?tenant= param |
| SPINA v2 (eIDAS) | Aucun | 127.0.0.1:5099 | ?tenant= param |
| GRAFT | Aucun | 127.0.0.1:5100 | ?tenant= param |
| NOVA SYNAPSE | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| NOVA CYTOKINE | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| NOVA HOMEOSTASIS | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| NOVA DERMA | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| NOVA MESH | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| NOVA ALFA | Aucun | 127.0.0.1:51XX | Port dédié par tenant |
| PostgreSQL | Aucun | 127.0.0.1:5432 | Row-Level Security |
| Redis | Aucun | 127.0.0.1:6379 | Préfixe clé tenant:{id}: |
| Node Exporter | Aucun | 127.0.0.1:9100 | Métriques agrégées |
| Cockpit Server | Aucun (ufw deny) | 127.0.0.1:9090 | Bloqué externe |
| CloudPanel | Aucun (ufw deny) | 127.0.0.1:8443 | Bloqué externe |
Zéro port public sauf 22 (SSH), 80 (HTTP→443), 443 (HTTPS nginx). Tout le reste est 127.0.0.1.
1. CRÉER LE TENANT
├── UUID généré → tenant_id
├── INSERT INTO "Organization" (id, name, slug)
└── INSERT INTO "Domain" (organizationId, domain)
2. CONFIGURER DNS
├── Ajouter A record → 217.160.192.96
└── certbot --nginx -d {domaine}
3. CONFIGURER NGINX
├── Nouveau bloc server { server_name {domaine}; }
├── proxy_set_header X-Tenant-ID "{tenant_id}";
└── nginx -s reload
4. INITIALISER SPINA
├── Créer /opt/nova/spina/{tenant_id}/
├── Générer bloc genesis (hash SHA256 unique)
└── Démarrer spina --tenant {tenant_id}
5. DÉMARRER NOVA
├── Calculer ports : 5{index}XX
├── Créer /opt/nova/tenants/{tenant_id}/
├── nova organism --tenant {tenant_id} --bind 127.0.0.1
└── Vérifier : ss -tlnp | grep 5{index}
6. ACTIVER RLS PostgreSQL
├── ALTER TABLE ... ENABLE ROW LEVEL SECURITY
├── CREATE POLICY tenant_isolation ...
└── Vérifier : tentative de lecture croisée → 0 ligne
7. CONFIGURER COCKPIT
├── URL cockpit → https://{domaine}/dashboard
├── JWT signé avec tenant_id claim
└── Vérifier : changement ?tenant= dans l'URL → 403
8. ACTIVER PWA OFFLINE
├── Servir sw.js + manifest.json
├── Cache des assets Holoscope
└── Vérifier : couper Internet → cockpit toujours accessible
ÉTAPE 0 — Le MSP crée un client
────────────────────────────────
MSP Dashboard → "Nouveau Client" → nom + domaine
→ tenant créé (checklist ci-dessus)
→ URL unique générée : https://{domaine}/audit/{id}
ÉTAPE 1 — Le client exécute le one-liner
─────────────────────────────────────────
$ curl -s https://0data.fr/scan/{id} | bash
→ Phase 0 : détection auto (ARP count → tier S/M/L/XL/XXL)
→ Phase 1 : scan adaptatif (ports, OS, services)
→ Phase 2 : POST /api/scan/submit (avec tenant_id)
→ Phase 3 : redirection → https://{domaine}/audit/{id}/results
ÉTAPE 2 — Holoscope AVANT
─────────────────────────
→ cockpit.html charge les données du scan
→ Affiche le réseau en 3D (grille microscope)
→ Nœuds rouges = vulnérabilités
→ Synapses grises = trafic non surveillé
→ Bouton "Déployer NOVA"
ÉTAPE 3 — Déploiement NOVA
───────────────────────────
→ One-liner avec NOVA_KEY intégrée
→ curl -s https://0data.fr/graft/{tenant_id} | bash
→ Télécharge le symbiote
→ Démarre en mode observe (read-only, zéro risque)
→ Monte les 7 systèmes sur les ports dédiés
ÉTAPE 4 — Holoscope APRÈS
─────────────────────────
→ Même URL, données live
→ Synapses cyan = surveillées par SYNAPSE
→ Alertes or = anomalies détectées par CYTOKINE
→ Arcs verts = corrections HOMEOSTASIS
→ SPINA = X blocs dans la chaîne
→ ALFA = état de conscience
→ Bouton "S'abonner" → Stripe checkout
ÉTAPE 5 — Paiement & Activation
─────────────────────────────────
→ Stripe Checkout (prix selon tier détecté)
→ Webhook checkout.session.completed
→ Service activé dans Organization.services
→ NOVA passe en mode immune (actions réelles)
→ Dashboard MSP mis à jour avec le nouveau client actif
| # | Vérification | Commande |
|---|---|---|
| 1 | API bind 127.0.0.1 | ss -tlnp | grep 5{index} | grep 127.0.0.1 |
| 2 | UFW bloque le port | ufw status | grep 5{index} → rien |
| 3 | PostgreSQL RLS actif | SELECT * FROM "Service" WHERE "organizationId" != current_setting('app.current_tenant_id')::uuid → 0 ligne |
| 4 | SPINA chaînes séparées | diff /opt/nova/spina/A/genesis.json /opt/nova/spina/B/genesis.json → différents |
| 5 | JWT tenant claim | curl -H "Authorization: Bearer {tokenB}" https://{domaineA}/api/agents → 403 |
| 6 | NATS sujet isolé | nats sub "tenant.A.>" ne reçoit pas les messages de tenant.B.> |
| 7 | Redis clé isolée | redis-cli KEYS "tenant:A:*" ≠ redis-cli KEYS "tenant:B:*" |
| 8 | Logs séparés | ls /var/log/nova/ → un fichier par tenant, permissions 640 |
| 9 | SSL par domaine | openssl s_client -connect {domaine}:443 → certificat valide |
| 10 | PWA offline | Couper Internet → cockpit accessible, actions en file d'attente |