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-3-data-portability: done
4-4-explicit-ai-consent: done
4-5-eu-data-residency: backlog
4-6-sso-saml-audit-logging: backlog
4-5-eu-data-residency: done
4-6-sso-saml-audit-logging: done
epic-4-retrospective: optional
epic-5: in-progress
5-1-nextgen-editor: done

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -107,6 +108,14 @@ export async function POST(req: NextRequest) {
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.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' })
}

View File

@@ -12,6 +12,7 @@ import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
import path from 'path'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
export const maxDuration = 60
@@ -433,6 +434,13 @@ Focus ONLY on this note unless asked otherwise.`
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
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 prisma from '@/lib/prisma';
import { buildAuthProviders } from '@/lib/auth-providers';
import { logAuditEvent } from '@/lib/audit-log';
export const { auth, signIn, signOut, handlers } = NextAuth({
...authConfig,
@@ -12,12 +13,14 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
async createUser({ user }) {
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) {
logAuditEvent({ userId: user.id, action: 'USER_CREATED', metadata: { email: user.email } });
return;
}
await prisma.user.update({
where: { id: user.id },
data: { role: 'ADMIN', emailVerified: new Date() },
});
logAuditEvent({ userId: user.id, action: 'USER_CREATED', metadata: { email: user.email, role: 'ADMIN' } });
},
async signOut(message) {
const userId =
@@ -29,6 +32,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
if (!userId) return;
logAuditEvent({ userId, action: 'LOGOUT' });
await prisma.$transaction([
prisma.user.update({
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;
},
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) {
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);
}

View File

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

View File

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