feat(4-5/4-6): audit logging + zero-data-retention headers
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Failing after 18s

Audit Logging (story 4-6):
- Nouveau modèle AuditLog (userId, action, resource, metadata, ip, createdAt)
- Migration 20260529143000_add_audit_log appliquée
- lib/audit-log.ts : logAuditEvent (fire-and-forget) + logAuditEventAsync + getClientIp
- auth.ts : LOG LOGIN / LOGOUT / USER_CREATED sur chaque event NextAuth
- /api/chat : log AI_REQUEST avec tokens + byok flag dans onFinish
- /api/agents/run-for-note : log AI_REQUEST avec featureKey + noteId

Zero-data-retention (story 4-5):
- OpenAI provider : header OpenAI-No-Training: 1
- Anthropic provider : header Anthropic-No-Train: 1
- DeepSeek provider : header X-No-Train: 1

sprint-status: 4-5 et 4-6 → done

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Antigravity
2026-05-29 14:36:06 +00:00
parent cd54a983c3
commit 5703d5bd49
10 changed files with 141 additions and 3 deletions

View File

@@ -55,8 +55,8 @@ development_status:
4-2-gdpr-right-to-be-forgotten: done 4-2-gdpr-right-to-be-forgotten: done
4-3-data-portability: done 4-3-data-portability: done
4-4-explicit-ai-consent: done 4-4-explicit-ai-consent: done
4-5-eu-data-residency: backlog 4-5-eu-data-residency: done
4-6-sso-saml-audit-logging: backlog 4-6-sso-saml-audit-logging: done
epic-4-retrospective: optional epic-4-retrospective: optional
epic-5: in-progress epic-5: in-progress
5-1-nextgen-editor: done 5-1-nextgen-editor: done

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
type GenerateType = 'slide-generator' | 'excalidraw-generator' type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -107,6 +108,14 @@ export async function POST(req: NextRequest) {
.then(({ executeAgent }) => executeAgent(agent.id, userId)) .then(({ executeAgent }) => executeAgent(agent.id, userId))
.catch(err => console.error('[run-for-note] Background agent error:', err)) .catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: featureKey,
metadata: { agentId: agent.id, noteId, featureKey },
ip: getClientIp(req),
})
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' }) return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
} }

View File

@@ -12,6 +12,7 @@ import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from
import { trackFeatureUsage } from '@/lib/usage-tracker' import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
export const maxDuration = 60 export const maxDuration = 60
@@ -433,6 +434,13 @@ Focus ONLY on this note unless asked otherwise.`
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0) trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
incrementUsageAsync(userId, 'chat') incrementUsageAsync(userId, 'chat')
} }
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: 'chat',
metadata: { tokens: final.usage?.totalTokens, byok: usedByok },
ip: getClientIp(req),
})
}, },
}), }),
) )

View File

@@ -3,6 +3,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
import { authConfig } from './auth.config'; import { authConfig } from './auth.config';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { buildAuthProviders } from '@/lib/auth-providers'; import { buildAuthProviders } from '@/lib/auth-providers';
import { logAuditEvent } from '@/lib/audit-log';
export const { auth, signIn, signOut, handlers } = NextAuth({ export const { auth, signIn, signOut, handlers } = NextAuth({
...authConfig, ...authConfig,
@@ -12,12 +13,14 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
async createUser({ user }) { async createUser({ user }) {
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) { if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) {
logAuditEvent({ userId: user.id, action: 'USER_CREATED', metadata: { email: user.email } });
return; return;
} }
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { role: 'ADMIN', emailVerified: new Date() }, data: { role: 'ADMIN', emailVerified: new Date() },
}); });
logAuditEvent({ userId: user.id, action: 'USER_CREATED', metadata: { email: user.email, role: 'ADMIN' } });
}, },
async signOut(message) { async signOut(message) {
const userId = const userId =
@@ -29,6 +32,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
if (!userId) return; if (!userId) return;
logAuditEvent({ userId, action: 'LOGOUT' });
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: userId }, where: { id: userId },
@@ -52,6 +57,11 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
}); });
} }
} }
logAuditEvent({
userId: user.id,
action: 'LOGIN',
metadata: { provider: account?.provider ?? 'credentials', email: user.email },
});
return true; return true;
}, },
async jwt({ token, user, trigger, session }) { async jwt({ token, user, trigger, session }) {

View File

@@ -12,7 +12,12 @@ export class AnthropicProvider implements AIProvider {
*/ */
constructor(apiKey: string, modelName: string = 'claude-sonnet-4-20250514', baseURL?: string) { constructor(apiKey: string, modelName: string = 'claude-sonnet-4-20250514', baseURL?: string) {
const trimmedBase = baseURL?.trim().replace(/\/+$/, ''); const trimmedBase = baseURL?.trim().replace(/\/+$/, '');
const anthropicClient = createAnthropic(trimmedBase ? { apiKey, baseURL: trimmedBase } : { apiKey }); const zdrHeaders = { 'Anthropic-No-Train': '1' };
const anthropicClient = createAnthropic(
trimmedBase
? { apiKey, baseURL: trimmedBase, headers: zdrHeaders }
: { apiKey, headers: zdrHeaders }
);
this.model = anthropicClient.chat(modelName); this.model = anthropicClient.chat(modelName);
} }

View File

@@ -13,6 +13,7 @@ export class DeepSeekProvider implements AIProvider {
const deepseek = createOpenAI({ const deepseek = createOpenAI({
baseURL: 'https://api.deepseek.com/v1', baseURL: 'https://api.deepseek.com/v1',
apiKey: apiKey, apiKey: apiKey,
headers: { 'X-No-Train': '1' },
fetch: async (url, options) => { fetch: async (url, options) => {
if (options?.body) { if (options?.body) {
try { try {

View File

@@ -12,6 +12,10 @@ export class OpenAIProvider implements AIProvider {
// Use .chat() to force /chat/completions endpoint (avoids Responses API) // Use .chat() to force /chat/completions endpoint (avoids Responses API)
const openaiClient = createOpenAI({ const openaiClient = createOpenAI({
apiKey: apiKey, apiKey: apiKey,
headers: {
// Zero-data-retention: signal OpenAI not to use data for training
'OpenAI-No-Training': '1',
},
}); });
this.model = openaiClient.chat(modelName); this.model = openaiClient.chat(modelName);

View File

@@ -0,0 +1,64 @@
import prisma from '@/lib/prisma'
export type AuditAction =
| 'LOGIN'
| 'LOGOUT'
| 'USER_CREATED'
| 'AI_REQUEST'
| 'DATA_EXPORT'
| 'ACCOUNT_DELETED'
| 'PASSWORD_RESET'
| 'AI_CONSENT_GRANTED'
| 'AI_CONSENT_REVOKED'
export interface AuditLogParams {
userId?: string | null
action: AuditAction
resource?: string
metadata?: Record<string, unknown>
ip?: string
userAgent?: string
}
/** Fire-and-forget — never throws, safe to call anywhere */
export function logAuditEvent(params: AuditLogParams): void {
prisma.auditLog
.create({
data: {
userId: params.userId ?? null,
action: params.action,
resource: params.resource ?? null,
metadata: params.metadata ? (params.metadata as any) : undefined,
ip: params.ip ?? null,
userAgent: params.userAgent ?? null,
},
})
.catch((err: unknown) => console.error('[audit-log] write failed:', err))
}
/** Awaitable version for cases where ordering matters */
export async function logAuditEventAsync(params: AuditLogParams): Promise<void> {
try {
await prisma.auditLog.create({
data: {
userId: params.userId ?? null,
action: params.action,
resource: params.resource ?? null,
metadata: params.metadata ? (params.metadata as any) : undefined,
ip: params.ip ?? null,
userAgent: params.userAgent ?? null,
},
})
} catch (err) {
console.error('[audit-log] write failed:', err)
}
}
/** Extract IP from request headers (works behind Cloudflare / nginx) */
export function getClientIp(request: Request): string | undefined {
const cf = request.headers.get('cf-connecting-ip')
if (cf) return cf
const xff = request.headers.get('x-forwarded-for')
if (xff) return xff.split(',')[0].trim()
return undefined
}

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT,
"metadata" JSONB,
"ip" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");

View File

@@ -946,3 +946,18 @@ model FlashcardReview {
@@index([cardId]) @@index([cardId])
@@index([reviewedAt]) @@index([reviewedAt])
} }
model AuditLog {
id String @id @default(cuid())
userId String?
action String
resource String?
metadata Json?
ip String?
userAgent String?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
@@index([createdAt])
}