fix(decisions): enforce DSGVO tenant isolation and RBAC on decisions API

- GET /api/decisions: Add requirePermission('decisions:read'), use
  withTenantDb() for RLS enforcement, add application-level tenant
  filter (own tenant OR published+anonymized)
- POST /api/decisions: Add requirePermission('decisions:write'), use
  withTenantDb(), set tenantId from authenticated session context
  instead of accepting it from request body (prevents tenant spoofing)

Addresses DSGVO Art. 32 (security of processing) and Art. 5(1)(f)
(integrity and confidentiality).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 00:45:55 +00:00
parent b837f4a71e
commit 0daf65ce91

View File

@@ -1,12 +1,22 @@
// GET /api/decisions — list/search decisions with filters and FTS
// POST /api/decisions — create a new decision
//
// DSGVO Art. 32 + Art. 5(1)(f): Tenant isolation via withTenantDb()
// and RBAC via requirePermission(). Decisions visible cross-tenant
// only when isPublished=true AND isAnonymized=true.
import { db } from "@/lib/db";
import { withTenantDb } from "@/lib/db";
import { decisions } from "@/lib/db/schema";
import { eq, and, desc, asc, sql, ilike, type SQL } from "drizzle-orm";
import { eq, and, or, desc, sql, ilike, type SQL } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { logAuditEvent } from "@/lib/auth/audit";
import { requirePermission } from "@/lib/auth/rbac";
export async function GET(request: NextRequest) {
const auth = await requirePermission('decisions:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const url = new URL(request.url);
// Pagination
@@ -24,8 +34,17 @@ export async function GET(request: NextRequest) {
const dateTo = url.searchParams.get("dateTo");
const domain = url.searchParams.get("domain");
// Build WHERE conditions
const conditions: SQL[] = [];
// Build WHERE conditions — tenant isolation enforced at application level:
// Own tenant's decisions OR published+anonymized decisions (public precedent data)
const tenantFilter = or(
eq(decisions.tenantId, ctx.tenantId),
and(
eq(decisions.isPublished, true),
eq(decisions.isAnonymized, true),
),
)!;
const conditions: SQL[] = [tenantFilter];
if (caseReference) {
conditions.push(ilike(decisions.caseReference, `%${caseReference}%`));
@@ -62,34 +81,49 @@ export async function GET(request: NextRequest) {
orderBy = sql`ts_rank(${tsVector}, ${tsQuery}) DESC`;
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const where = and(...conditions);
const [results, countResult] = await Promise.all([
db
.select()
.from(decisions)
.where(where)
.orderBy(orderBy)
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(decisions)
.where(where),
]);
// Use withTenantDb to set app.tenant_id for RLS enforcement (defense-in-depth)
const { results, total } = await withTenantDb(ctx.tenantId, async (tenantDb) => {
const [rows, countResult] = await Promise.all([
tenantDb
.select()
.from(decisions)
.where(where)
.orderBy(orderBy)
.limit(limit)
.offset(offset),
tenantDb
.select({ count: sql<number>`count(*)::int` })
.from(decisions)
.where(where),
]);
return { results: rows, total: countResult[0].count };
});
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
ctx, 'list', 'decision', null, { q, court, limit, offset }, ip,
);
return Response.json({
decisions: results,
pagination: {
total: countResult[0].count,
total,
limit,
offset,
hasMore: offset + limit < countResult[0].count,
hasMore: offset + limit < total,
},
});
}
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
const auth = await requirePermission('decisions:write');
if ('response' in auth) return auth.response;
const { ctx } = auth;
let body: Record<string, unknown>;
try {
body = await request.json();
@@ -98,7 +132,6 @@ export async function POST(request: Request) {
}
const {
tenantId,
type,
caseReference,
decisionDate,
@@ -133,29 +166,40 @@ export async function POST(request: Request) {
);
}
const [created] = await db
.insert(decisions)
.values({
tenantId: tenantId ?? null,
type,
caseReference: caseReference ?? null,
decisionDate,
court,
tribunalId: tribunalId ?? null,
chamber: chamber ?? null,
headnote: headnote ?? null,
tenor: tenor ?? null,
facts: facts ?? null,
reasoning: reasoning ?? null,
fullText: fullText ?? null,
domains: domains ?? [],
keywords: keywords ?? [],
publicationSource: publicationSource ?? null,
isPublished: isPublished ?? false,
isAnonymized: isAnonymized ?? false,
metadata: metadata ?? null,
})
.returning();
// tenantId is set from authenticated context, NEVER from request body (DSGVO Art. 32)
const created = await withTenantDb(ctx.tenantId, async (tenantDb) => {
const [row] = await tenantDb
.insert(decisions)
.values({
tenantId: ctx.tenantId,
type,
caseReference: caseReference ?? null,
decisionDate,
court,
tribunalId: tribunalId ?? null,
chamber: chamber ?? null,
headnote: headnote ?? null,
tenor: tenor ?? null,
facts: facts ?? null,
reasoning: reasoning ?? null,
fullText: fullText ?? null,
domains: domains ?? [],
keywords: keywords ?? [],
publicationSource: publicationSource ?? null,
isPublished: isPublished ?? false,
isAnonymized: isAnonymized ?? false,
metadata: metadata ?? null,
})
.returning();
return row;
});
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
ctx, 'create', 'decision', created.id, { court, caseReference }, ip,
);
return Response.json({ decision: created }, { status: 201 });
}