Add API-backed registry mode and evidence artifact service

This commit is contained in:
OpenAI 2026-04-17 15:08:30 +00:00
parent 5836be465c
commit 2db31cb30e
12 changed files with 448 additions and 74 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
VITE_SOC2_SYNC_MODE=local
VITE_SOC2_API_BASE_URL=http://localhost:4177/api

159
README.md
View file

@ -1,101 +1,130 @@
# Chezlepro SOC 2 Type II Cockpit
Cockpit web React orienté **conformité opérationnelle SOC 2 Type II** pour Chezlepro Inc.
Cockpit web React + API Node pour piloter une conformité opérationnelle **SOC 2 Type II**.
Lapplication fournit :
Cette version nest plus seulement un prototype dinterface. Le dépôt contient maintenant :
- un **radar des 5 piliers** SOC 2 ;
- un suivi **pondéré des jalons** ;
- une **bibliothèque de preuves** éditable ;
- une **salle auditeur** avec paquets de revue ;
- une **source de vérité JSON** persistée localement ;
- des **exports/manifests** pour préparer un dépôt Git versionné.
- un **frontend React/Vite** ;
- un **backend HTTP Node** ;
- un **registre JSON versionné** dans `compliance/soc2/registry/` ;
- des **manifests dérivés** ;
- un répertoire d**artefacts de preuves** ;
- un **contrat de connecteurs** pour Forgejo / Icinga2 / Keycloak.
## Architecture
## Structure
```text
src/
App.jsx
styles.css
model/
schema.js
seed.js
stages.js
lib/
utils.js
validation.js
selectors.js
view.js
features/
hooks/
useSoc2Registry.js
features/hooks/
components/
PillarOverviewCard.jsx
MilestoneCard.jsx
EvidenceLibrary.jsx
EvidenceEditor.jsx
DataTab.jsx
ui/
primitives.jsx
server/
index.js
config.js
lib/
http.js
store.js
contracts/
connectors.json
scripts/
seed-registry.mjs
compliance/soc2/
registry/
soc2-registry.json
evidence-manifest.json
auditor-pack-index.json
evidence/
packs/
```
## Principes de conception
## Modes de fonctionnement
- **Modèle** : constantes, schéma, seed data, états des contrôles.
- **Lib métier** : normalisation, validation, calculs, sélecteurs.
- **Hook de registre** : persistance locale, import/export, mutations synchronisées.
- **Composants** : interface séparée du moteur métier.
### Mode local
Le cockpit est prêt à évoluer vers une vraie source de vérité Git/Forgejo :
- chemin recommandé pour le registre : `compliance/soc2/registry/soc2-registry.json`
- pièces jointes recommandées : `compliance/soc2/evidence/`
## Lancer le projet
Le frontend persiste le registre dans `localStorage`.
```bash
cp .env.example .env
# laisser VITE_SOC2_SYNC_MODE=local
npm install
npm run dev
```
Build de production :
### Mode API
Le frontend devient client dune source de vérité sur disque, servie par lAPI locale.
```bash
npm run build
npm run preview
cp .env.example .env
# définir VITE_SOC2_SYNC_MODE=api
npm install
npm run seed:registry
npm run server
npm run dev
```
## Dépendances
API par défaut : `http://localhost:4177/api`
- React
- Vite
- Recharts
- Framer Motion
- Lucide React
## Endpoints backend
## Source de vérité
- `GET /api/health`
- `GET /api/registry`
- `PUT /api/registry`
- `POST /api/registry/reset`
- `GET /api/evidence`
- `POST /api/evidence`
Le cockpit persiste son état dans `localStorage` pour le prototype.
### Exemple : écrire le registre
La prochaine étape naturelle est de remplacer cette persistance par :
```bash
curl -X PUT http://localhost:4177/api/registry \
-H 'Content-Type: application/json' \
--data @compliance/soc2/registry/soc2-registry.json
```
1. un fichier JSON versionné dans un dépôt Forgejo ;
2. une API légère de lecture/écriture ;
3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes.
### Exemple : pousser un artefact de preuve
## Limites actuelles
```bash
python - <<'PY'
import base64, json
from pathlib import Path
payload = {
"filename": "example-proof.txt",
"contentBase64": base64.b64encode(b"preuve de test").decode(),
"mimeType": "text/plain"
}
Path("payload.json").write_text(json.dumps(payload), encoding="utf-8")
PY
- pas dauthentification applicative ;
- pas de backend ;
- pas de gestion de fichiers binaires de preuves ;
- pas encore de synchronisation distante.
curl -X POST http://localhost:4177/api/evidence \
-H 'Content-Type: application/json' \
--data @payload.json
```
## Recommandation dévolution
## Connecteurs prévus
Séquence recommandée :
Le fichier `server/contracts/connectors.json` décrit les contrats attendus pour :
1. brancher le registre JSON versionné ;
2. ajouter gestion de pièces jointes ;
3. ajouter règles de validation plus strictes ;
4. ajouter API backend ;
5. brancher les collecteurs automatiques de preuves.
- **Forgejo** : changements, PR, approbations
- **Icinga2** : disponibilité, incidents, tendances
- **Keycloak** : MFA, rôles admin, revues daccès
Le dépôt neffectue pas encore les collectes réelles sans les paramètres daccès du site cible, mais larchitecture est prête pour les implémenter.
## Ce qui rend cette version utile
- le registre nest plus seulement stocké côté navigateur ;
- lAPI peut servir de **source de vérité locale** ;
- les manifests sont régénérés à chaque écriture ;
- les artefacts de preuves ont un emplacement physique ;
- le front peut fonctionner en **mode local** ou **mode API**.
## Séquence suivante recommandée
1. brancher les vrais accès Forgejo / Icinga2 / Keycloak ;
2. ajouter une authentification applicative ;
3. gérer le téléversement multipart réel ;
4. ajouter des jobs de collecte planifiés ;
5. produire des packs auditeur exportables en HTML/PDF.

View file

@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server/index.js",
"seed:registry": "node server/scripts/seed-registry.mjs",
"dev:full": "sh -c \"node server/index.js & vite\""
},
"dependencies": {
"framer-motion": "^12.23.12",

12
server/config.js Normal file
View file

@ -0,0 +1,12 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const repoRoot = path.resolve(__dirname, "..");
export const host = process.env.SOC2_HOST || "127.0.0.1";
export const port = Number(process.env.SOC2_PORT || 4177);
export const registryPath = path.join(repoRoot, "compliance", "soc2", "registry", "soc2-registry.json");
export const manifestsDir = path.join(repoRoot, "compliance", "soc2", "registry");
export const evidenceDir = path.join(repoRoot, "compliance", "soc2", "evidence");

View file

@ -0,0 +1,34 @@
{
"connectors": [
{
"name": "forgejo",
"purpose": "Collecter les changements, pull requests, approbations et tags de release.",
"input": {
"baseUrl": "https://forge.example.tld",
"tokenEnv": "FORGEJO_TOKEN",
"repositories": ["org/repo"]
},
"producesEvidence": ["change-logs", "pull-request-approvals", "release-history"]
},
{
"name": "icinga2",
"purpose": "Collecter la disponibilité, les incidents, les SLA internes et les tendances.",
"input": {
"apiUrl": "https://icinga.example.tld:5665/v1",
"usernameEnv": "ICINGA_USER",
"passwordEnv": "ICINGA_PASSWORD"
},
"producesEvidence": ["uptime-reports", "incident-history", "alert-summary"]
},
{
"name": "keycloak",
"purpose": "Collecter les rôles administrateurs, MFA et revues d'accès.",
"input": {
"baseUrl": "https://keycloak.example.tld",
"realm": "master",
"tokenEnv": "KEYCLOAK_TOKEN"
},
"producesEvidence": ["admin-role-exports", "mfa-status", "access-review-snapshots"]
}
]
}

76
server/index.js Normal file
View file

@ -0,0 +1,76 @@
import http from "node:http";
import { URL } from "node:url";
import { host, port } from "./config.js";
import { readJsonBody, sendJson, sendText } from "./lib/http.js";
import { listEvidenceArtifacts, loadRegistry, resetRegistry, writeEvidenceArtifact, writeRegistry } from "./lib/store.js";
import { validateRegistry } from "../src/lib/validation.js";
const server = http.createServer(async (req, res) => {
if (!req.url) return sendJson(res, 400, { error: "Missing URL." });
const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
return res.end();
}
try {
if (req.method === "GET" && url.pathname === "/health") {
return sendText(res, 200, "ok");
}
if (req.method === "GET" && url.pathname === "/api/health") {
const registry = await loadRegistry();
const validation = validateRegistry(registry);
return sendJson(res, 200, {
ok: true,
service: "soc2-cockpit-api",
registryValid: validation.isValid,
warningCount: validation.warnings.length,
errorCount: validation.errors.length,
});
}
if (req.method === "GET" && url.pathname === "/api/registry") {
const registry = await loadRegistry();
return sendJson(res, 200, { registry, validation: validateRegistry(registry) });
}
if (req.method === "PUT" && url.pathname === "/api/registry") {
const payload = await readJsonBody(req);
const result = await writeRegistry(payload);
return sendJson(res, 200, result);
}
if (req.method === "POST" && url.pathname === "/api/registry/reset") {
const result = await resetRegistry();
return sendJson(res, 200, result);
}
if (req.method === "GET" && url.pathname === "/api/evidence") {
const artifacts = await listEvidenceArtifacts();
return sendJson(res, 200, { artifacts });
}
if (req.method === "POST" && url.pathname === "/api/evidence") {
const payload = await readJsonBody(req);
const artifact = await writeEvidenceArtifact(payload);
return sendJson(res, 201, { artifact });
}
return sendJson(res, 404, { error: "Not found." });
} catch (error) {
if (error?.validation) {
return sendJson(res, 400, { error: error.message, validation: error.validation });
}
return sendJson(res, 500, { error: error?.message || "Internal server error." });
}
});
server.listen(port, host, () => {
console.log(`SOC 2 cockpit API listening on http://${host}:${port}`);
});

29
server/lib/http.js Normal file
View file

@ -0,0 +1,29 @@
import { Buffer } from "node:buffer";
export function sendJson(res, status, payload) {
const body = JSON.stringify(payload, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end(body);
}
export function sendText(res, status, text) {
res.writeHead(status, {
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(text),
"Access-Control-Allow-Origin": "*",
});
res.end(text);
}
export async function readJsonBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf-8").trim();
return raw ? JSON.parse(raw) : {};
}

78
server/lib/store.js Normal file
View file

@ -0,0 +1,78 @@
import fs from "node:fs/promises";
import path from "node:path";
import { buildRegistryManifests, normalizeDb, validateRegistry } from "../../src/lib/validation.js";
import { INITIAL_DB } from "../../src/model/seed.js";
import { evidenceDir, manifestsDir, registryPath } from "../config.js";
async function ensureDirs() {
await fs.mkdir(manifestsDir, { recursive: true });
await fs.mkdir(evidenceDir, { recursive: true });
}
export async function loadRegistry() {
await ensureDirs();
try {
const raw = await fs.readFile(registryPath, "utf-8");
return normalizeDb(JSON.parse(raw));
} catch {
return normalizeDb(INITIAL_DB);
}
}
export async function writeRegistry(input) {
await ensureDirs();
const normalized = normalizeDb(input);
const validation = validateRegistry(normalized);
if (!validation.isValid) {
const error = new Error("Registry validation failed.");
error.validation = validation;
throw error;
}
await fs.writeFile(registryPath, JSON.stringify(normalized, null, 2), "utf-8");
const manifests = buildRegistryManifests(normalized);
await fs.writeFile(path.join(manifestsDir, "evidence-manifest.json"), JSON.stringify(manifests.evidenceManifest, null, 2), "utf-8");
await fs.writeFile(path.join(manifestsDir, "auditor-pack-index.json"), JSON.stringify(manifests.auditorPackIndex, null, 2), "utf-8");
return { registry: normalized, validation, manifests };
}
export async function resetRegistry() {
return writeRegistry(INITIAL_DB);
}
export async function listEvidenceArtifacts() {
await ensureDirs();
const names = await fs.readdir(evidenceDir).catch(() => []);
const stats = await Promise.all(
names
.filter((name) => !name.startsWith("."))
.map(async (name) => {
const fullPath = path.join(evidenceDir, name);
const stat = await fs.stat(fullPath);
return {
filename: name,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
path: fullPath,
};
})
);
return stats.sort((a, b) => a.filename.localeCompare(b.filename));
}
export async function writeEvidenceArtifact({ filename, contentBase64 }) {
await ensureDirs();
if (!filename || typeof filename !== "string") throw new Error("filename is required.");
if (!contentBase64 || typeof contentBase64 !== "string") throw new Error("contentBase64 is required.");
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const fullPath = path.join(evidenceDir, safeName);
await fs.writeFile(fullPath, Buffer.from(contentBase64, "base64"));
const stat = await fs.stat(fullPath);
return {
filename: safeName,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
path: fullPath,
};
}

View file

@ -0,0 +1,4 @@
import { resetRegistry } from "../lib/store.js";
const payload = await resetRegistry();
console.log(`Registry seeded. Evidence items: ${payload.registry.evidence.length}`);

View file

@ -96,7 +96,7 @@ function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPilla
export default function App() {
const registry = useSoc2Registry();
const { db, lastSavedAt, importError, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
const [activeTab, setActiveTab] = useState("dashboard");
const [selectedPillar, setSelectedPillar] = useState("security");
@ -170,7 +170,7 @@ export default function App() {
{blocker ? <Tag tone="is-bad">Blocage: Security &lt; 60</Tag> : null}
</div>
<h1 className="hero-title">Assistant web de conformité SOC 2 orienté preuve, remédiation et exposition auditeur</h1>
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
<div className="row wrap gap-8 mt-16">
<button className="btn btn-primary"><Eye size={16} /> Ouvrir la salle auditeur</button>
<button className="btn btn-secondary" onClick={handleCreateEvidence}><Plus size={16} /> Nouvelle preuve</button>
@ -181,7 +181,7 @@ export default function App() {
<ScorePill label="Conformité pondérée" value={`${globalScore}%`} sublabel="Score global des 5 piliers" />
<ScorePill label="Preuves auditables" value={`${readinessRate}%`} sublabel={`${readyEvidenceCount}/${db.evidence.length} exposables aux auditeurs`} />
<ScorePill label="Fenêtre Type II" value={`${auditWindowDays} j`} sublabel={`${db.appContext.observationWindow.start}${db.appContext.observationWindow.end}`} />
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel="Persistance locale automatique" />
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel={`Mode ${syncMode} • état ${syncStatus}`} />
</div>
</div>
</motion.div>

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { INITIAL_DB } from "../../model/seed.js";
import { STORAGE_KEY, TODAY } from "../../model/schema.js";
import { clone } from "../../lib/utils.js";
import { normalizeDb } from "../../lib/validation.js";
import { fetchRegistryFromApi, getSyncMode, resetRegistryOnApi, saveRegistryToApi } from "../../lib/apiClient.js";
function loadDb() {
if (typeof window === "undefined") return clone(INITIAL_DB);
@ -16,15 +17,58 @@ function loadDb() {
}
export function useSoc2Registry() {
const syncMode = getSyncMode();
const [db, setDb] = useState(() => loadDb());
const [lastSavedAt, setLastSavedAt] = useState(null);
const [importError, setImportError] = useState("");
const [syncStatus, setSyncStatus] = useState(syncMode === "api" ? "connecting" : "local");
const skipFirstApiSave = useRef(true);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
setLastSavedAt(new Date().toLocaleString("fr-CA"));
}, [db]);
if (syncMode !== "api") return;
let cancelled = false;
fetchRegistryFromApi()
.then((remote) => {
if (cancelled) return;
setDb(normalizeDb(remote));
setSyncStatus("connected");
setLastSavedAt(new Date().toLocaleString("fr-CA"));
})
.catch(() => {
if (cancelled) return;
setSyncStatus("degraded");
});
return () => {
cancelled = true;
};
}, [syncMode]);
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
}
if (syncMode !== "api") {
setLastSavedAt(new Date().toLocaleString("fr-CA"));
return;
}
if (skipFirstApiSave.current) {
skipFirstApiSave.current = false;
return;
}
const timer = window.setTimeout(() => {
saveRegistryToApi(db)
.then(() => {
setSyncStatus("connected");
setLastSavedAt(new Date().toLocaleString("fr-CA"));
})
.catch(() => setSyncStatus("degraded"));
}, 500);
return () => window.clearTimeout(timer);
}, [db, syncMode]);
const updateMilestoneStage = (pillarId, milestoneId, nextStage) => {
setDb((current) => ({
@ -171,6 +215,15 @@ export function useSoc2Registry() {
};
const resetDemo = () => {
if (syncMode === "api") {
resetRegistryOnApi()
.then((payload) => {
setDb(normalizeDb(payload.registry));
setSyncStatus("connected");
})
.catch(() => setSyncStatus("degraded"));
return;
}
setDb(clone(INITIAL_DB));
};
@ -178,6 +231,8 @@ export function useSoc2Registry() {
db,
lastSavedAt,
importError,
syncMode,
syncStatus,
updateMilestoneStage,
updateEvidence,
toggleEvidenceMilestoneLink,

52
src/lib/apiClient.js Normal file
View file

@ -0,0 +1,52 @@
const apiBase = (import.meta.env.VITE_SOC2_API_BASE_URL || "http://localhost:4177/api").replace(/\/$/, "");
async function readJson(response) {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
return payload;
}
export function getSyncMode() {
return import.meta.env.VITE_SOC2_SYNC_MODE === "api" ? "api" : "local";
}
export async function fetchRegistryFromApi() {
const response = await fetch(`${apiBase}/registry`);
const payload = await readJson(response);
return payload.registry;
}
export async function saveRegistryToApi(registry) {
const response = await fetch(`${apiBase}/registry`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(registry),
});
return readJson(response);
}
export async function fetchServerStatus() {
const response = await fetch(`${apiBase}/health`);
return readJson(response);
}
export async function resetRegistryOnApi() {
const response = await fetch(`${apiBase}/registry/reset`, { method: "POST" });
return readJson(response);
}
export async function listEvidenceArtifacts() {
const response = await fetch(`${apiBase}/evidence`);
return readJson(response);
}
export async function writeEvidenceArtifact({ filename, contentBase64, mimeType = "application/octet-stream" }) {
const response = await fetch(`${apiBase}/evidence`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, contentBase64, mimeType }),
});
return readJson(response);
}