From 166056f84883bc31716639a1a37e7d70a03b7df4 Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 10:33:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(rls):=20Phase=201=20#10=20=E2=80=94=20Appr?= =?UTF-8?q?ovalPolicy=20RLS=20+=20callsite=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 20260604000000: TENANT_DIRECT RLS on ApprovalPolicy. Pre-auth lookups (loadPolicy, decisions pre-auth) use withAdminBypass — same pattern as loadDepartment/loadGoal from migrations #4/#5. CRUD routes use withOrgContext with org ID from loadPolicy result. checkPolicies gains optional organizationId param; requestApproval wraps the policy findUnique in withOrgContext. processTimeouts unchanged — cross-org cron relies on DATABASE_URL BYPASSRLS. --- .../migration.sql | 43 ++++++++++++++++ .../policies/[policyId]/decisions/route.ts | 11 ++-- src/app/api/policies/[policyId]/route.ts | 50 +++++++++++-------- src/app/api/policies/route.ts | 43 +++++++++------- src/lib/governance/approval-engine.ts | 28 +++++++---- 5 files changed, 123 insertions(+), 52 deletions(-) create mode 100644 prisma/migrations/20260604000000_rls_phase1_approvalpolicy/migration.sql diff --git a/prisma/migrations/20260604000000_rls_phase1_approvalpolicy/migration.sql b/prisma/migrations/20260604000000_rls_phase1_approvalpolicy/migration.sql new file mode 100644 index 00000000..971b02e4 --- /dev/null +++ b/prisma/migrations/20260604000000_rls_phase1_approvalpolicy/migration.sql @@ -0,0 +1,43 @@ +-- RLS Phase 1 #10 — ApprovalPolicy (TENANT_DIRECT) +-- Board governance table. Admin/cron ops (processTimeouts) rely on +-- DATABASE_URL BYPASSRLS and do NOT use withOrgContext. + +-- 1. Composite index for RLS performance (CRITICAL) +CREATE INDEX IF NOT EXISTS "ApprovalPolicy_organizationId_id_idx" + ON "ApprovalPolicy" ("organizationId", "id"); + +-- 2. Enable RLS with FORCE (so table owner also obeys policies) +ALTER TABLE "ApprovalPolicy" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ApprovalPolicy" FORCE ROW LEVEL SECURITY; + +-- 3. Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON "ApprovalPolicy" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "ApprovalPolicy" TO admin_user; + +-- 4. Policies (admin_user has BYPASSRLS — no policies needed for it) +CREATE POLICY approvalpolicy_select ON "ApprovalPolicy" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY approvalpolicy_insert ON "ApprovalPolicy" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY approvalpolicy_update ON "ApprovalPolicy" + FOR UPDATE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)) + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY approvalpolicy_delete ON "ApprovalPolicy" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +-- ========================================================================= +-- Rollback (uncomment to revert) +-- ========================================================================= +-- DROP POLICY IF EXISTS approvalpolicy_select ON "ApprovalPolicy"; +-- DROP POLICY IF EXISTS approvalpolicy_insert ON "ApprovalPolicy"; +-- DROP POLICY IF EXISTS approvalpolicy_update ON "ApprovalPolicy"; +-- DROP POLICY IF EXISTS approvalpolicy_delete ON "ApprovalPolicy"; +-- ALTER TABLE "ApprovalPolicy" DISABLE ROW LEVEL SECURITY; +-- DROP INDEX IF EXISTS "ApprovalPolicy_organizationId_id_idx"; diff --git a/src/app/api/policies/[policyId]/decisions/route.ts b/src/app/api/policies/[policyId]/decisions/route.ts index fe8d0b6c..a9dcded6 100644 --- a/src/app/api/policies/[policyId]/decisions/route.ts +++ b/src/app/api/policies/[policyId]/decisions/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard"; import { prisma } from "@/lib/prisma"; +import { withAdminBypass } from "@/lib/api/tenant-context"; import { logger } from "@/lib/logger"; interface RouteParams { @@ -11,10 +12,12 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis const { policyId } = await params; const status = request.nextUrl.searchParams.get("status") ?? undefined; - const policy = await prisma.approvalPolicy.findUnique({ - where: { id: policyId }, - select: { id: true, organizationId: true }, - }); + const policy = await withAdminBypass((db) => + db.approvalPolicy.findUnique({ + where: { id: policyId }, + select: { id: true, organizationId: true }, + }) + ); if (!policy) return NextResponse.json({ success: false, error: "Policy not found" }, { status: 404 }); const authResult = await requireOrgMember(policy.organizationId, request); diff --git a/src/app/api/policies/[policyId]/route.ts b/src/app/api/policies/[policyId]/route.ts index 9541b32c..49f9a355 100644 --- a/src/app/api/policies/[policyId]/route.ts +++ b/src/app/api/policies/[policyId]/route.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard"; import { parseBodyWithLimit } from "@/lib/api/body-limit"; import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; +import { withAdminBypass } from "@/lib/api/tenant-context"; import { logger } from "@/lib/logger"; interface RouteParams { @@ -19,10 +21,12 @@ const UpdatePolicySchema = z.object({ }); async function loadPolicy(policyId: string) { - return prisma.approvalPolicy.findUnique({ - where: { id: policyId }, - select: { id: true, organizationId: true }, - }); + return withAdminBypass((db) => + db.approvalPolicy.findUnique({ + where: { id: policyId }, + select: { id: true, organizationId: true }, + }) + ); } export async function GET(request: NextRequest, { params }: RouteParams): Promise { @@ -35,10 +39,12 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis if (isAuthError(authResult)) return authResult; try { - const full = await prisma.approvalPolicy.findUnique({ - where: { id: policyId }, - include: { _count: { select: { decisions: true } } }, - }); + const full = await withOrgContext(prisma, policy.organizationId, (tx) => + tx.approvalPolicy.findUnique({ + where: { id: policyId }, + include: { _count: { select: { decisions: true } } }, + }) + ); return NextResponse.json({ success: true, data: full }); } catch (error) { logger.error("GET /api/policies/[policyId] error", { policyId, error }); @@ -68,17 +74,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams): Prom } try { - const updated = await prisma.approvalPolicy.update({ - where: { id: policyId }, - data: { - ...(parsed.data.name !== undefined && { name: parsed.data.name }), - ...(parsed.data.actionPattern !== undefined && { actionPattern: parsed.data.actionPattern }), - ...(parsed.data.approverIds !== undefined && { approverIds: parsed.data.approverIds }), - ...(parsed.data.timeoutSeconds !== undefined && { timeoutSeconds: parsed.data.timeoutSeconds }), - ...(parsed.data.timeoutApprove !== undefined && { timeoutApprove: parsed.data.timeoutApprove }), - ...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }), - }, - }); + const updated = await withOrgContext(prisma, policy.organizationId, (tx) => + tx.approvalPolicy.update({ + where: { id: policyId }, + data: { + ...(parsed.data.name !== undefined && { name: parsed.data.name }), + ...(parsed.data.actionPattern !== undefined && { actionPattern: parsed.data.actionPattern }), + ...(parsed.data.approverIds !== undefined && { approverIds: parsed.data.approverIds }), + ...(parsed.data.timeoutSeconds !== undefined && { timeoutSeconds: parsed.data.timeoutSeconds }), + ...(parsed.data.timeoutApprove !== undefined && { timeoutApprove: parsed.data.timeoutApprove }), + ...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }), + }, + }) + ); return NextResponse.json({ success: true, data: updated }); } catch (error) { logger.error("PATCH /api/policies/[policyId] error", { policyId, error }); @@ -104,7 +112,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro ); } - await prisma.approvalPolicy.delete({ where: { id: policyId } }); + await withOrgContext(prisma, policy.organizationId, (tx) => + tx.approvalPolicy.delete({ where: { id: policyId } }) + ); return NextResponse.json({ success: true, data: null }); } catch (error) { logger.error("DELETE /api/policies/[policyId] error", { policyId, error }); diff --git a/src/app/api/policies/route.ts b/src/app/api/policies/route.ts index 1f679e5b..c34cccd8 100644 --- a/src/app/api/policies/route.ts +++ b/src/app/api/policies/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard"; import { parseBodyWithLimit } from "@/lib/api/body-limit"; import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; import { logger } from "@/lib/logger"; const CreatePolicySchema = z.object({ @@ -27,14 +28,16 @@ export async function GET(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const policies = await prisma.approvalPolicy.findMany({ - where: { - organizationId: orgId, - ...(agentId && { agentId }), - }, - orderBy: { createdAt: "desc" }, - include: { _count: { select: { decisions: true } } }, - }); + const policies = await withOrgContext(prisma, orgId, (tx) => + tx.approvalPolicy.findMany({ + where: { + organizationId: orgId, + ...(agentId && { agentId }), + }, + orderBy: { createdAt: "desc" }, + include: { _count: { select: { decisions: true } } }, + }) + ); return NextResponse.json({ success: true, data: policies }); } catch (error) { @@ -67,17 +70,19 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ success: false, error: "Agent not found" }, { status: 404 }); } - const policy = await prisma.approvalPolicy.create({ - data: { - agentId, - organizationId, - name, - actionPattern, - approverIds, - timeoutSeconds: timeoutSeconds ?? null, - timeoutApprove: timeoutApprove ?? false, - }, - }); + const policy = await withOrgContext(prisma, organizationId, (tx) => + tx.approvalPolicy.create({ + data: { + agentId, + organizationId, + name, + actionPattern, + approverIds, + timeoutSeconds: timeoutSeconds ?? null, + timeoutApprove: timeoutApprove ?? false, + }, + }) + ); return NextResponse.json({ success: true, data: policy }, { status: 201 }); } catch (error) { diff --git a/src/lib/governance/approval-engine.ts b/src/lib/governance/approval-engine.ts index 915bcfb1..e6420f27 100644 --- a/src/lib/governance/approval-engine.ts +++ b/src/lib/governance/approval-engine.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; import { logger } from "@/lib/logger"; import { Prisma } from "@/generated/prisma"; import type { ApprovalPolicy, PolicyDecision } from "@/generated/prisma"; @@ -30,16 +31,19 @@ export interface ProcessTimeoutsResult { export async function checkPolicies( agentId: string, action: string, + organizationId?: string | null, ): Promise { try { - const policy = await prisma.approvalPolicy.findFirst({ - where: { - agentId, - isActive: true, - actionPattern: { in: [action, "*"] }, - }, - orderBy: { actionPattern: "asc" }, // exact match (non-"*") sorts before "*" - }); + const policy = await withOrgContext(prisma, organizationId ?? null, (tx) => + tx.approvalPolicy.findFirst({ + where: { + agentId, + isActive: true, + actionPattern: { in: [action, "*"] }, + }, + orderBy: { actionPattern: "asc" }, // exact match (non-"*") sorts before "*" + }) + ); return { requiresApproval: policy !== null, policy }; } catch (error) { @@ -68,7 +72,9 @@ export async function requestApproval( return { decision: existing, alreadyPending: true }; } - const policy = await prisma.approvalPolicy.findUnique({ where: { id: policyId } }); + const policy = await withOrgContext(prisma, organizationId, (tx) => + tx.approvalPolicy.findUnique({ where: { id: policyId } }) + ); if (!policy) { throw new Error(`ApprovalPolicy ${policyId} not found`); } @@ -129,6 +135,10 @@ export async function resolveDecision( /** * Finds all PENDING decisions whose expiresAt is in the past and resolves them * according to each policy's timeoutApprove flag. + * + * Cross-org cron — deliberately uses no withOrgContext. Relies on the + * DATABASE_URL connection having BYPASSRLS (Phase 0b) so the ApprovalPolicy + * include resolves across all tenants. */ export async function processTimeouts(): Promise { const expired = await prisma.policyDecision.findMany({