From 80578504178d2f9a6fec98752309f491f9f87220 Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 11:32:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(rls):=20Phase=201=20#11=20=E2=80=94=20Poli?= =?UTF-8?q?cyDecision=20RLS=20+=20callsite=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 20260605000000: enable RLS + FORCE on PolicyDecision, 4 policies for app_user, organizationId+id composite index - approval-engine: wrap requestApproval findFirst+create, resolveDecision findUnique+update, waitForDecision findUnique in withOrgContext; add organizationId param to resolveDecision and waitForDecision; processTimeouts stays unwrapped (cross-org cron, BYPASSRLS) - decisions/[decisionId]/route: loadDecision to withAdminBypass, GET findUnique and DELETE update to withOrgContext, pass organizationId to resolveDecision - policies/[policyId]/route: policyDecision.count to withOrgContext - policies/[policyId]/decisions/route: findMany to withOrgContext - pending-approvals/route: agent lookup for orgId, findMany to withOrgContext - approval-engine tests: update resolveDecision and waitForDecision call signatures to match new organizationId param positions --- .../migration.sql | 28 ++++++++ .../[agentId]/pending-approvals/route.ts | 20 ++++-- src/app/api/decisions/[decisionId]/route.ts | 33 +++++---- .../policies/[policyId]/decisions/route.ts | 17 +++-- src/app/api/policies/[policyId]/route.ts | 4 +- .../__tests__/approval-engine.test.ts | 14 ++-- src/lib/governance/approval-engine.ts | 67 +++++++++++-------- 7 files changed, 122 insertions(+), 61 deletions(-) create mode 100644 prisma/migrations/20260605000000_rls_phase1_policydecision/migration.sql diff --git a/prisma/migrations/20260605000000_rls_phase1_policydecision/migration.sql b/prisma/migrations/20260605000000_rls_phase1_policydecision/migration.sql new file mode 100644 index 00000000..44e94214 --- /dev/null +++ b/prisma/migrations/20260605000000_rls_phase1_policydecision/migration.sql @@ -0,0 +1,28 @@ +-- Phase 1 #11 — PolicyDecision RLS +-- FK to ApprovalPolicy; applied immediately after Migration #10. +-- processTimeouts cross-org cron relies on DATABASE_URL BYPASSRLS (see tech-debt #6). + +CREATE INDEX IF NOT EXISTS "PolicyDecision_organizationId_id_idx" + ON "PolicyDecision" ("organizationId", "id"); + +ALTER TABLE "PolicyDecision" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "PolicyDecision" FORCE ROW LEVEL SECURITY; + +GRANT SELECT, INSERT, UPDATE, DELETE ON "PolicyDecision" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "PolicyDecision" TO admin_user; + +CREATE POLICY "pd_select" ON "PolicyDecision" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY "pd_insert" ON "PolicyDecision" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY "pd_update" ON "PolicyDecision" + FOR UPDATE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY "pd_delete" ON "PolicyDecision" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); diff --git a/src/app/api/agents/[agentId]/pending-approvals/route.ts b/src/app/api/agents/[agentId]/pending-approvals/route.ts index 8468403a..e4ce0d3e 100644 --- a/src/app/api/agents/[agentId]/pending-approvals/route.ts +++ b/src/app/api/agents/[agentId]/pending-approvals/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAgentOwner, isAuthError } from "@/lib/api/auth-guard"; import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; import { logger } from "@/lib/logger"; interface RouteParams { @@ -13,14 +14,19 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis const authResult = await requireAgentOwner(agentId, request); if (isAuthError(authResult)) return authResult; + const agent = await prisma.agent.findUnique({ where: { id: agentId }, select: { organizationId: true } }); + const orgId = agent?.organizationId ?? null; + try { - const decisions = await prisma.policyDecision.findMany({ - where: { agentId, status: "PENDING" }, - orderBy: { createdAt: "asc" }, - include: { - policy: { select: { id: true, name: true, actionPattern: true, approverIds: true, timeoutSeconds: true } }, - }, - }); + const decisions = await withOrgContext(prisma, orgId, (tx) => + tx.policyDecision.findMany({ + where: { agentId, status: "PENDING" }, + orderBy: { createdAt: "asc" }, + include: { + policy: { select: { id: true, name: true, actionPattern: true, approverIds: true, timeoutSeconds: true } }, + }, + }) + ); return NextResponse.json({ success: true, data: decisions }); } catch (error) { logger.error("GET /api/agents/[agentId]/pending-approvals error", { agentId, error }); diff --git a/src/app/api/decisions/[decisionId]/route.ts b/src/app/api/decisions/[decisionId]/route.ts index 95c917f4..5103a2db 100644 --- a/src/app/api/decisions/[decisionId]/route.ts +++ b/src/app/api/decisions/[decisionId]/route.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { requireOrgMember, requireAuth, 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"; import { resolveDecision } from "@/lib/governance/approval-engine"; @@ -16,10 +18,12 @@ const ResolveDecisionSchema = z.object({ }); async function loadDecision(decisionId: string) { - return prisma.policyDecision.findUnique({ - where: { id: decisionId }, - select: { id: true, organizationId: true, status: true }, - }); + return withAdminBypass((db) => + db.policyDecision.findUnique({ + where: { id: decisionId }, + select: { id: true, organizationId: true, status: true }, + }) + ); } export async function GET(request: NextRequest, { params }: RouteParams): Promise { @@ -32,10 +36,12 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis if (isAuthError(authResult)) return authResult; try { - const full = await prisma.policyDecision.findUnique({ - where: { id: decisionId }, - include: { policy: { select: { id: true, name: true, actionPattern: true, approverIds: true } } }, - }); + const full = await withOrgContext(prisma, decision.organizationId, (tx) => + tx.policyDecision.findUnique({ + where: { id: decisionId }, + include: { policy: { select: { id: true, name: true, actionPattern: true, approverIds: true } } }, + }) + ); return NextResponse.json({ success: true, data: full }); } catch (error) { logger.error("GET /api/decisions/[decisionId] error", { decisionId, error }); @@ -79,6 +85,7 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi decisionId, parsed.data.resolution, userAuthResult.userId, + decision.organizationId, parsed.data.resolverNote, ); return NextResponse.json({ success: true, data: resolved }); @@ -105,10 +112,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro ); } - const cancelled = await prisma.policyDecision.update({ - where: { id: decisionId }, - data: { status: "CANCELLED", resolvedAt: new Date() }, - }); + const cancelled = await withOrgContext(prisma, decision.organizationId, (tx) => + tx.policyDecision.update({ + where: { id: decisionId }, + data: { status: "CANCELLED", resolvedAt: new Date() }, + }) + ); return NextResponse.json({ success: true, data: cancelled }); } catch (error) { logger.error("DELETE /api/decisions/[decisionId] error", { decisionId, error }); diff --git a/src/app/api/policies/[policyId]/decisions/route.ts b/src/app/api/policies/[policyId]/decisions/route.ts index a9dcded6..c408f30e 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 { withOrgContext } from "@/lib/db/rls-middleware"; import { withAdminBypass } from "@/lib/api/tenant-context"; import { logger } from "@/lib/logger"; @@ -24,13 +25,15 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis if (isAuthError(authResult)) return authResult; try { - const decisions = await prisma.policyDecision.findMany({ - where: { - policyId, - ...(status && { status }), - }, - orderBy: { createdAt: "desc" }, - }); + const decisions = await withOrgContext(prisma, policy.organizationId, (tx) => + tx.policyDecision.findMany({ + where: { + policyId, + ...(status && { status }), + }, + orderBy: { createdAt: "desc" }, + }) + ); return NextResponse.json({ success: true, data: decisions }); } catch (error) { logger.error("GET /api/policies/[policyId]/decisions error", { policyId, error }); diff --git a/src/app/api/policies/[policyId]/route.ts b/src/app/api/policies/[policyId]/route.ts index 49f9a355..97fea2f4 100644 --- a/src/app/api/policies/[policyId]/route.ts +++ b/src/app/api/policies/[policyId]/route.ts @@ -104,7 +104,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro if (isAuthError(authResult)) return authResult; try { - const pendingCount = await prisma.policyDecision.count({ where: { policyId, status: "PENDING" } }); + const pendingCount = await withOrgContext(prisma, policy.organizationId, (tx) => + tx.policyDecision.count({ where: { policyId, status: "PENDING" } }) + ); if (pendingCount > 0) { return NextResponse.json( { success: false, error: `Cannot delete policy with ${pendingCount} pending decision(s).` }, diff --git a/src/lib/governance/__tests__/approval-engine.test.ts b/src/lib/governance/__tests__/approval-engine.test.ts index 345af742..2dc85097 100644 --- a/src/lib/governance/__tests__/approval-engine.test.ts +++ b/src/lib/governance/__tests__/approval-engine.test.ts @@ -180,7 +180,7 @@ describe("resolveDecision", () => { const resolved = makeDecision({ status: "APPROVED", resolvedBy: "user-1", resolvedAt: new Date() }); mockDecisionUpdate.mockResolvedValue(resolved); - const result = await resolveDecision("decision-1", "APPROVED", "user-1", "Looks good"); + const result = await resolveDecision("decision-1", "APPROVED", "user-1", "org-1", "Looks good"); expect(result.decision.status).toBe("APPROVED"); expect(mockDecisionUpdate).toHaveBeenCalledWith( @@ -194,7 +194,7 @@ describe("resolveDecision", () => { it("throws when decision is already resolved", async () => { mockDecisionFindUnique.mockResolvedValue(makeDecision({ status: "APPROVED" })); - await expect(resolveDecision("decision-1", "REJECTED", "user-1")).rejects.toThrow( + await expect(resolveDecision("decision-1", "REJECTED", "user-1", "org-1")).rejects.toThrow( "PolicyDecision decision-1 is already APPROVED", ); }); @@ -202,7 +202,7 @@ describe("resolveDecision", () => { it("throws when decision not found", async () => { mockDecisionFindUnique.mockResolvedValue(null); - await expect(resolveDecision("nonexistent", "APPROVED", "user-1")).rejects.toThrow( + await expect(resolveDecision("nonexistent", "APPROVED", "user-1", "org-1")).rejects.toThrow( "PolicyDecision nonexistent not found", ); }); @@ -271,7 +271,7 @@ describe("waitForDecision", () => { const resolved = makeDecision({ status: "APPROVED" }); mockDecisionFindUnique.mockResolvedValue(resolved); - const result = await waitForDecision("decision-1", 5000, 10); + const result = await waitForDecision("decision-1", null, 5000, 10); expect(result.status).toBe("APPROVED"); expect(mockDecisionFindUnique).toHaveBeenCalledTimes(1); @@ -282,7 +282,7 @@ describe("waitForDecision", () => { const resolved = makeDecision({ status: "REJECTED" }); mockDecisionFindUnique.mockResolvedValueOnce(pending).mockResolvedValueOnce(resolved); - const result = await waitForDecision("decision-1", 5000, 1); + const result = await waitForDecision("decision-1", null, 5000, 1); expect(result.status).toBe("REJECTED"); expect(mockDecisionFindUnique).toHaveBeenCalledTimes(2); @@ -291,12 +291,12 @@ describe("waitForDecision", () => { it("throws when max wait time exceeded", async () => { mockDecisionFindUnique.mockResolvedValue(makeDecision({ status: "PENDING" })); - await expect(waitForDecision("decision-1", 50, 10)).rejects.toThrow("waitForDecision timed out"); + await expect(waitForDecision("decision-1", null, 50, 10)).rejects.toThrow("waitForDecision timed out"); }); it("throws when decision not found", async () => { mockDecisionFindUnique.mockResolvedValue(null); - await expect(waitForDecision("decision-1", 5000, 10)).rejects.toThrow("PolicyDecision decision-1 not found"); + await expect(waitForDecision("decision-1", null, 5000, 10)).rejects.toThrow("PolicyDecision decision-1 not found"); }); }); diff --git a/src/lib/governance/approval-engine.ts b/src/lib/governance/approval-engine.ts index e6420f27..2437b63d 100644 --- a/src/lib/governance/approval-engine.ts +++ b/src/lib/governance/approval-engine.ts @@ -64,9 +64,11 @@ export async function requestApproval( action: string, context?: Record, ): Promise { - const existing = await prisma.policyDecision.findFirst({ - where: { policyId, agentId, action, status: "PENDING" }, - }); + const existing = await withOrgContext(prisma, organizationId, (tx) => + tx.policyDecision.findFirst({ + where: { policyId, agentId, action, status: "PENDING" }, + }) + ); if (existing) { return { decision: existing, alreadyPending: true }; @@ -84,17 +86,19 @@ export async function requestApproval( ? new Date(Date.now() + policy.timeoutSeconds * 1000) : null; - const decision = await prisma.policyDecision.create({ - data: { - policyId, - agentId, - organizationId, - action, - context: context !== undefined ? (context as Prisma.InputJsonValue) : Prisma.JsonNull, - status: "PENDING", - expiresAt, - }, - }); + const decision = await withOrgContext(prisma, organizationId, (tx) => + tx.policyDecision.create({ + data: { + policyId, + agentId, + organizationId, + action, + context: context !== undefined ? (context as Prisma.InputJsonValue) : Prisma.JsonNull, + status: "PENDING", + expiresAt, + }, + }) + ); logger.info("Approval requested", { decisionId: decision.id, agentId, action, policyId }); @@ -109,23 +113,28 @@ export async function resolveDecision( decisionId: string, resolution: "APPROVED" | "REJECTED", resolvedBy: string, + organizationId: string, resolverNote?: string, ): Promise { - const existing = await prisma.policyDecision.findUnique({ where: { id: decisionId } }); + const existing = await withOrgContext(prisma, organizationId, (tx) => + tx.policyDecision.findUnique({ where: { id: decisionId } }) + ); if (!existing) throw new Error(`PolicyDecision ${decisionId} not found`); if (existing.status !== "PENDING") { throw new Error(`PolicyDecision ${decisionId} is already ${existing.status}`); } - const decision = await prisma.policyDecision.update({ - where: { id: decisionId }, - data: { - status: resolution, - resolvedBy, - resolvedAt: new Date(), - resolverNote: resolverNote ?? null, - }, - }); + const decision = await withOrgContext(prisma, organizationId, (tx) => + tx.policyDecision.update({ + where: { id: decisionId }, + data: { + status: resolution, + resolvedBy, + resolvedAt: new Date(), + resolverNote: resolverNote ?? null, + }, + }) + ); logger.info("Decision resolved", { decisionId, resolution, resolvedBy }); @@ -137,8 +146,9 @@ export async function resolveDecision( * 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. + * DATABASE_URL connection having BYPASSRLS (Phase 0b) so both the + * PolicyDecision findMany and ApprovalPolicy include resolve across all tenants. + * See tech-debt #6 for pre-flag-flip verification steps. */ export async function processTimeouts(): Promise { const expired = await prisma.policyDecision.findMany({ @@ -176,13 +186,16 @@ export async function processTimeouts(): Promise { */ export async function waitForDecision( decisionId: string, + organizationId: string | null = null, maxWaitMs = 300_000, pollIntervalMs = 3_000, ): Promise { const deadline = Date.now() + maxWaitMs; while (Date.now() < deadline) { - const decision = await prisma.policyDecision.findUnique({ where: { id: decisionId } }); + const decision = await withOrgContext(prisma, organizationId, (tx) => + tx.policyDecision.findUnique({ where: { id: decisionId } }) + ); if (!decision) throw new Error(`PolicyDecision ${decisionId} not found`); if (decision.status !== "PENDING") return decision;