From c0971079819e908d1c03fe4a34541242c58e874e Mon Sep 17 00:00:00 2001 From: Dan G Date: Fri, 31 Jul 2026 06:28:38 +0200 Subject: [PATCH 1/2] Add standalone multi-tenant authentication --- .env.example | 37 +- .gitignore | 3 + README.md | 127 +- app/accept-invitation/page.tsx | 72 + app/api/audit-history/route.ts | 61 +- app/api/auth/[...all]/route.ts | 6 + app/api/exception-sync/route.ts | 88 +- app/auth-ui.tsx | 86 + app/chatgpt-auth.ts | 86 - app/dashboard.tsx | 2527 ++++++++++++++++++++++++ app/forgot-password/page.tsx | 73 + app/globals.css | 473 +++++ app/login/page.tsx | 86 + app/onboarding/page.tsx | 216 ++ app/page.tsx | 2524 +---------------------- app/reset-password/page.tsx | 92 + app/settings/page.tsx | 13 + app/settings/settings-client.tsx | 412 ++++ app/signup/page.tsx | 109 + app/trustmap-enterprise.tsx | 4 +- app/two-factor/page.tsx | 83 + db/index.ts | 5 +- db/schema.ts | 178 +- drizzle/0002_curious_vermin.sql | 109 + drizzle/meta/0002_snapshot.json | 973 +++++++++ drizzle/meta/_journal.json | 7 + lib/auth/client.ts | 25 + lib/auth/email.ts | 54 + lib/auth/permissions.ts | 48 + lib/auth/server.ts | 217 ++ lib/enterprise-authorization.ts | 27 - lib/key-management.ts | 2 +- package-lock.json | 476 ++++- package.json | 9 +- tests/enterprise-authorization.test.ts | 22 +- tests/enterprise-sync.test.ts | 2 +- tests/rendered-html.test.mjs | 371 ++-- tests/standalone-auth.test.ts | 24 + wrangler.example.jsonc | 42 + 39 files changed, 6665 insertions(+), 3104 deletions(-) create mode 100644 app/accept-invitation/page.tsx create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/auth-ui.tsx delete mode 100644 app/chatgpt-auth.ts create mode 100644 app/dashboard.tsx create mode 100644 app/forgot-password/page.tsx create mode 100644 app/login/page.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 app/reset-password/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/settings/settings-client.tsx create mode 100644 app/signup/page.tsx create mode 100644 app/two-factor/page.tsx create mode 100644 drizzle/0002_curious_vermin.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 lib/auth/client.ts create mode 100644 lib/auth/email.ts create mode 100644 lib/auth/permissions.ts create mode 100644 lib/auth/server.ts create mode 100644 tests/standalone-auth.test.ts create mode 100644 wrangler.example.jsonc diff --git a/.env.example b/.env.example index 50e749b..8aedea5 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,32 @@ -# One deployed private Site corresponds to one shared TrustMap workspace. -TRUSTMAP_WORKSPACE_ID=mcp-trustmap-primary +# URL publique exacte de l’application, sans barre oblique finale. +BETTER_AUTH_URL=http://localhost:3000 -# 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= +# Active les valeurs de secours réservées au poste de développement. +# En production, utilisez impérativement la valeur `production`. +TRUSTMAP_ENVIRONMENT=development -# Current envelope-key version. Rotate by changing the id and the key together. -TRUSTMAP_KMS_KEY_ID=sites-secret:v1 +# Secret de signature/chiffrement des sessions, 32 octets aléatoires minimum. +# Exemple de génération : npx auth secret +BETTER_AUTH_SECRET= -# Local/private deployment mode: 32 random bytes encoded as base64url. -# Store the real value only as a Sites secret, never in this file. +# Envoi des vérifications, invitations et réinitialisations via Resend. +RESEND_API_KEY= +TRUSTMAP_EMAIL_FROM=MCP TrustMap + +# Développement uniquement : écrit les liens d’e-mail dans la console lorsque +# Resend n’est pas configuré. Ne jamais activer en production. +TRUSTMAP_DEV_EMAIL_LOG=true + +# Version de la clé d’enveloppe utilisée pour le registre d’exceptions. +TRUSTMAP_KMS_KEY_ID=cloudflare-secret:v1 + +# 32 octets aléatoires encodés en base64url. Secret de production. TRUSTMAP_KMS_MASTER_KEY= -# Optional JSON map of at most ten previous platform keys needed during rotation. -# Example shape only: {"sites-secret:v0":""} +# Optionnel : anciennes clés conservées pendant une rotation. +# Forme : {"cloudflare-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. +# Optionnel : passerelle KMS/HSM externe. TRUSTMAP_KMS_ENDPOINT= TRUSTMAP_KMS_BEARER_TOKEN= diff --git a/.gitignore b/.gitignore index c4949f3..8123e75 100644 --- a/.gitignore +++ b/.gitignore @@ -33,12 +33,15 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +.dev.vars +wrangler.local.jsonc # vercel .vercel # typescript next-env.d.ts +*.tsbuildinfo /dist/ /.wrangler/ /outputs/ diff --git a/README.md b/README.md index 316780e..04304be 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ 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, synchronise le registre chiffré avec SSO, - applique les rôles et pilote les approbations critiques. + présente la posture par équipe, synchronise le registre chiffré par + organisation, applique les rôles et pilote les approbations critiques. Les vues Enterprise reflètent uniquement les données réellement chargées. -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. +MCP TrustMap fournit sa propre identité : inscription, vérification de l’adresse, +connexion, récupération du mot de passe et MFA TOTP. Les autorisations et +l’appartenance à l’organisation active 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 @@ -65,6 +67,10 @@ autorisations sont recalculées côté serveur à chaque requête. - score de sécurité global et par serveur ; - priorisation par criticité ; - remédiations expliquées avec extraits de configuration copiables ; +- comptes autonomes avec vérification de l’adresse, récupération du mot de passe + et authentification multifacteur TOTP avec codes de secours ; +- organisations clientes isolées et invitations par courriel ; +- rôles `Admin`, `Auditor` et `Reader` appliqués côté serveur ; - historique persistant des scores et écarts, comparé audit par audit ; - export CSV chronologique de la posture et des écarts agrégés ; - exceptions de risque motivées, attribuées, datées et révocables ; @@ -124,14 +130,29 @@ et renouvelez-la. ### Installation -```bash +```powershell git clone https://github.com/mawoole/MCPTrustMap.git cd MCPTrustMap npm ci +Copy-Item .env.example .dev.vars +npx wrangler d1 migrations apply DB --local --config wrangler.example.jsonc npm run dev ``` Ouvrez ensuite [http://localhost:3000](http://localhost:3000). +Le fichier `.dev.vars` est ignoré par Git. Pour recevoir réellement les +courriels en local, renseignez `RESEND_API_KEY` et `TRUSTMAP_EMAIL_FROM`. Sans +fournisseur d’e-mail, `TRUSTMAP_DEV_EMAIL_LOG=true` écrit les liens de +vérification, d’invitation et de récupération dans la console locale. + +### Premier démarrage + +1. Créez un compte dans **Inscription** puis vérifiez son adresse. +2. Créez l’organisation du premier client ; son créateur devient `Admin`. +3. Ouvrez **Paramètres** pour inviter les autres membres et leur attribuer + `Admin`, `Auditor` ou `Reader`. +4. Activez le MFA dans **Paramètres → Sécurité du compte**, scannez le QR code et + conservez les codes de secours hors ligne. ### Vérification de production @@ -576,27 +597,34 @@ exploitable. - **Next.js 16 / React 19** pour l’interface ; - **TypeScript** pour le moteur d’analyse et les composants ; - **vinext / Vite** pour la construction ; -- **Cloudflare D1** pour les synthèses historiques pseudonymisées ; +- **Better Auth** pour les comptes, sessions, organisations, invitations et MFA ; +- **Cloudflare D1** pour l’identité et les données métier isolées par organisation ; +- **Resend** pour les courriels transactionnels ; +- **Cloudflare Workers** pour l’hébergement autonome et le domaine personnalisé ; - aucune API distante requise pour l’analyse statique ; OSV reste optionnel. Principaux fichiers : ```text app/ - page.tsx Interface et orchestration des audits + page.tsx Garde de session et chargement de l’organisation active + dashboard.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 + settings/ Membres, rôles, invitations et MFA + api/auth/ API d’identité autonome + api/exception-sync/route.ts Synchronisation par organisation db/ - schema.ts Historique et enveloppes d’exceptions dans D1 + schema.ts Identité, organisations, historique et exceptions dans D1 lib/ + auth/ Configuration serveur/client, rôles et courriels 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 enterprise-authorization.ts Rôles et double approbation côté serveur - key-management.ts Enveloppe de clés Sites ou passerelle KMS HTTPS + key-management.ts Enveloppe de clés de l’hébergeur 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 @@ -640,6 +668,10 @@ public/ | `npm run dev` | Démarre l’application en développement | | `npm run build` | Produit et valide la version de production | | `npm run start` | Lance la version construite | +| `npm run db:generate` | Génère une migration Drizzle après un changement de schéma | +| `npm run db:migrate:local` | Applique les migrations à la base D1 locale | +| `npm run db:migrate:remote` | Applique les migrations à la base D1 de production | +| `npm run deploy:standalone` | Construit et déploie le Worker autonome | | `npm run collect` | Produit un inventaire local assaini | | `npm run audit:ci -- --path ` | Bloque la CI sur les constats critiques ou élevés | | `npm run collect:sbom` | Produit l’inventaire et le SBOM CycloneDX | @@ -768,16 +800,16 @@ 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 +### Synchronisation autonome par organisation 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. +`/api/exception-sync`. L’API accepte uniquement une session MCP TrustMap valide +et vérifie son appartenance à l’organisation active directement dans D1. Chaque +écriture est attribuée à une empreinte SHA-256 pseudonymisée propre au membre et +à l’organisation ; l’adresse e-mail n’est pas inscrite dans le registre métier. -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 +Chaque organisation cliente représente un espace de confiance séparé. Un membre +ne lit et ne modifie que le registre de son organisation active. La synchronisation : - fusionne les décisions sans supprimer celles absentes d’un appareil ; @@ -789,8 +821,9 @@ synchronisation : ### 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`. +Les rôles sont stockés dans l’appartenance de chaque membre à une organisation. +Un `Admin` les attribue depuis **Paramètres → Membres et rôles** et peut inviter +une adresse vérifiée par courriel. | Rôle | Lire | Synchroniser/révoquer | Approuver | Rejeter | | --- | --- | --- | --- | --- | @@ -812,8 +845,8 @@ 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 ; +1. **secret de plateforme**, utilisé par l’hébergement autonome : une clé + d’enveloppe de 32 octets est stockée comme secret du Worker ; 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. @@ -825,6 +858,44 @@ une ancienne version. Les variables attendues et leurs formes sont documentées dans `.env.example` sans aucune valeur secrète. +## Déploiement autonome avec domaine personnalisé + +Le modèle `wrangler.example.jsonc` déploie MCP TrustMap sur Cloudflare Workers, +relie D1 et configure un domaine personnalisé. Le domaine doit appartenir à une +zone Cloudflare de votre compte. + +```powershell +Copy-Item wrangler.example.jsonc wrangler.local.jsonc +npx wrangler login +npx wrangler d1 create mcp-trustmap-production +``` + +Reportez le `database_id` retourné dans `wrangler.local.jsonc`, puis remplacez +`app.votre-domaine.tld` dans `routes`, `BETTER_AUTH_URL` et +`TRUSTMAP_EMAIL_FROM`. Les valeurs sensibles restent hors du fichier : + +```powershell +npx wrangler secret put BETTER_AUTH_SECRET +npx wrangler secret put RESEND_API_KEY +npx wrangler secret put TRUSTMAP_KMS_MASTER_KEY +# Seulement si une passerelle KMS externe est utilisée : +npx wrangler secret put TRUSTMAP_KMS_BEARER_TOKEN +``` + +`BETTER_AUTH_SECRET` et `TRUSTMAP_KMS_MASTER_KEY` doivent chacun être des valeurs +aléatoires indépendantes d’au moins 32 octets. Appliquez enfin le schéma avant le +premier déploiement : + +```powershell +npm run db:migrate:remote +npm run deploy:standalone +``` + +Cloudflare crée le routage DNS et le certificat TLS du domaine personnalisé. +Le déploiement de production refuse de démarrer sans `BETTER_AUTH_SECRET`; la +journalisation locale des liens d’e-mail est explicitement désactivée dans le +modèle de production. + ## Limites actuelles - la découverte doit être lancée explicitement sur chaque poste à inventorier ; @@ -850,11 +921,10 @@ aucune valeur secrète. 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 ; -- la politique d’accès Sites définit les membres de l’espace ; l’application ne - 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 ; +- l’envoi des vérifications, invitations et récupérations nécessite une clé + Resend et un domaine d’expédition vérifié ; +- une organisation limitée à une seule identité ne peut pas achever une double + approbation : deux auditeurs distincts du demandeur doivent être membres ; - 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 @@ -864,9 +934,10 @@ aucune valeur secrète. ## Prochaines étapes possibles -- interface d’administration des membres et rôles reliée à un annuaire ; +- fédération SAML/OIDC et provisionnement SCIM pour les grands comptes ; +- clés d’accès matérielles WebAuthn en complément du TOTP ; - 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. +- clés de signature gérées par un HSM d’entreprise. ## Contribution diff --git a/app/accept-invitation/page.tsx b/app/accept-invitation/page.tsx new file mode 100644 index 0000000..5869d7b --- /dev/null +++ b/app/accept-invitation/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { authClient } from "../../lib/auth/client"; +import { + AuthLayout, + authErrorMessage, + FormMessage, +} from "../auth-ui"; + +export default function AcceptInvitationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const session = authClient.useSession(); + const [message, setMessage] = useState("Validation de l’invitation…"); + const [error, setError] = useState(""); + const invitationId = searchParams.get("invitationId"); + + useEffect(() => { + if (session.isPending || !invitationId) return; + if (!session.data) { + const returnTo = encodeURIComponent( + `/accept-invitation?invitationId=${encodeURIComponent(invitationId)}`, + ); + router.replace(`/login?returnTo=${returnTo}`); + return; + } + + void (async () => { + const invitation = await authClient.organization.getInvitation({ + query: { id: invitationId }, + }); + if (invitation.error || !invitation.data) { + setError(authErrorMessage(invitation.error)); + return; + } + const result = await authClient.organization.acceptInvitation({ + invitationId, + }); + if (result.error) { + setError(authErrorMessage(result.error)); + return; + } + await authClient.organization.setActive({ + organizationId: invitation.data.organizationId, + }); + setMessage("Invitation acceptée. Ouverture de votre espace…"); + router.replace("/"); + })(); + }, [invitationId, router, session.data, session.isPending]); + + const displayedError = invitationId + ? error + : "Cette invitation est absente ou invalide."; + + return ( + + {displayedError ? ( + <> + {displayedError} + + Voir mes espaces + + + ) : ( + {message} + )} + + ); +} diff --git a/app/api/audit-history/route.ts b/app/api/audit-history/route.ts index 8e9d8d4..db21fb1 100644 --- a/app/api/audit-history/route.ts +++ b/app/api/audit-history/route.ts @@ -8,10 +8,10 @@ import { type AuditHistoryPayload, type AuditHistoryRecord, } from "../../../lib/audit-history"; +import { getAuthContext } from "../../../lib/auth/server"; export const dynamic = "force-dynamic"; -const AUTHENTICATED_EMAIL_HEADER = "oai-authenticated-user-email"; const MAX_REQUEST_BYTES = 16_384; function responseJson(body: unknown, status = 200): Response { @@ -23,30 +23,6 @@ function responseJson(body: unknown, status = 200): Response { }); } -function isLocalRequest(request: Request): boolean { - return ["localhost", "127.0.0.1", "::1"].includes( - new URL(request.url).hostname, - ); -} - -async function actorHash(request: Request): Promise { - const email = request.headers - .get(AUTHENTICATED_EMAIL_HEADER) - ?.trim() - .toLowerCase(); - const identity = email || (isLocalRequest(request) ? "local-preview" : ""); - if (!identity) return null; - - const digest = await crypto.subtle.digest( - "SHA-256", - // Keep the original namespace so existing per-user history remains readable. - new TextEncoder().encode(`mcp-sentinel:audit-history:${identity}`), - ); - return [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - function sameOrigin(request: Request): boolean { const origin = request.headers.get("Origin"); if (!origin) return false; @@ -98,14 +74,14 @@ async function readPayload(request: Request): Promise { export async function GET(request: Request) { try { - const owner = await actorHash(request); - if (!owner) return responseJson({ error: "Authentification requise." }, 401); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); await ensureAuditHistorySchema(); const rows = await getDb() .select() .from(auditHistory) - .where(eq(auditHistory.actorHash, owner)) + .where(eq(auditHistory.organizationId, actor.organizationId)) .orderBy(desc(auditHistory.createdAt)) .limit(AUDIT_HISTORY_LIMIT); @@ -123,8 +99,14 @@ export async function POST(request: Request) { if (!sameOrigin(request)) { return responseJson({ error: "Origine de requête refusée." }, 403); } - const owner = await actorHash(request); - if (!owner) return responseJson({ error: "Authentification requise." }, 401); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role === "reader") { + return responseJson( + { error: "Le rôle Reader ne peut pas enregistrer un audit." }, + 403, + ); + } const payload = await readPayload(request); const id = crypto.randomUUID(); @@ -133,7 +115,8 @@ export async function POST(request: Request) { const db = getDb(); await db.insert(auditHistory).values({ id, - actorHash: owner, + organizationId: actor.organizationId, + actorHash: actor.actorHash, createdAt, source: payload.source, score: payload.score, @@ -149,7 +132,7 @@ export async function POST(request: Request) { const stale = await db .select({ id: auditHistory.id }) .from(auditHistory) - .where(eq(auditHistory.actorHash, owner)) + .where(eq(auditHistory.organizationId, actor.organizationId)) .orderBy(desc(auditHistory.createdAt)) .limit(500) .offset(AUDIT_HISTORY_LIMIT); @@ -158,7 +141,7 @@ export async function POST(request: Request) { .delete(auditHistory) .where( and( - eq(auditHistory.actorHash, owner), + eq(auditHistory.organizationId, actor.organizationId), inArray( auditHistory.id, stale.map((entry) => entry.id), @@ -197,13 +180,19 @@ export async function DELETE(request: Request) { if (!sameOrigin(request)) { return responseJson({ error: "Origine de requête refusée." }, 403); } - const owner = await actorHash(request); - if (!owner) return responseJson({ error: "Authentification requise." }, 401); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role !== "admin") { + return responseJson( + { error: "Seul un administrateur peut effacer l’historique." }, + 403, + ); + } await ensureAuditHistorySchema(); await getDb() .delete(auditHistory) - .where(eq(auditHistory.actorHash, owner)); + .where(eq(auditHistory.organizationId, actor.organizationId)); return responseJson({ deleted: true }); } catch { return responseJson( diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..f08a1d1 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "../../../../lib/auth/server"; + +export const dynamic = "force-dynamic"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/app/api/exception-sync/route.ts b/app/api/exception-sync/route.ts index dcb6df3..233a30a 100644 --- a/app/api/exception-sync/route.ts +++ b/app/api/exception-sync/route.ts @@ -13,7 +13,6 @@ import { EnterpriseAuthorizationError, normalizeStoredRiskException, prepareRiskExceptionForSync, - resolveEnterpriseRole, roleCapabilities, type EnterpriseActor, type ExceptionDecision, @@ -26,13 +25,10 @@ import { createKeyManagementProvider, KeyManagementConfigurationError, } from "../../../lib/key-management"; +import { getAuthContext } from "../../../lib/auth/server"; 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; @@ -56,12 +52,6 @@ function responseJson(body: unknown, status = 200): Response { }); } -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; @@ -72,58 +62,21 @@ function sameOrigin(request: Request): boolean { } } -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 { - const email = request.headers - .get(AUTHENTICATED_EMAIL_HEADER) - ?.trim() - .toLowerCase(); - const localPreview = isLocalRequest(request); - const identity = email || (localPreview ? "local-preview" : ""); - if (!identity) return null; +): Promise<(EnterpriseActor & { organizationId: string }) | null> { + const context = await getAuthContext(request.headers); + if (!context) 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), + actorHash: context.actorHash, + displayName: context.displayName, + organizationId: context.organizationId, + role: context.role, }; } -async function workspaceId(): Promise { - const configured = runtime.TRUSTMAP_WORKSPACE_ID; - return createExceptionSpaceId( - typeof configured === "string" && configured.trim() - ? configured - : "primary-private-site", - ); +async function workspaceId(organizationId: string): Promise { + return createExceptionSpaceId(`organization:${organizationId}`); } async function readExceptions(request: Request): Promise { @@ -469,7 +422,6 @@ async function trimAuditEvents(spaceId: string) { } async function syncMetadata( - request: Request, actor: EnterpriseActor, spaceId: string, ) { @@ -495,7 +447,7 @@ async function syncMetadata( lastActivityAt: event?.created_at ? new Date(event.created_at).toISOString() : null, - localPreview: isLocalRequest(request), + localPreview: false, }; } @@ -503,14 +455,14 @@ export async function GET(request: Request) { try { const actor = await authenticatedActor(request); if (!actor) { - return responseJson({ error: "Authentification SSO requise." }, 401); + return responseJson({ error: "Authentification MCP TrustMap requise." }, 401); } await ensureExceptionSyncSchema(); - const spaceId = await workspaceId(); + const spaceId = await workspaceId(actor.organizationId); const shared = await listSharedExceptions(spaceId); return responseJson({ ...shared, - sync: await syncMetadata(request, actor, spaceId), + sync: await syncMetadata(actor, spaceId), }); } catch (error) { const configuration = error instanceof KeyManagementConfigurationError; @@ -532,7 +484,7 @@ export async function PUT(request: Request) { } const actor = await authenticatedActor(request); if (!actor) { - return responseJson({ error: "Authentification SSO requise." }, 401); + return responseJson({ error: "Authentification MCP TrustMap requise." }, 401); } if (!roleCapabilities(actor.role).canSync) { return responseJson( @@ -542,7 +494,7 @@ export async function PUT(request: Request) { } const incoming = await readExceptions(request); await ensureExceptionSyncSchema(); - const spaceId = await workspaceId(); + const spaceId = await workspaceId(actor.organizationId); let changed = 0; for (const exception of incoming) { if (await upsertException(spaceId, actor, exception)) changed += 1; @@ -552,7 +504,7 @@ export async function PUT(request: Request) { return responseJson({ ...shared, changed, - sync: await syncMetadata(request, actor, spaceId), + sync: await syncMetadata(actor, spaceId), }); } catch (error) { if (error instanceof EnterpriseAuthorizationError) { @@ -581,11 +533,11 @@ export async function PATCH(request: Request) { } const actor = await authenticatedActor(request); if (!actor) { - return responseJson({ error: "Authentification SSO requise." }, 401); + return responseJson({ error: "Authentification MCP TrustMap requise." }, 401); } const decision = await readDecision(request); await ensureExceptionSyncSchema(); - const spaceId = await workspaceId(); + const spaceId = await workspaceId(actor.organizationId); await applyStoredDecision( spaceId, actor, @@ -597,7 +549,7 @@ export async function PATCH(request: Request) { return responseJson({ ...shared, changed: 1, - sync: await syncMetadata(request, actor, spaceId), + sync: await syncMetadata(actor, spaceId), }); } catch (error) { if (error instanceof EnterpriseAuthorizationError) { diff --git a/app/auth-ui.tsx b/app/auth-ui.tsx new file mode 100644 index 0000000..77953cb --- /dev/null +++ b/app/auth-ui.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +export function AuthLayout({ + children, + eyebrow, + title, +}: { + children: ReactNode; + eyebrow: string; + title: string; +}) { + return ( +
+
+ + + + MCP TrustMap + Autonomous trust & security workspace + + +
+ SÉCURITÉ MCP MULTI-CLIENT +

Une identité indépendante pour chaque organisation.

+

+ Comptes vérifiés, rôles cloisonnés, double approbation et MFA sans + dépendance à un espace ChatGPT. +

+
    +
  • Isolation stricte des données par organisation
  • +
  • Rôles Admin, Auditor et Reader
  • +
  • Invitations et récupération sécurisées par e-mail
  • +
+
+
+
+
+

{eyebrow}

+

{title}

+ {children} +
+
+
+ ); +} + +export function FormMessage({ + children, + tone = "error", +}: { + children: ReactNode; + tone?: "error" | "success" | "info"; +}) { + return ( +

+ {children} +

+ ); +} + +export function authErrorMessage(error: unknown): string { + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + return "L’opération n’a pas pu être terminée."; +} + +export function safeReturnTo(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) return "/"; + try { + const url = new URL(value, "https://mcp-trustmap.local"); + return url.origin === "https://mcp-trustmap.local" + ? `${url.pathname}${url.search}${url.hash}` + : "/"; + } catch { + return "/"; + } +} diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts deleted file mode 100644 index 8d1fb35..0000000 --- a/app/chatgpt-auth.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -export type ChatGPTUser = { - displayName: string; - email: string; - fullName: string | null; -}; - -const USER_EMAIL_HEADER = "oai-authenticated-user-email"; -const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; -const USER_FULL_NAME_ENCODING_HEADER = - "oai-authenticated-user-full-name-encoding"; -const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; -const SIGN_IN_PATH = "/signin-with-chatgpt"; -const SIGN_OUT_PATH = "/signout-with-chatgpt"; -const CALLBACK_PATH = "/callback"; - -export async function getChatGPTUser(): Promise { - const requestHeaders = await headers(); - const email = requestHeaders.get(USER_EMAIL_HEADER); - if (!email) return null; - - const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); - const fullName = - encodedFullName && - requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 - ? safeDecodeURIComponent(encodedFullName) - : null; - - return { - displayName: fullName ?? email, - email, - fullName, - }; -} - -export async function requireChatGPTUser( - returnTo: string, -): Promise { - const user = await getChatGPTUser(); - if (user) return user; - - redirect(chatGPTSignInPath(returnTo)); -} - -export function chatGPTSignInPath(returnTo: string): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -export function chatGPTSignOutPath(returnTo = "/"): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -function safeRelativeReturnPath(value: string): string { - if (!value.startsWith("/") || value.startsWith("//")) return "/"; - - let url: URL; - try { - url = new URL(value, "https://app.local"); - } catch { - return "/"; - } - if (url.origin !== "https://app.local") return "/"; - if (isReservedAuthPath(url.pathname)) return "/"; - - return `${url.pathname}${url.search}${url.hash}`; -} - -function isReservedAuthPath(pathname: string): boolean { - return ( - pathname === SIGN_IN_PATH || - pathname === SIGN_OUT_PATH || - pathname === CALLBACK_PATH - ); -} - -function safeDecodeURIComponent(value: string): string | null { - try { - return decodeURIComponent(value); - } catch { - return null; - } -} diff --git a/app/dashboard.tsx b/app/dashboard.tsx new file mode 100644 index 0000000..319d0b0 --- /dev/null +++ b/app/dashboard.tsx @@ -0,0 +1,2527 @@ +"use client"; + +import { + ChangeEvent, + CSSProperties, + useEffect, + useMemo, + useState, +} from "react"; +import { + auditConfiguration, + calculateAuditMetrics, + SECURITY_RULES, + type Finding, + type McpServer, + type ProbeStatus, + type ServerStatus, + type Severity, +} from "../lib/audit-engine"; +import type { ExceptionDecision } from "../lib/enterprise-authorization"; +import { + createGovernedAuditReport, + createGovernedSarifReport, + createRiskException, + findActiveRiskException, + findLatestRiskException, + findingExceptionKey, + openFindingEntries, + parseRiskExceptions, + revokeRiskException, + riskExceptionStatus, + type RiskException, +} from "../lib/finding-exceptions"; +import { + createCycloneDxReport, + type ComponentPinStatus, +} from "../lib/supply-chain"; +import { + AUDIT_HISTORY_LIMIT, + createAuditHistoryPayload, + createAuditHistoryCsv, + parseAuditHistoryRecords, + type AuditHistoryRecord, + type AuditHistorySource, +} from "../lib/audit-history"; +import { AuditHistoryView } from "./audit-history-view"; +import { TrustMapDiscover } from "./trustmap-discover"; +import { TrustMapCi } from "./trustmap-ci"; +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"; + +export type DashboardIdentity = { + displayName: string; + email: string; + organizationName: string; + role: "admin" | "auditor" | "reader"; +}; + +type ExceptionDraft = { + findingKey: string; + reason: string; + owner: string; + expiresOn: string; + minimumExpiresOn: string; + maximumExpiresOn: string; +}; + +const RISK_EXCEPTIONS_STORAGE_KEY = "mcp-trustmap.risk-exceptions.v1"; +const LEGACY_RISK_EXCEPTIONS_STORAGE_KEYS = [ + ["secure", "mpc.risk-exceptions.v1"].join("-"), + "mcp-sentinel.risk-exceptions.v1", +] as const; + +function dateInputValue(date: Date): string { + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 10); +} + +function defaultExceptionExpiry(): string { + return dateInputValue(new Date(Date.now() + 30 * 24 * 60 * 60 * 1_000)); +} + +function formatExceptionDate(value: string): string { + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(value)); +} + +const severityLabel: Record = { + critical: "Critique", + high: "Élevé", + medium: "Modéré", +}; + +const statusLabel: Record = { + critical: "Critique", + attention: "À corriger", + secure: "Conforme", +}; + +const probeStatusLabel: Record = { + "not-requested": "Non vérifié", + reachable: "Négociation réussie", + "auth-required": "Authentification exigée", + "skipped-insecure": "Ignoré — HTTP non chiffré", + "skipped-stdio": "Ignoré — exécution locale", + timeout: "Délai dépassé", + unreachable: "Injoignable", + "protocol-error": "Erreur de protocole", +}; + +const pinStatusLabel: Record = { + pinned: "Immuable", + unpinned: "Non verrouillé", + mutable: "Tag mutable", + unknown: "Version inconnue", + "not-applicable": "Non applicable", +}; + +const makeFinding = ( + id: string, + severity: Severity, + title: string, + description: string, + remediation: string, + snippet: string, + rule: string, +): Finding => ({ + id, + severity, + title, + description, + remediation, + snippet, + rule, +}); + +const demoServers: McpServer[] = [ + { + id: "github", + name: "GitHub MCP", + owner: "Équipe Platform", + transport: "Stdio", + source: "Claude Desktop", + score: 42, + status: "critical", + controls: 4, + lastScan: "il y a 2 min", + findings: [ + makeFinding( + "github-secret", + "critical", + "Secret stocké dans la configuration", + "Un jeton d’accès est présent directement dans le bloc env. Il peut fuiter via une sauvegarde, un log ou un partage de configuration.", + "Placez le jeton dans un coffre de secrets, injectez-le au démarrage et révoquez la valeur actuellement exposée.", + `"env": {\n "GITHUB_TOKEN": "\${GITHUB_TOKEN}"\n}`, + "MCP-SEC-01", + ), + makeFinding( + "github-version", + "high", + "Dépendance non verrouillée", + "Le serveur est lancé avec npx sans version exacte. Une mise à jour compromise pourrait être exécutée automatiquement.", + "Verrouillez le paquet sur une version revue et mettez les mises à jour sous contrôle de votre gestionnaire de dépendances.", + `"args": ["-y", "@modelcontextprotocol/server-github@2026.7.2"]`, + "MCP-SUP-02", + ), + ], + }, + { + id: "filesystem", + name: "Filesystem MCP", + owner: "Équipe Produit", + transport: "Stdio", + source: "Cursor", + score: 58, + status: "critical", + controls: 4, + lastScan: "il y a 2 min", + findings: [ + makeFinding( + "fs-scope", + "critical", + "Périmètre de fichiers trop large", + "Le serveur peut parcourir la racine du disque. Une instruction malveillante pourrait lire des clés, des sources ou des fichiers personnels.", + "Exposez uniquement le dossier nécessaire au projet et exécutez le processus avec un compte sans privilèges.", + `"args": [\n "@modelcontextprotocol/server-filesystem@2026.7.1",\n "/workspace/projet-mcp"\n]`, + "MCP-AUTHZ-03", + ), + ], + }, + { + id: "postgres", + name: "Postgres Ops", + owner: "Équipe Data", + transport: "Stdio", + source: "VS Code", + score: 67, + status: "critical", + controls: 4, + lastScan: "il y a 4 min", + findings: [ + makeFinding( + "pg-role", + "critical", + "Compte de base de données sur-privilégié", + "La connexion utilise un rôle administrateur avec des droits d’écriture sur la production.", + "Créez un rôle dédié en lecture seule, limitez-le au schéma utile et imposez une durée maximale aux requêtes.", + `CREATE ROLE mcp_reader LOGIN;\nGRANT CONNECT ON DATABASE ops TO mcp_reader;\nGRANT USAGE ON SCHEMA reporting TO mcp_reader;\nGRANT SELECT ON ALL TABLES IN SCHEMA reporting TO mcp_reader;`, + "MCP-AUTHZ-01", + ), + ], + }, + { + id: "notion", + name: "Notion Knowledge", + owner: "Équipe Enablement", + transport: "HTTPS", + source: "Claude Desktop", + score: 76, + status: "secure", + controls: 4, + lastScan: "il y a 5 min", + findings: [], + }, + { + id: "slack", + name: "Slack Gateway", + owner: "Équipe Support", + transport: "HTTPS", + source: "Configuration centrale", + score: 91, + status: "attention", + controls: 4, + lastScan: "il y a 7 min", + findings: [ + makeFinding( + "slack-scope", + "high", + "Portée OAuth excessive", + "Le jeton peut écrire dans tous les canaux alors que ce serveur n’effectue que de la recherche.", + "Remplacez les droits d’écriture par les seules portées de lecture nécessaires, puis renouvelez le jeton.", + `scopes:\n - channels:read\n - search:read\n - users:read`, + "MCP-AUTHZ-02", + ), + ], + }, + { + id: "browser", + name: "Browser Tools", + owner: "Équipe QA", + transport: "Stdio", + source: "Cursor", + score: 82, + status: "critical", + controls: 4, + lastScan: "il y a 8 min", + findings: [ + makeFinding( + "browser-flag", + "critical", + "Contournement de sécurité activé", + "Le paramètre --no-sandbox désactive une barrière d’isolation essentielle du navigateur.", + "Supprimez ce paramètre et exécutez le navigateur dans un conteneur ou un profil isolé et éphémère.", + `"args": ["@acme/browser-mcp@3.4.1", "--isolated", "--ephemeral"]`, + "MCP-EXEC-02", + ), + ], + }, + { + id: "sentry", + name: "Sentry Reader", + owner: "Équipe SRE", + transport: "HTTPS", + source: "Configuration centrale", + score: 88, + status: "secure", + controls: 4, + lastScan: "il y a 10 min", + findings: [], + }, + { + id: "fetch", + name: "Internal Fetch", + owner: "Équipe Platform", + transport: "HTTP", + source: "VS Code", + score: 52, + status: "critical", + controls: 4, + lastScan: "il y a 11 min", + findings: [ + makeFinding( + "fetch-tls", + "critical", + "Transport non chiffré", + "Le serveur distant utilise HTTP. Les requêtes, réponses et éventuels jetons peuvent être interceptés.", + "Exposez le serveur uniquement en HTTPS avec un certificat valide et refusez toute redirection vers HTTP.", + `"url": "https://mcp.internal.example/v1"\n"transport": "streamable-http"`, + "MCP-NET-01", + ), + ], + }, + { + id: "kubernetes", + name: "Kubernetes Prod", + owner: "Équipe SRE", + transport: "Stdio", + source: "Claude Desktop", + score: 73, + status: "critical", + controls: 4, + lastScan: "il y a 12 min", + findings: [ + makeFinding( + "k8s-shell", + "critical", + "Exécution via un interpréteur de commandes", + "Le serveur démarre avec bash -c, ce qui augmente le risque d’injection de commandes par les arguments ou variables.", + "Lancez directement le binaire MCP et appliquez un profil RBAC Kubernetes limité aux ressources consultées.", + `"command": "/opt/mcp/kubernetes-server"\n"args": ["--context", "prod-readonly"]`, + "MCP-EXEC-01", + ), + ], + }, + { + id: "linear", + name: "Linear MCP", + owner: "Équipe Produit", + transport: "HTTPS", + source: "Configuration centrale", + score: 96, + status: "secure", + controls: 4, + lastScan: "il y a 15 min", + findings: [], + }, + { + id: "analytics", + name: "Analytics DB", + owner: "Équipe Data", + transport: "Stdio", + source: "Cursor", + score: 65, + status: "attention", + controls: 4, + lastScan: "il y a 17 min", + findings: [ + makeFinding( + "analytics-audit", + "medium", + "Journalisation insuffisante", + "Aucun identifiant de session MCP n’est transmis aux journaux de requêtes de la base.", + "Ajoutez un nom d’application et corrélez chaque appel d’outil à l’identité de l’utilisateur et à la session.", + `PGAPPNAME=mcp-analytics\nMCP_AUDIT_MODE=metadata-only`, + "MCP-AUDIT-01", + ), + ], + }, + { + id: "design", + name: "Design Assets", + owner: "Équipe Design", + transport: "HTTPS", + source: "Configuration centrale", + score: 74, + status: "secure", + controls: 3, + lastScan: "il y a 19 min", + findings: [], + }, +]; + +const rules = SECURITY_RULES; + +const sampleConfig = `{ + "mcpServers": { + "filesystem-project": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/"] + }, + "internal-api": { + "url": "http://mcp.internal.example/v1", + "headers": { + "Authorization": "Bearer secret-token-value" + } + } + } +}`; + +function BrandMark() { + return ( + + ); +} + +function StatusDot({ status }: { status: ServerStatus }) { + return