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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user