From 35fe09592c9292592894ec2a807543434138652a Mon Sep 17 00:00:00 2001 From: Dan G Date: Fri, 31 Jul 2026 04:56:19 +0200 Subject: [PATCH] feat: enforce enterprise roles and critical approvals --- .env.example | 5 + README.md | 50 ++++-- app/api/exception-sync/route.ts | 213 ++++++++++++++++++++-- app/globals.css | 113 +++++++++++- app/page.tsx | 126 ++++++++++++- app/trustmap-enterprise.tsx | 140 ++++++++++++++- lib/enterprise-authorization.ts | 234 +++++++++++++++++++++++++ lib/finding-exceptions.ts | 118 ++++++++++++- lib/trustmap-governance.ts | 17 ++ package.json | 2 +- public/og.png | Bin 1313119 -> 1339431 bytes tests/enterprise-authorization.test.ts | 168 ++++++++++++++++++ tests/finding-exceptions.test.ts | 30 +++- tests/rendered-html.test.mjs | 106 ++++++++--- 14 files changed, 1247 insertions(+), 75 deletions(-) create mode 100644 lib/enterprise-authorization.ts create mode 100644 tests/enterprise-authorization.test.ts diff --git a/.env.example b/.env.example index 929c908..50e749b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # One deployed private Site corresponds to one shared TrustMap workspace. TRUSTMAP_WORKSPACE_ID=mcp-trustmap-primary +# Server-side RBAC map. Keep the real email-to-role map in a Sites secret. +# Unmapped authenticated users are readers. Allowed roles: reader, auditor, admin. +# Example shape only: {"owner@example.test":"admin","audit@example.test":"auditor"} +TRUSTMAP_ROLE_BINDINGS= + # Current envelope-key version. Rotate by changing the id and the key together. TRUSTMAP_KMS_KEY_ID=sites-secret:v1 diff --git a/README.md b/README.md index c511bde..36fa503 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ directement applicables. commande ainsi qu’un workflow GitHub Actions multi-environnements, avec chemins, seuils, SARIF, CycloneDX, OSV et provenance configurables par profil. - **TrustMap Enterprise** mesure la couverture des propriétaires et des preuves, - présente la posture par équipe et exporte un pack de gouvernance JSON. + présente la posture par équipe, synchronise le registre chiffré avec SSO, + applique les rôles et pilote les approbations critiques. Les vues Enterprise reflètent uniquement les données réellement chargées. -L’interface ne simule pas de SSO, de synchronisation multi-utilisateurs ni de -connexion à un annuaire d’entreprise. +L’identité SSO et la politique d’accès sont fournies par le site privé ; les +autorisations sont recalculées côté serveur à chaque requête. > Le moteur actuel réalise une **analyse statique locale** des configurations. > Il ne remplace pas un test d’intrusion, une revue des permissions réellement @@ -79,7 +80,8 @@ par un collecteur local explicite : - aucune configuration importée n’est envoyée à un service distant ; - les valeurs sensibles détectées ne sont jamais affichées ; - aucun secret n’est enregistré dans le stockage du navigateur ; -- le registre d’exceptions reste sur l’appareil dans le stockage du navigateur ; +- une copie locale du registre d’exceptions reste dans le navigateur et le + registre partagé stocke uniquement des enveloppes chiffrées dans D1 ; - l’historique distant conserve uniquement des compteurs agrégés par règle, associés à un identifiant utilisateur pseudonymisé ; - aucun nom de serveur, chemin, configuration, extrait de correction ou secret @@ -154,14 +156,18 @@ sous exception depuis le détail de l’écart. MCP TrustMap exige : - un responsable identifié ; - une date d’expiration future, limitée à 366 jours. +Une exception **critique** reste en attente et ne masque jamais le constat avant +deux approbations attribuées à deux auditeurs distincts du demandeur. Une même +identité ne peut pas voter deux fois ; le demandeur ne peut pas s’auto-approuver +et seul un administrateur peut rejeter la demande. + Une exception active retire temporairement l’écart des remédiations prioritaires mais ne réduit pas le score brut : le risque reste visible. À l’échéance ou après révocation, l’écart redevient automatiquement prioritaire. -Le registre est conservé uniquement dans le navigateur courant. Le rapport JSON -1.1 inclut les exceptions actives, expirées et révoquées. L’export SARIF conserve -le résultat et ajoute une suppression `external/accepted` documentée pour les -seules exceptions actives. +Le rapport JSON 1.1 inclut les exceptions actives, en attente, rejetées, +expirées et révoquées. L’export SARIF conserve le résultat et ajoute une +suppression `external/accepted` documentée pour les seules exceptions actives. ## Historique des audits @@ -589,6 +595,7 @@ lib/ 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 + enterprise-authorization.ts Rôles et double approbation côté serveur 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 @@ -607,6 +614,7 @@ tools/ collector.ts Interface en ligne de commande multiplateforme tests/ audit-engine.test.ts Tests de sécurité du moteur + enterprise-authorization.test.ts Tests des rôles et approbations critiques finding-exceptions.test.ts Tests d’expiration, révocation et exports collector.test.ts Tests du collecteur et du protocole passif kubernetes-admission.test.ts Tests YAML, identités et préfixes Kubernetes @@ -779,6 +787,24 @@ synchronisation : - 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. +### Rôles Enterprise et approbation critique + +`TRUSTMAP_ROLE_BINDINGS` est un secret JSON qui associe les adresses SSO aux +rôles. Toute identité absente de cette table reçoit le rôle `reader`. + +| Rôle | Lire | Synchroniser/révoquer | Approuver | Rejeter | +| --- | --- | --- | --- | --- | +| `reader` | oui | non | non | non | +| `auditor` | oui | oui | oui | non | +| `admin` | oui | oui | oui | oui | + +Pour une nouvelle exception critique, l’API ignore tout état d’approbation +fourni par le navigateur, recalcule une sévérité minimale selon la règle et +crée une demande liée à l’empreinte pseudonymisée du demandeur. Deux appels +`PATCH /api/exception-sync` effectués par des auditeurs différents sont requis. +Les contrôles sont appliqués côté serveur ; les boutons désactivés de +l’interface ne constituent qu’un retour utilisateur. + 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, @@ -825,7 +851,10 @@ aucune valeur secrète. - 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 ; - 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 ; + fournit pas encore d’interface d’invitation et les rôles sont configurés par + le secret `TRUSTMAP_ROLE_BINDINGS` ; +- un espace privé limité à une seule identité ne peut pas achever une double + approbation : deux auditeurs distincts du demandeur doivent être autorisés ; - 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 @@ -835,8 +864,7 @@ aucune valeur secrète. ## Prochaines étapes possibles -- rôles distincts lecteur, auditeur et administrateur avec approbation à deux - personnes pour les exceptions critiques ; +- interface d’administration des membres et rôles reliée à un annuaire ; - 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. diff --git a/app/api/exception-sync/route.ts b/app/api/exception-sync/route.ts index 5cc67f0..dcb6df3 100644 --- a/app/api/exception-sync/route.ts +++ b/app/api/exception-sync/route.ts @@ -8,6 +8,16 @@ import { parseExceptionEnvelope, serializeExceptionEnvelope, } from "../../../lib/enterprise-sync"; +import { + applyRiskExceptionDecision, + EnterpriseAuthorizationError, + normalizeStoredRiskException, + prepareRiskExceptionForSync, + resolveEnterpriseRole, + roleCapabilities, + type EnterpriseActor, + type ExceptionDecision, +} from "../../../lib/enterprise-authorization"; import { parseRiskExceptions, type RiskException, @@ -16,7 +26,6 @@ import { createKeyManagementProvider, KeyManagementConfigurationError, } from "../../../lib/key-management"; -import { mergeRiskExceptions } from "../../../lib/trustmap-governance"; export const dynamic = "force-dynamic"; @@ -89,21 +98,22 @@ function decodeDisplayName(request: Request): string | null { } } -async function authenticatedActor(request: Request): Promise<{ - actorHash: string; - displayName: string; -} | null> { +async function authenticatedActor( + request: Request, +): Promise { const email = request.headers .get(AUTHENTICATED_EMAIL_HEADER) ?.trim() .toLowerCase(); - const identity = email || (isLocalRequest(request) ? "local-preview" : ""); + const localPreview = isLocalRequest(request); + const identity = email || (localPreview ? "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"), + role: resolveEnterpriseRole(email ?? null, runtime, localPreview), }; } @@ -153,6 +163,42 @@ async function readExceptions(request: Request): Promise { return parsed; } +async function readDecision(request: Request): Promise<{ + exceptionId: string; + action: ExceptionDecision; +}> { + const contentType = request.headers.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().startsWith("application/json")) { + throw new SyncValidationError("invalid-request"); + } + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > 2_000) { + 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 record = value as Record; + if ( + typeof record.exceptionId !== "string" || + !record.exceptionId.trim() || + record.exceptionId.length > 120 || + !["approve", "reject"].includes(String(record.action)) + ) { + throw new SyncValidationError("invalid-decision"); + } + return { + exceptionId: record.exceptionId, + action: record.action as ExceptionDecision, + }; +} + async function readStoredRows(spaceId: string): Promise { const result = await getD1() .prepare( @@ -180,12 +226,13 @@ async function decryptRow( envelope.provider, envelope.keyId, ); - return decryptSyncedRiskException( + const decrypted = await decryptSyncedRiskException( envelope, provider, spaceId, row.record_key, ); + return normalizeStoredRiskException(decrypted, row.actor_hash); } async function listSharedExceptions( @@ -217,7 +264,13 @@ async function appendAuditEvent( spaceId: string, recordKey: string, actorHash: string, - action: "upserted" | "revoked" | "rekeyed", + action: + | "upserted" + | "revoked" + | "rekeyed" + | "approval-requested" + | "approved" + | "rejected", version: number, createdAt: number, ) { @@ -241,7 +294,7 @@ async function appendAuditEvent( async function upsertException( spaceId: string, - actorHash: string, + actor: EnterpriseActor, incoming: RiskException, ): Promise { const recordKey = await createExceptionRecordKey(spaceId, incoming.id); @@ -257,9 +310,11 @@ async function upsertException( const existing = existingRow ? await decryptRow(existingRow, spaceId) : undefined; - const merged = existing - ? mergeRiskExceptions([existing], [incoming])[0] - : incoming; + const merged = prepareRiskExceptionForSync( + incoming, + actor.actorHash, + existing, + ); const provider = createKeyManagementProvider(runtime); const existingEnvelope = existingRow ? parseExceptionEnvelope(existingRow.envelope) @@ -289,7 +344,7 @@ async function upsertException( ) .bind( serializeExceptionEnvelope(envelope), - actorHash, + actor.actorHash, updatedAt, version, recordKey, @@ -307,7 +362,7 @@ async function upsertException( recordKey, spaceId, serializeExceptionEnvelope(envelope), - actorHash, + actor.actorHash, updatedAt, version, ) @@ -316,12 +371,14 @@ async function upsertException( await appendAuditEvent( spaceId, recordKey, - actorHash, + actor.actorHash, requiresRekey ? "rekeyed" : merged.revokedAt ? "revoked" - : "upserted", + : merged.approval?.status === "pending" + ? "approval-requested" + : "upserted", version, updatedAt, ); @@ -331,6 +388,70 @@ async function upsertException( throw new Error("Conflit de synchronisation persistant."); } +async function applyStoredDecision( + spaceId: string, + actor: EnterpriseActor, + exceptionId: string, + action: ExceptionDecision, +): Promise { + const recordKey = await createExceptionRecordKey(spaceId, exceptionId); + 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(); + if (!existingRow) { + throw new EnterpriseAuthorizationError( + "L’exception demandée est introuvable.", + 404, + ); + } + const existing = await decryptRow(existingRow, spaceId); + const decided = applyRiskExceptionDecision(existing, actor, action); + const provider = createKeyManagementProvider(runtime); + const envelope = await encryptSyncedRiskException( + decided, + provider, + spaceId, + recordKey, + ); + const updatedAt = Date.now(); + const version = existingRow.version + 1; + const result = 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), + actor.actorHash, + updatedAt, + version, + recordKey, + spaceId, + existingRow.version, + ) + .run(); + if ((result.meta.changes ?? 0) === 1) { + await appendAuditEvent( + spaceId, + recordKey, + actor.actorHash, + action === "approve" ? "approved" : "rejected", + version, + updatedAt, + ); + return; + } + } + throw new Error("Conflit d’approbation persistant."); +} + async function trimAuditEvents(spaceId: string) { await getD1() .prepare( @@ -349,7 +470,7 @@ async function trimAuditEvents(spaceId: string) { async function syncMetadata( request: Request, - actor: { actorHash: string; displayName: string }, + actor: EnterpriseActor, spaceId: string, ) { const provider = createKeyManagementProvider(runtime); @@ -366,6 +487,8 @@ async function syncMetadata( return { authenticated: true, identity: actor.displayName, + role: actor.role, + capabilities: roleCapabilities(actor.role), actorRef: actor.actorHash.slice(0, 12), workspaceRef: spaceId.slice(-12), kms: provider.status(), @@ -411,12 +534,18 @@ export async function PUT(request: Request) { if (!actor) { return responseJson({ error: "Authentification SSO requise." }, 401); } + if (!roleCapabilities(actor.role).canSync) { + return responseJson( + { error: "Le rôle lecteur ne peut pas modifier le registre." }, + 403, + ); + } 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; + if (await upsertException(spaceId, actor, exception)) changed += 1; } await trimAuditEvents(spaceId); const shared = await listSharedExceptions(spaceId); @@ -426,6 +555,9 @@ export async function PUT(request: Request) { sync: await syncMetadata(request, actor, spaceId), }); } catch (error) { + if (error instanceof EnterpriseAuthorizationError) { + return responseJson({ error: error.message }, error.status); + } const invalid = error instanceof SyncValidationError || error instanceof SyntaxError; const configuration = error instanceof KeyManagementConfigurationError; @@ -441,3 +573,48 @@ export async function PUT(request: Request) { ); } } + +export async function PATCH(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 decision = await readDecision(request); + await ensureExceptionSyncSchema(); + const spaceId = await workspaceId(); + await applyStoredDecision( + spaceId, + actor, + decision.exceptionId, + decision.action, + ); + await trimAuditEvents(spaceId); + const shared = await listSharedExceptions(spaceId); + return responseJson({ + ...shared, + changed: 1, + sync: await syncMetadata(request, actor, spaceId), + }); + } catch (error) { + if (error instanceof EnterpriseAuthorizationError) { + return responseJson({ error: error.message }, error.status); + } + const invalid = + error instanceof SyncValidationError || error instanceof SyntaxError; + const configuration = error instanceof KeyManagementConfigurationError; + return responseJson( + { + error: invalid + ? "La décision d’approbation envoyée est invalide." + : configuration + ? "Le fournisseur de clés n’est pas configuré." + : "La décision d’approbation n’a pas pu être enregistrée.", + }, + invalid ? 400 : configuration ? 503 : 500, + ); + } +} diff --git a/app/globals.css b/app/globals.css index 907f1bf..c162a1d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1904,7 +1904,7 @@ button.table-row:hover { border: 1px solid var(--line); border-radius: 12px; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; } @@ -1956,6 +1956,73 @@ button.table-row:hover { margin: 0; } +.approval-card { + border-color: #d9d2f2; + padding: 25px; +} + +.approval-list { + display: grid; + gap: 10px; +} + +.approval-row, +.approval-empty { + align-items: center; + background: var(--ivory); + border: 1px solid var(--line); + border-radius: 12px; + display: flex; + gap: 18px; + justify-content: space-between; + padding: 16px; +} + +.approval-row strong, +.approval-empty strong { + font-size: 10px; +} + +.approval-row p, +.approval-empty p, +.role-hint { + color: var(--muted); + font-size: 9px; + line-height: 1.5; + margin: 5px 0; +} + +.approval-row small { + color: var(--subtle); + font-size: 8px; +} + +.approval-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.approval-empty { + justify-content: flex-start; +} + +.approval-empty > span { + align-items: center; + background: var(--green-soft); + border-radius: 50%; + color: var(--green-dark); + display: inline-flex; + flex: 0 0 auto; + height: 30px; + justify-content: center; + width: 30px; +} + +.role-hint { + margin-bottom: 0; +} + .crypto-badge { background: var(--green-soft); border: 1px solid #c6e5d6; @@ -2649,6 +2716,16 @@ button.table-row:hover { color: #8d5a0f; } +.exception-status.pending { + background: #eee9ff; + color: #5d40a5; +} + +.exception-status.rejected { + background: var(--coral-soft); + color: #a0352f; +} + .exception-status.expired { background: var(--coral-soft); color: #a0352f; @@ -3505,6 +3582,16 @@ button.table-row:hover { border-color: #e6c985; } +.finding-exception.pending { + background: #f5f1ff; + border-color: #d5c9f2; +} + +.finding-exception.rejected { + background: var(--coral-soft); + border-color: #ecc1bc; +} + .finding-exception.expired { background: var(--coral-soft); border-color: #ecc1bc; @@ -3528,6 +3615,14 @@ button.table-row:hover { color: var(--coral); } +.finding-exception.pending .finding-exception-head span { + color: #5d40a5; +} + +.finding-exception.rejected .finding-exception-head span { + color: var(--coral); +} + .finding-exception-head strong { font-size: 8px; } @@ -3838,10 +3933,19 @@ button.table-row:hover { border-right: 0; } - .sync-facts > div:nth-child(-n + 2) { + .sync-facts > div:nth-child(4) { + border-right: 0; + } + + .sync-facts > div:not(:last-child) { border-bottom: 1px solid var(--line); } + .sync-facts > div:last-child { + border-right: 0; + grid-column: 1 / -1; + } + .exception-sync-grid .crypto-actions { flex-wrap: wrap; } @@ -4041,6 +4145,11 @@ button.table-row:hover { flex-direction: column; } + .approval-row { + 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 ac7fc7c..3afe167 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,6 +17,7 @@ import { type ServerStatus, type Severity, } from "../lib/audit-engine"; +import type { ExceptionDecision } from "../lib/enterprise-authorization"; import { createGovernedAuditReport, createGovernedSarifReport, @@ -506,6 +507,9 @@ export default function Home() { identity?: string; workspaceRef?: string; kms?: { label?: string; keyId?: string }; + role?: SharedExceptionSyncState["role"]; + capabilities?: SharedExceptionSyncState["capabilities"]; + localPreview?: boolean; }; }; if (!response.ok) { @@ -528,14 +532,19 @@ export default function Home() { }); setSharedExceptionSync({ phase: "synced", - message: "Session SSO vérifiée. Le registre local est à jour.", + message: body.sync?.capabilities?.canSync + ? "Session SSO vérifiée. Le registre local est à jour." + : "Session SSO vérifiée. Accès au registre en lecture seule.", identity: body.sync?.identity, workspaceRef: body.sync?.workspaceRef, kmsLabel: body.sync?.kms?.label, kmsKeyId: body.sync?.kms?.keyId, lastSyncedAt: body.lastSyncedAt, + role: body.sync?.role, + capabilities: body.sync?.capabilities, + localPreview: body.sync?.localPreview, }); - setExceptionSyncReady(true); + setExceptionSyncReady(body.sync?.capabilities?.canSync === true); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { return; @@ -582,6 +591,9 @@ export default function Home() { identity?: string; workspaceRef?: string; kms?: { label?: string; keyId?: string }; + role?: SharedExceptionSyncState["role"]; + capabilities?: SharedExceptionSyncState["capabilities"]; + localPreview?: boolean; }; }; if (!response.ok) { @@ -610,6 +622,9 @@ export default function Home() { kmsLabel: body.sync?.kms?.label, kmsKeyId: body.sync?.kms?.keyId, lastSyncedAt: body.lastSyncedAt ?? new Date().toISOString(), + role: body.sync?.role, + capabilities: body.sync?.capabilities, + localPreview: body.sync?.localPreview, }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { @@ -701,6 +716,8 @@ export default function Home() { ), [riskExceptions], ); + const canManageRiskExceptions = + sharedExceptionSync.capabilities?.canSync !== false; const filteredServers = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); @@ -897,6 +914,12 @@ export default function Home() { const saveRiskException = (server: McpServer, finding: Finding) => { if (!exceptionDraft) return; setExceptionError(""); + if (!canManageRiskExceptions) { + setExceptionError( + "Votre rôle lecteur ne permet pas de créer une exception.", + ); + return; + } if (findActiveRiskException(server, finding, riskExceptions)) { setExceptionError("Une exception active existe déjà pour cet écart."); @@ -918,7 +941,9 @@ export default function Home() { setRiskExceptions((current) => [...current, created]); setExceptionDraft(null); setToast( - `Exception documentée jusqu’au ${formatExceptionDate(created.expiresAt)}`, + finding.severity === "critical" + ? "Exception critique enregistrée — deux approbations distinctes sont requises" + : `Exception documentée jusqu’au ${formatExceptionDate(created.expiresAt)}`, ); window.setTimeout(() => setToast(""), 3000); } catch (error) { @@ -931,6 +956,11 @@ export default function Home() { }; const revokeException = (exceptionId: string) => { + if (sharedExceptionSync.capabilities?.canRevoke === false) { + setToast("Votre rôle ne permet pas de révoquer une exception"); + window.setTimeout(() => setToast(""), 3000); + return; + } setRiskExceptions((current) => current.map((exception) => exception.id === exceptionId @@ -944,6 +974,67 @@ export default function Home() { window.setTimeout(() => setToast(""), 3000); }; + const decideRiskException = async ( + exceptionId: string, + action: ExceptionDecision, + ) => { + const response = await fetch("/api/exception-sync", { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ exceptionId, action }), + }); + const body = (await response.json()) as { + error?: string; + exceptions?: unknown; + lastSyncedAt?: string | null; + sync?: { + identity?: string; + workspaceRef?: string; + kms?: { label?: string; keyId?: string }; + role?: SharedExceptionSyncState["role"]; + capabilities?: SharedExceptionSyncState["capabilities"]; + localPreview?: boolean; + }; + }; + if (!response.ok) { + const message = + body.error || "La décision d’approbation n’a pas pu être enregistrée."; + setToast(message); + window.setTimeout(() => setToast(""), 4000); + throw new Error(message); + } + 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) => mergeRiskExceptions(current, remote)); + setSharedExceptionSync({ + phase: "synced", + message: + action === "approve" + ? "Approbation attribuée et enregistrée côté serveur." + : "Exception critique rejetée côté serveur.", + identity: body.sync?.identity, + workspaceRef: body.sync?.workspaceRef, + kmsLabel: body.sync?.kms?.label, + kmsKeyId: body.sync?.kms?.keyId, + lastSyncedAt: body.lastSyncedAt ?? new Date().toISOString(), + role: body.sync?.role, + capabilities: body.sync?.capabilities, + localPreview: body.sync?.localPreview, + }); + setToast( + action === "approve" + ? "Approbation enregistrée" + : "Exception critique rejetée", + ); + window.setTimeout(() => setToast(""), 3000); + }; + const clearAuditHistory = async () => { if ( !window.confirm( @@ -1444,6 +1535,10 @@ export default function Home() { {status === "active" ? "Active" + : status === "pending" + ? "À approuver" + : status === "rejected" + ? "Rejetée" : status === "expired" ? "Expirée" : "Révoquée"} @@ -1459,7 +1554,8 @@ export default function Home() { {formatExceptionDate(exception.expiresAt)} - {status === "active" ? ( + {["active", "pending"].includes(status) && + canManageRiskExceptions ? ( - Stockage local à cet appareil. L’export JSON et - SARIF conserve la justification et l’échéance. + Les exceptions critiques restent ouvertes + jusqu’à deux approbations distinctes côté + serveur. L’export conserve la justification et + l’échéance. ) : null} diff --git a/app/trustmap-enterprise.tsx b/app/trustmap-enterprise.tsx index a5e3d62..0b2f361 100644 --- a/app/trustmap-enterprise.tsx +++ b/app/trustmap-enterprise.tsx @@ -2,7 +2,15 @@ import { useMemo, useState } from "react"; import type { McpServer } from "../lib/audit-engine"; -import type { RiskException } from "../lib/finding-exceptions"; +import type { + EnterpriseCapabilities, + EnterpriseRole, + ExceptionDecision, +} from "../lib/enterprise-authorization"; +import { + riskExceptionStatus, + type RiskException, +} from "../lib/finding-exceptions"; import { createEnterprisePolicyPack, createEnterpriseSummary, @@ -21,6 +29,15 @@ export type SharedExceptionSyncState = { kmsLabel?: string; kmsKeyId?: string; lastSyncedAt?: string | null; + role?: EnterpriseRole; + capabilities?: EnterpriseCapabilities; + localPreview?: boolean; +}; + +const roleLabel: Record = { + reader: "Lecteur", + auditor: "Auditeur", + admin: "Administrateur", }; export function TrustMapEnterprise({ @@ -30,6 +47,7 @@ export function TrustMapEnterprise({ onExceptionsImported, sharedSync, onSyncNow, + onExceptionDecision, }: { servers: McpServer[]; exceptions: RiskException[]; @@ -37,6 +55,10 @@ export function TrustMapEnterprise({ onExceptionsImported: (exceptions: RiskException[]) => void; sharedSync: SharedExceptionSyncState; onSyncNow: () => void; + onExceptionDecision: ( + exceptionId: string, + decision: ExceptionDecision, + ) => Promise; }) { const [passphrase, setPassphrase] = useState(""); const [encryptedBundle, setEncryptedBundle] = useState(""); @@ -44,10 +66,31 @@ export function TrustMapEnterprise({ const [syncMessage, setSyncMessage] = useState(""); const [syncError, setSyncError] = useState(""); const [syncBusy, setSyncBusy] = useState(false); + const [decisionBusy, setDecisionBusy] = useState(""); const summary = useMemo( () => createEnterpriseSummary(servers, exceptions), [exceptions, servers], ); + const pendingExceptions = useMemo( + () => + exceptions.filter( + (exception) => riskExceptionStatus(exception) === "pending", + ), + [exceptions], + ); + const canSync = sharedSync.capabilities?.canSync !== false; + + const decideException = async ( + exceptionId: string, + decision: ExceptionDecision, + ) => { + setDecisionBusy(`${exceptionId}:${decision}`); + try { + await onExceptionDecision(exceptionId, decision); + } finally { + setDecisionBusy(""); + } + }; const downloadJson = (filename: string, value: unknown) => { const url = URL.createObjectURL( @@ -252,6 +295,15 @@ export function TrustMapEnterprise({ : "En attente"} +
+ Rôle effectif + + {sharedSync.role + ? roleLabel[sharedSync.role] + : "En attente"} + + Autorisation vérifiée côté serveur +
Gestion des clés {sharedSync.kmsLabel || "Non configurée"} @@ -275,7 +327,8 @@ export function TrustMapEnterprise({ className="button secondary" disabled={ sharedSync.phase === "connecting" || - sharedSync.phase === "syncing" + sharedSync.phase === "syncing" || + !canSync } onClick={onSyncNow} > @@ -283,6 +336,87 @@ export function TrustMapEnterprise({
+
+
+
+ SÉPARATION DES TÂCHES +

Double approbation des exceptions critiques

+
+ {pendingExceptions.length} +
+

+ Une exception critique ne réduit jamais le risque avant deux + validations provenant d’auditeurs distincts du demandeur. Le serveur + vérifie l’identité, le rôle et l’unicité de chaque décision. +

+ {pendingExceptions.length ? ( +
+ {pendingExceptions.map((exception) => ( +
+
+ + {exception.serverName} · {exception.findingTitle} + +

{exception.reason}

+ + {exception.approval?.approvals.length ?? 0}/ + {exception.approval?.requiredApprovals ?? 2} approbations · + expire le{" "} + {new Intl.DateTimeFormat("fr-FR", { + dateStyle: "short", + }).format(new Date(exception.expiresAt))} + +
+
+ + {sharedSync.capabilities?.canReject ? ( + + ) : null} +
+
+ ))} +
+ ) : ( +
+ +
+ Aucune approbation critique en attente +

+ Les exceptions non critiques restent gérées par les rôles + auditeur et administrateur. +

+
+
+ )} + {!sharedSync.capabilities?.canApprove ? ( +

+ Votre rôle est en lecture seule pour les décisions d’approbation. +

+ ) : null} +
@@ -348,7 +482,7 @@ export function TrustMapEnterprise({