From cd63658659b02f99c953b927fcda9d3ef08dbd59 Mon Sep 17 00:00:00 2001 From: Dan G Date: Thu, 30 Jul 2026 20:27:51 +0200 Subject: [PATCH] feat: add SSO exception sync and KMS envelopes --- .env.example | 18 ++ .gitignore | 1 + README.md | 59 +++- app/api/exception-sync/route.ts | 443 ++++++++++++++++++++++++++++++ app/globals.css | 139 ++++++++++ app/page.tsx | 168 ++++++++++- app/trustmap-enterprise.tsx | 85 +++++- db/index.ts | 42 +++ db/schema.ts | 37 +++ drizzle/0001_slim_speed_demon.sql | 21 ++ drizzle/meta/0001_snapshot.json | 250 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/enterprise-sync.ts | 251 +++++++++++++++++ lib/key-management.ts | 390 ++++++++++++++++++++++++++ package.json | 2 +- public/og.png | Bin 1336638 -> 1313119 bytes tests/enterprise-sync.test.ts | 161 +++++++++++ tests/rendered-html.test.mjs | 127 +++++++++ 18 files changed, 2191 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100644 app/api/exception-sync/route.ts create mode 100644 drizzle/0001_slim_speed_demon.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 lib/enterprise-sync.ts create mode 100644 lib/key-management.ts create mode 100644 tests/enterprise-sync.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..929c908 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# One deployed private Site corresponds to one shared TrustMap workspace. +TRUSTMAP_WORKSPACE_ID=mcp-trustmap-primary + +# Current envelope-key version. Rotate by changing the id and the key together. +TRUSTMAP_KMS_KEY_ID=sites-secret:v1 + +# Local/private deployment mode: 32 random bytes encoded as base64url. +# Store the real value only as a Sites secret, never in this file. +TRUSTMAP_KMS_MASTER_KEY= + +# Optional JSON map of at most ten previous platform keys needed during rotation. +# Example shape only: {"sites-secret:v0":""} +TRUSTMAP_KMS_PREVIOUS_KEYS= + +# External KMS gateway mode. When the endpoint is set, key id and bearer token +# are all required. The gateway receives wrap/unwrap operations over HTTPS. +TRUSTMAP_KMS_ENDPOINT= +TRUSTMAP_KMS_BEARER_TOKEN= diff --git a/.gitignore b/.gitignore index fdc31d4..c4949f3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index 22e2eb8..c511bde 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,16 @@ app/ page.tsx Interface et orchestration des audits globals.css Système visuel responsive layout.tsx Métadonnées et partage social + api/exception-sync/route.ts Synchronisation SSO et journal d’écriture +db/ + schema.ts Historique et enveloppes d’exceptions dans D1 lib/ audit-engine.ts Règles, scoring et exports JSON/SARIF audit-history.ts Agrégation confidentielle et comparaison des audits finding-exceptions.ts Registre local et exports des risques acceptés trustmap-governance.ts Signature des politiques et bundles d’exceptions chiffrés + enterprise-sync.ts Chiffrement par clé de données et pseudonymisation + key-management.ts Enveloppe de clés Sites ou passerelle KMS HTTPS collector.ts Découverte, redaction et probe MCP passif lockfiles.ts Graphes package-lock, pnpm, Yarn, uv et Poetry kubernetes-admission.ts Génération sûre des politiques d’admission @@ -612,6 +617,7 @@ tests/ pdf-report.test.ts Tests du document PDF et de sa pagination provenance.test.ts Tests ECDSA, digest SLSA et politique Sigstore supply-chain.test.ts Tests npm, PyPI, OCI, PURL et CycloneDX + enterprise-sync.test.ts Tests de chiffrement par enveloppe et rotation trustmap-governance.test.ts Tests des signatures et échanges chiffrés workspaces.test.ts Tests de découverte et d’isolation des monorepos rendered-html.test.mjs Tests du rendu de production @@ -754,6 +760,45 @@ fusion. Les décisions sont rapprochées par identifiant et une révocation gagn toujours sur une version active, ce qui évite de réactiver un risque déjà refusé. Aucune phrase secrète n’est persistée par l’application. +### Synchronisation automatique avec SSO et KMS + +Dans **TrustMap Enterprise → Espace partagé**, le navigateur se connecte à +`/api/exception-sync`. L’API accepte uniquement une identité transmise par la +plateforme dans l’en-tête authentifié, ou l’identité spéciale de l’aperçu local. +Chaque écriture est attribuée à une empreinte SHA-256 pseudonymisée ; l’adresse +e-mail n’est pas inscrite dans D1. + +Un site privé représente un espace de confiance. Les membres explicitement +autorisés par la politique d’accès Sites partagent le même registre. La +synchronisation : + +- fusionne les décisions sans supprimer celles absentes d’un appareil ; +- donne toujours priorité à une révocation ; +- utilise une mise à jour optimiste versionnée pour éviter les écrasements + concurrents ; +- conserve les 500 événements d’écriture les plus récents sans nom de serveur, + règle, motif ou autre contenu métier en clair. + +Chaque exception est chiffrée avec une clé de données AES-256-GCM indépendante. +La clé de données est ensuite enveloppée par le fournisseur de clés configuré. +D1 ne reçoit que l’enveloppe, une clé d’enregistrement pseudonymisée, la version, +la date et l’empreinte de l’acteur. + +Deux fournisseurs sont disponibles : + +1. **secret de plateforme**, utilisé par le site privé actuel : une clé + d’enveloppe de 32 octets est stockée comme secret Sites ; +2. **passerelle KMS externe**, qui reçoit des opérations `wrap` et `unwrap` sur + HTTPS avec un identifiant de clé et un jeton conservé comme secret. + +La rotation change simultanément `TRUSTMAP_KMS_KEY_ID` et la clé courante. Le +secret JSON `TRUSTMAP_KMS_PREVIOUS_KEYS` peut contenir au maximum dix anciennes +versions le temps de relire les enregistrements existants. Chaque +synchronisation réenveloppe automatiquement une décision encore protégée par +une ancienne version. Les +variables attendues et leurs formes sont documentées dans `.env.example` sans +aucune valeur secrète. + ## Limites actuelles - la découverte doit être lancée explicitement sur chaque poste à inventorier ; @@ -779,9 +824,10 @@ refusé. Aucune phrase secrète n’est persistée par l’application. OAuth ou système de fichiers ; - l’historique est limité à 60 synthèses agrégées et ne permet pas de rouvrir l’inventaire complet d’un audit précédent ; -- le registre d’exceptions reste local au navigateur ; sa synchronisation - s’effectue volontairement par échange de bundles chiffrés et n’est pas encore - automatique ; +- la politique d’accès Sites définit les membres de l’espace ; l’application ne + fournit pas encore d’interface d’invitation ou de gestion des rôles ; +- la passerelle KMS externe suit le contrat HTTPS MCP TrustMap et nécessite un + adaptateur devant AWS KMS, Azure Key Vault, Google Cloud KMS ou un HSM ; - l’empreinte d’une identité de signature doit être validée par un canal distinct ; un fichier auto-signé ne constitue pas à lui seul une identité de confiance ; @@ -789,9 +835,10 @@ refusé. Aucune phrase secrète n’est persistée par l’application. ## Prochaines étapes possibles -- synchronisation automatique des exceptions avec SSO, journal d’accès et - révocation centralisée ; -- clés de signature matérielles ou gérées par un KMS d’entreprise. +- rôles distincts lecteur, auditeur et administrateur avec approbation à deux + personnes pour les exceptions critiques ; +- adaptateurs KMS natifs et migration automatique lors de la rotation ; +- clés de signature matérielles WebAuthn ou gérées par un HSM d’entreprise. ## Contribution diff --git a/app/api/exception-sync/route.ts b/app/api/exception-sync/route.ts new file mode 100644 index 0000000..5cc67f0 --- /dev/null +++ b/app/api/exception-sync/route.ts @@ -0,0 +1,443 @@ +import { env } from "cloudflare:workers"; +import { ensureExceptionSyncSchema, getD1 } from "../../../db"; +import { + createExceptionRecordKey, + createExceptionSpaceId, + decryptSyncedRiskException, + encryptSyncedRiskException, + parseExceptionEnvelope, + serializeExceptionEnvelope, +} from "../../../lib/enterprise-sync"; +import { + parseRiskExceptions, + type RiskException, +} from "../../../lib/finding-exceptions"; +import { + createKeyManagementProvider, + KeyManagementConfigurationError, +} from "../../../lib/key-management"; +import { mergeRiskExceptions } from "../../../lib/trustmap-governance"; + +export const dynamic = "force-dynamic"; + +const AUTHENTICATED_EMAIL_HEADER = "oai-authenticated-user-email"; +const AUTHENTICATED_NAME_HEADER = "oai-authenticated-user-full-name"; +const AUTHENTICATED_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const MAX_REQUEST_BYTES = 1_500_000; +const MAX_SYNCED_EXCEPTIONS = 1_000; +const runtime = env as unknown as Record; + +type StoredExceptionRow = { + record_key: string; + envelope: string; + actor_hash: string; + updated_at: number; + version: number; +}; + +class SyncValidationError extends Error {} + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { + "Cache-Control": "no-store", + }, + }); +} + +function isLocalRequest(request: Request): boolean { + return ["localhost", "127.0.0.1", "::1"].includes( + new URL(request.url).hostname, + ); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function decodeDisplayName(request: Request): string | null { + const encoded = request.headers.get(AUTHENTICATED_NAME_HEADER); + if ( + !encoded || + request.headers.get(AUTHENTICATED_NAME_ENCODING_HEADER) !== + "percent-encoded-utf-8" + ) { + return null; + } + try { + return decodeURIComponent(encoded).slice(0, 120); + } catch { + return null; + } +} + +async function authenticatedActor(request: Request): Promise<{ + actorHash: string; + displayName: string; +} | null> { + const email = request.headers + .get(AUTHENTICATED_EMAIL_HEADER) + ?.trim() + .toLowerCase(); + const identity = email || (isLocalRequest(request) ? "local-preview" : ""); + if (!identity) return null; + return { + actorHash: await sha256(`mcp-trustmap:exception-sync:actor:${identity}`), + displayName: + decodeDisplayName(request) ?? + (email ? email.slice(0, 160) : "Aperçu local"), + }; +} + +async function workspaceId(): Promise { + const configured = runtime.TRUSTMAP_WORKSPACE_ID; + return createExceptionSpaceId( + typeof configured === "string" && configured.trim() + ? configured + : "primary-private-site", + ); +} + +async function readExceptions(request: Request): Promise { + const contentType = request.headers.get("Content-Type") ?? ""; + const contentLength = Number(request.headers.get("Content-Length") ?? "0"); + if ( + !contentType.toLowerCase().startsWith("application/json") || + !Number.isFinite(contentLength) || + contentLength > MAX_REQUEST_BYTES + ) { + throw new SyncValidationError("invalid-request"); + } + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) { + throw new SyncValidationError("invalid-request"); + } + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch { + throw new SyncValidationError("invalid-json"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new SyncValidationError("invalid-payload"); + } + const candidates = (value as Record).exceptions; + if ( + !Array.isArray(candidates) || + candidates.length > MAX_SYNCED_EXCEPTIONS + ) { + throw new SyncValidationError("invalid-exceptions"); + } + const parsed = parseRiskExceptions(JSON.stringify(candidates)); + if (parsed.length !== candidates.length) { + throw new SyncValidationError("invalid-exceptions"); + } + return parsed; +} + +async function readStoredRows(spaceId: string): Promise { + const result = await getD1() + .prepare( + `SELECT record_key, envelope, actor_hash, updated_at, version + FROM exception_sync_records + WHERE space_id = ? + ORDER BY updated_at DESC + LIMIT ?`, + ) + .bind(spaceId, MAX_SYNCED_EXCEPTIONS + 1) + .all(); + if (result.results.length > MAX_SYNCED_EXCEPTIONS) { + throw new Error("Le registre partagé dépasse la limite autorisée."); + } + return result.results; +} + +async function decryptRow( + row: StoredExceptionRow, + spaceId: string, +): Promise { + const envelope = parseExceptionEnvelope(row.envelope); + const provider = createKeyManagementProvider( + runtime, + envelope.provider, + envelope.keyId, + ); + return decryptSyncedRiskException( + envelope, + provider, + spaceId, + row.record_key, + ); +} + +async function listSharedExceptions( + spaceId: string, +): Promise<{ + exceptions: RiskException[]; + lastSyncedAt: string | null; +}> { + const rows = await readStoredRows(spaceId); + const exceptions = await Promise.all( + rows.map((row) => decryptRow(row, spaceId)), + ); + return { + exceptions, + lastSyncedAt: rows.length + ? new Date(Math.max(...rows.map((row) => row.updated_at))).toISOString() + : null, + }; +} + +function sameException( + left: RiskException, + right: RiskException, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function appendAuditEvent( + spaceId: string, + recordKey: string, + actorHash: string, + action: "upserted" | "revoked" | "rekeyed", + version: number, + createdAt: number, +) { + await getD1() + .prepare( + `INSERT INTO exception_sync_events + (id, space_id, record_key, actor_hash, action, created_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + crypto.randomUUID(), + spaceId, + recordKey, + actorHash, + action, + createdAt, + version, + ) + .run(); +} + +async function upsertException( + spaceId: string, + actorHash: string, + incoming: RiskException, +): Promise { + const recordKey = await createExceptionRecordKey(spaceId, incoming.id); + for (let attempt = 0; attempt < 4; attempt += 1) { + const existingRow = await getD1() + .prepare( + `SELECT record_key, envelope, actor_hash, updated_at, version + FROM exception_sync_records + WHERE record_key = ? AND space_id = ?`, + ) + .bind(recordKey, spaceId) + .first(); + const existing = existingRow + ? await decryptRow(existingRow, spaceId) + : undefined; + const merged = existing + ? mergeRiskExceptions([existing], [incoming])[0] + : incoming; + const provider = createKeyManagementProvider(runtime); + const existingEnvelope = existingRow + ? parseExceptionEnvelope(existingRow.envelope) + : undefined; + const requiresRekey = + Boolean(existingEnvelope) && + (existingEnvelope?.provider !== provider.provider || + existingEnvelope.keyId !== provider.keyId); + if (existing && sameException(existing, merged) && !requiresRekey) { + return false; + } + + const envelope = await encryptSyncedRiskException( + merged, + provider, + spaceId, + recordKey, + ); + const updatedAt = Date.now(); + const version = (existingRow?.version ?? 0) + 1; + const result = existingRow + ? await getD1() + .prepare( + `UPDATE exception_sync_records + SET envelope = ?, actor_hash = ?, updated_at = ?, version = ? + WHERE record_key = ? AND space_id = ? AND version = ?`, + ) + .bind( + serializeExceptionEnvelope(envelope), + actorHash, + updatedAt, + version, + recordKey, + spaceId, + existingRow.version, + ) + .run() + : await getD1() + .prepare( + `INSERT OR IGNORE INTO exception_sync_records + (record_key, space_id, envelope, actor_hash, updated_at, version) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind( + recordKey, + spaceId, + serializeExceptionEnvelope(envelope), + actorHash, + updatedAt, + version, + ) + .run(); + if ((result.meta.changes ?? 0) === 1) { + await appendAuditEvent( + spaceId, + recordKey, + actorHash, + requiresRekey + ? "rekeyed" + : merged.revokedAt + ? "revoked" + : "upserted", + version, + updatedAt, + ); + return true; + } + } + throw new Error("Conflit de synchronisation persistant."); +} + +async function trimAuditEvents(spaceId: string) { + await getD1() + .prepare( + `DELETE FROM exception_sync_events + WHERE space_id = ? + AND id NOT IN ( + SELECT id FROM exception_sync_events + WHERE space_id = ? + ORDER BY created_at DESC + LIMIT 500 + )`, + ) + .bind(spaceId, spaceId) + .run(); +} + +async function syncMetadata( + request: Request, + actor: { actorHash: string; displayName: string }, + spaceId: string, +) { + const provider = createKeyManagementProvider(runtime); + const event = await getD1() + .prepare( + `SELECT created_at + FROM exception_sync_events + WHERE space_id = ? + ORDER BY created_at DESC + LIMIT 1`, + ) + .bind(spaceId) + .first<{ created_at: number }>(); + return { + authenticated: true, + identity: actor.displayName, + actorRef: actor.actorHash.slice(0, 12), + workspaceRef: spaceId.slice(-12), + kms: provider.status(), + lastActivityAt: event?.created_at + ? new Date(event.created_at).toISOString() + : null, + localPreview: isLocalRequest(request), + }; +} + +export async function GET(request: Request) { + try { + const actor = await authenticatedActor(request); + if (!actor) { + return responseJson({ error: "Authentification SSO requise." }, 401); + } + await ensureExceptionSyncSchema(); + const spaceId = await workspaceId(); + const shared = await listSharedExceptions(spaceId); + return responseJson({ + ...shared, + sync: await syncMetadata(request, actor, spaceId), + }); + } catch (error) { + const configuration = error instanceof KeyManagementConfigurationError; + return responseJson( + { + error: configuration + ? "Le fournisseur de clés n’est pas configuré." + : "Le registre partagé n’a pas pu être chargé.", + }, + configuration ? 503 : 500, + ); + } +} + +export async function PUT(request: Request) { + try { + if (!sameOrigin(request)) { + return responseJson({ error: "Origine de requête refusée." }, 403); + } + const actor = await authenticatedActor(request); + if (!actor) { + return responseJson({ error: "Authentification SSO requise." }, 401); + } + const incoming = await readExceptions(request); + await ensureExceptionSyncSchema(); + const spaceId = await workspaceId(); + let changed = 0; + for (const exception of incoming) { + if (await upsertException(spaceId, actor.actorHash, exception)) changed += 1; + } + await trimAuditEvents(spaceId); + const shared = await listSharedExceptions(spaceId); + return responseJson({ + ...shared, + changed, + sync: await syncMetadata(request, actor, spaceId), + }); + } catch (error) { + const invalid = + error instanceof SyncValidationError || error instanceof SyntaxError; + const configuration = error instanceof KeyManagementConfigurationError; + return responseJson( + { + error: invalid + ? "Le registre d’exceptions envoyé est invalide." + : configuration + ? "Le fournisseur de clés n’est pas configuré." + : "La synchronisation chiffrée a échoué.", + }, + invalid ? 400 : configuration ? 503 : 500, + ); + } +} diff --git a/app/globals.css b/app/globals.css index aa29563..907f1bf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1830,10 +1830,132 @@ button.table-row:hover { } .policy-signature-card, +.automatic-sync-card, .exception-sync-card { padding: 25px; } +.automatic-sync-card { + border-color: #cdd8f6; + box-shadow: 0 14px 34px rgba(44, 78, 166, 0.08); +} + +.sync-state { + align-items: center; + background: var(--paper-soft); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + flex: 0 0 auto; + font-size: 8px; + font-weight: 750; + gap: 7px; + padding: 7px 10px; +} + +.sync-state i { + background: var(--subtle); + border-radius: 50%; + height: 7px; + width: 7px; +} + +.sync-state.synced { + background: var(--green-soft); + color: var(--green-dark); +} + +.sync-state.synced i { + background: var(--green); + box-shadow: 0 0 0 3px rgba(21, 122, 85, 0.1); +} + +.sync-state.connecting, +.sync-state.syncing { + background: #eff3ff; + color: #1d3f9f; +} + +.sync-state.connecting i, +.sync-state.syncing i { + animation: sync-pulse 1.2s ease-in-out infinite; + background: var(--cobalt); +} + +.sync-state.error, +.sync-state.unavailable { + background: var(--coral-soft); + color: var(--coral); +} + +.sync-state.error i, +.sync-state.unavailable i { + background: var(--coral); +} + +@keyframes sync-pulse { + 50% { + opacity: 0.35; + transform: scale(0.8); + } +} + +.sync-facts { + border: 1px solid var(--line); + border-radius: 12px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; +} + +.sync-facts > div { + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 5px; + min-height: 86px; + padding: 16px; +} + +.sync-facts > div:last-child { + border-right: 0; +} + +.sync-facts span { + color: var(--subtle); + font-size: 8px; + font-weight: 680; + text-transform: uppercase; +} + +.sync-facts strong { + font-size: 10px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.sync-facts small { + color: var(--muted); + font-family: var(--font-geist-mono), monospace; + font-size: 7px; + overflow-wrap: anywhere; +} + +.automatic-sync-footer { + align-items: center; + display: flex; + gap: 18px; + justify-content: space-between; + margin-top: 15px; +} + +.automatic-sync-footer p { + color: var(--muted); + font-size: 9px; + line-height: 1.5; + margin: 0; +} + .crypto-badge { background: var(--green-soft); border: 1px solid #c6e5d6; @@ -3708,6 +3830,18 @@ button.table-row:hover { grid-template-columns: 1fr; } + .sync-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sync-facts > div:nth-child(2) { + border-right: 0; + } + + .sync-facts > div:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + .exception-sync-grid .crypto-actions { flex-wrap: wrap; } @@ -3902,6 +4036,11 @@ button.table-row:hover { grid-template-columns: 1fr auto; } + .automatic-sync-footer { + align-items: stretch; + flex-direction: column; + } + .fingerprint code { grid-column: 1 / -1; grid-row: 2; diff --git a/app/page.tsx b/app/page.tsx index 4c9ec68..ac7fc7c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -45,7 +45,11 @@ import { import { AuditHistoryView } from "./audit-history-view"; import { TrustMapDiscover } from "./trustmap-discover"; import { TrustMapCi } from "./trustmap-ci"; -import { TrustMapEnterprise } from "./trustmap-enterprise"; +import { + TrustMapEnterprise, + type SharedExceptionSyncState, +} from "./trustmap-enterprise"; +import { mergeRiskExceptions } from "../lib/trustmap-governance"; type View = "discover" | "audit" | "ci" | "enterprise"; type AuditView = "overview" | "servers" | "rules" | "history"; @@ -419,6 +423,13 @@ export default function Home() { const [auditHistory, setAuditHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(true); const [historyError, setHistoryError] = useState(""); + const [exceptionSyncReady, setExceptionSyncReady] = useState(false); + const [exceptionSyncRequest, setExceptionSyncRequest] = useState(0); + const [sharedExceptionSync, setSharedExceptionSync] = + useState({ + phase: "connecting", + message: "Connexion à l’espace chiffré…", + }); useEffect(() => { const loadTimer = window.setTimeout(() => { @@ -471,6 +482,157 @@ export default function Home() { } }, [exceptionsLoaded, riskExceptions]); + useEffect(() => { + if (!exceptionsLoaded) return; + const controller = new AbortController(); + const connectSharedRegister = async () => { + setExceptionSyncReady(false); + setSharedExceptionSync((current) => ({ + ...current, + phase: "connecting", + message: "Connexion SSO à l’espace chiffré…", + })); + try { + const response = await fetch("/api/exception-sync", { + cache: "no-store", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + const body = (await response.json()) as { + error?: string; + exceptions?: unknown; + lastSyncedAt?: string | null; + sync?: { + identity?: string; + workspaceRef?: string; + kms?: { label?: string; keyId?: string }; + }; + }; + if (!response.ok) { + throw new Error( + body.error || "L’espace partagé n’est pas disponible.", + ); + } + const candidates = Array.isArray(body.exceptions) + ? body.exceptions + : []; + const remote = parseRiskExceptions(JSON.stringify(candidates)); + if (remote.length !== candidates.length) { + throw new Error("Le registre partagé reçu est invalide."); + } + setRiskExceptions((current) => { + const merged = mergeRiskExceptions(current, remote); + return JSON.stringify(merged) === JSON.stringify(current) + ? current + : merged; + }); + setSharedExceptionSync({ + phase: "synced", + message: "Session SSO vérifiée. Le registre local est à jour.", + identity: body.sync?.identity, + workspaceRef: body.sync?.workspaceRef, + kmsLabel: body.sync?.kms?.label, + kmsKeyId: body.sync?.kms?.keyId, + lastSyncedAt: body.lastSyncedAt, + }); + setExceptionSyncReady(true); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + setSharedExceptionSync((current) => ({ + ...current, + phase: "unavailable", + message: + error instanceof Error + ? error.message + : "L’espace partagé n’est pas disponible.", + })); + } + }; + void connectSharedRegister(); + return () => controller.abort(); + }, [exceptionSyncRequest, exceptionsLoaded]); + + useEffect(() => { + if (!exceptionSyncReady) return; + const controller = new AbortController(); + const timer = window.setTimeout(() => { + const synchronize = async () => { + setSharedExceptionSync((current) => ({ + ...current, + phase: "syncing", + message: "Chiffrement et synchronisation des décisions…", + })); + try { + const response = await fetch("/api/exception-sync", { + method: "PUT", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ exceptions: riskExceptions }), + signal: controller.signal, + }); + const body = (await response.json()) as { + error?: string; + exceptions?: unknown; + lastSyncedAt?: string | null; + sync?: { + identity?: string; + workspaceRef?: string; + kms?: { label?: string; keyId?: string }; + }; + }; + if (!response.ok) { + throw new Error( + body.error || "La synchronisation automatique a échoué.", + ); + } + const candidates = Array.isArray(body.exceptions) + ? body.exceptions + : []; + const remote = parseRiskExceptions(JSON.stringify(candidates)); + if (remote.length !== candidates.length) { + throw new Error("Le registre partagé reçu est invalide."); + } + setRiskExceptions((current) => { + const merged = mergeRiskExceptions(current, remote); + return JSON.stringify(merged) === JSON.stringify(current) + ? current + : merged; + }); + setSharedExceptionSync({ + phase: "synced", + message: "Toutes les décisions sont chiffrées et synchronisées.", + identity: body.sync?.identity, + workspaceRef: body.sync?.workspaceRef, + kmsLabel: body.sync?.kms?.label, + kmsKeyId: body.sync?.kms?.keyId, + lastSyncedAt: body.lastSyncedAt ?? new Date().toISOString(), + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + setSharedExceptionSync((current) => ({ + ...current, + phase: "error", + message: + error instanceof Error + ? error.message + : "La synchronisation automatique a échoué.", + })); + } + }; + void synchronize(); + }, 800); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [exceptionSyncReady, riskExceptions]); + useEffect(() => { const controller = new AbortController(); const loadHistory = async () => { @@ -1336,6 +1498,10 @@ export default function Home() { exceptions={riskExceptions} onNotify={notify} onExceptionsImported={setRiskExceptions} + sharedSync={sharedExceptionSync} + onSyncNow={() => { + setExceptionSyncRequest((current) => current + 1); + }} /> )} diff --git a/app/trustmap-enterprise.tsx b/app/trustmap-enterprise.tsx index 7dfd34f..a5e3d62 100644 --- a/app/trustmap-enterprise.tsx +++ b/app/trustmap-enterprise.tsx @@ -13,16 +13,30 @@ import { mergeRiskExceptions, } from "../lib/trustmap-governance"; +export type SharedExceptionSyncState = { + phase: "connecting" | "syncing" | "synced" | "unavailable" | "error"; + message: string; + identity?: string; + workspaceRef?: string; + kmsLabel?: string; + kmsKeyId?: string; + lastSyncedAt?: string | null; +}; + export function TrustMapEnterprise({ servers, exceptions, onNotify, onExceptionsImported, + sharedSync, + onSyncNow, }: { servers: McpServer[]; exceptions: RiskException[]; onNotify: (message: string) => void; onExceptionsImported: (exceptions: RiskException[]) => void; + sharedSync: SharedExceptionSyncState; + onSyncNow: () => void; }) { const [passphrase, setPassphrase] = useState(""); const [encryptedBundle, setEncryptedBundle] = useState(""); @@ -203,6 +217,72 @@ export function TrustMapEnterprise({ +
+
+
+ ESPACE PARTAGÉ · SSO +

Synchronisation automatique des décisions

+
+ +