From 608865949732fbba29a934f1822d9c86fd78b8aa Mon Sep 17 00:00:00 2001 From: buky Date: Sat, 23 May 2026 23:58:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(rls):=20Phase=201=20#5=20=E2=80=94=20Goal?= =?UTF-8?q?=20RLS=20+=20callsite=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 20260530000000: enable RLS + FORCE on Goal, composite index on (organizationId, id) — note: (organizationId, status) already exists; SELECT/INSERT/UPDATE/DELETE policies for app_user, grants to app_user + admin_user - goals/route.ts: wrap findMany(GET) and create(POST) with withOrgContext - goals/[goalId]/route.ts: loadGoal() uses withAdminBypass (pre-auth); GET findUnique (incl. CompanyMission JOIN), PATCH update, DELETE count + delete all wrapped with withOrgContext(goal.organizationId) - agents/[agentId]/goals/route.ts: goal existence check uses withAdminBypass (agent's orgId not in scope for this check) - lib/templates/template-engine.ts: goal.create in importTemplate wrapped with withOrgContext(organizationId) — called from API routes only Cross-table JOIN: GET /goals/[goalId] includes mission (CompanyMission, already RLS-enforced) — both sub-queries run in the same withOrgContext transaction, same app.current_org_id. Safe. RLS_ENFORCEMENT_ENABLED=false; wrapping is a no-op until flag flip. --- .../migration.sql | 46 +++++++++++++ src/app/api/agents/[agentId]/goals/route.ts | 5 +- src/app/api/goals/[goalId]/route.ts | 64 +++++++++++-------- src/app/api/goals/route.ts | 51 ++++++++------- src/lib/templates/template-engine.ts | 21 +++--- 5 files changed, 128 insertions(+), 59 deletions(-) create mode 100644 prisma/migrations/20260530000000_rls_phase1_goal/migration.sql diff --git a/prisma/migrations/20260530000000_rls_phase1_goal/migration.sql b/prisma/migrations/20260530000000_rls_phase1_goal/migration.sql new file mode 100644 index 00000000..2a088675 --- /dev/null +++ b/prisma/migrations/20260530000000_rls_phase1_goal/migration.sql @@ -0,0 +1,46 @@ +-- Phase 1 Migration #5: Goal RLS (TENANT_DIRECT) +-- Apply order: #5 of 14 — goals hierarchy; missionId FK → CompanyMission (already RLS-enforced) +-- Runbook: docs/rls-phase-1-cutover-runbook.md §2.3 +-- Template: skills/rls-rollout/templates/tenant-direct.sql.template + +-- 1. Composite index for RLS performance +-- Note: @@index([organizationId, status]) already exists as Goal_organizationId_status_idx; +-- this adds the (organizationId, id) index required by the standard RLS policy template. +CREATE INDEX IF NOT EXISTS "Goal_organizationId_id_idx" + ON "Goal" ("organizationId", "id"); + +-- 2. Enable RLS +ALTER TABLE "Goal" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Goal" FORCE ROW LEVEL SECURITY; + +-- 3. Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON "Goal" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "Goal" TO admin_user; + +-- 4. Policies (admin_user has BYPASSRLS — no policies needed for it) +CREATE POLICY goal_select ON "Goal" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY goal_insert ON "Goal" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY goal_update ON "Goal" + 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 goal_delete ON "Goal" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +-- ========================================================================= +-- Rollback (commented — uncomment to revert) +-- ========================================================================= +-- DROP POLICY IF EXISTS goal_select ON "Goal"; +-- DROP POLICY IF EXISTS goal_insert ON "Goal"; +-- DROP POLICY IF EXISTS goal_update ON "Goal"; +-- DROP POLICY IF EXISTS goal_delete ON "Goal"; +-- ALTER TABLE "Goal" DISABLE ROW LEVEL SECURITY; +-- DROP INDEX IF EXISTS "Goal_organizationId_id_idx"; diff --git a/src/app/api/agents/[agentId]/goals/route.ts b/src/app/api/agents/[agentId]/goals/route.ts index aac88bad..cdbbca3f 100644 --- a/src/app/api/agents/[agentId]/goals/route.ts +++ b/src/app/api/agents/[agentId]/goals/route.ts @@ -4,6 +4,7 @@ import { requireAgentOwner, isAuthError } from "@/lib/api/auth-guard"; import { parseBodyWithLimit } from "@/lib/api/body-limit"; import { prisma } from "@/lib/prisma"; import { logger } from "@/lib/logger"; +import { withAdminBypass } from "@/lib/api/tenant-context"; import { getAgentGoals } from "@/lib/goals/goal-context"; interface RouteParams { @@ -61,7 +62,9 @@ export async function POST( const { goalId, role = "CONTRIBUTOR" } = parsed.data; try { - const goal = await prisma.goal.findUnique({ where: { id: goalId }, select: { id: true } }); + const goal = await withAdminBypass((db) => + db.goal.findUnique({ where: { id: goalId }, select: { id: true } }) + ); if (!goal) { return NextResponse.json({ success: false, error: "Goal not found" }, { status: 404 }); } diff --git a/src/app/api/goals/[goalId]/route.ts b/src/app/api/goals/[goalId]/route.ts index ce812915..8fba9729 100644 --- a/src/app/api/goals/[goalId]/route.ts +++ b/src/app/api/goals/[goalId]/route.ts @@ -4,6 +4,8 @@ import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard"; import { parseBodyWithLimit } from "@/lib/api/body-limit"; import { prisma } from "@/lib/prisma"; import { logger } from "@/lib/logger"; +import { withOrgContext } from "@/lib/db/rls-middleware"; +import { withAdminBypass } from "@/lib/api/tenant-context"; interface RouteParams { params: Promise<{ goalId: string }>; @@ -19,10 +21,12 @@ const UpdateGoalSchema = z.object({ }); async function loadGoal(goalId: string) { - return prisma.goal.findUnique({ - where: { id: goalId }, - select: { id: true, organizationId: true }, - }); + return withAdminBypass((db) => + db.goal.findUnique({ + where: { id: goalId }, + select: { id: true, organizationId: true }, + }) + ); } export async function GET( @@ -38,16 +42,18 @@ export async function GET( if (isAuthError(authResult)) return authResult; try { - const full = await prisma.goal.findUnique({ - where: { id: goalId }, - include: { - childGoals: { select: { id: true, title: true, status: true, priority: true } }, - agentLinks: { - include: { agent: { select: { id: true, name: true } } }, + const full = await withOrgContext(prisma, goal.organizationId, (tx) => + tx.goal.findUnique({ + where: { id: goalId }, + include: { + childGoals: { select: { id: true, title: true, status: true, priority: true } }, + agentLinks: { + include: { agent: { select: { id: true, name: true } } }, + }, + mission: { select: { id: true, statement: true } }, }, - mission: { select: { id: true, statement: true } }, - }, - }); + }) + ); return NextResponse.json({ success: true, data: full }); } catch (error) { @@ -81,17 +87,19 @@ export async function PATCH( } try { - const updated = await prisma.goal.update({ - where: { id: goalId }, - data: { - ...(parsed.data.title !== undefined && { title: parsed.data.title }), - ...(parsed.data.description !== undefined && { description: parsed.data.description }), - ...(parsed.data.successMetric !== undefined && { successMetric: parsed.data.successMetric }), - ...(parsed.data.targetDate !== undefined && { targetDate: parsed.data.targetDate ? new Date(parsed.data.targetDate) : null }), - ...(parsed.data.status !== undefined && { status: parsed.data.status }), - ...(parsed.data.priority !== undefined && { priority: parsed.data.priority }), - }, - }); + const updated = await withOrgContext(prisma, goal.organizationId, (tx) => + tx.goal.update({ + where: { id: goalId }, + data: { + ...(parsed.data.title !== undefined && { title: parsed.data.title }), + ...(parsed.data.description !== undefined && { description: parsed.data.description }), + ...(parsed.data.successMetric !== undefined && { successMetric: parsed.data.successMetric }), + ...(parsed.data.targetDate !== undefined && { targetDate: parsed.data.targetDate ? new Date(parsed.data.targetDate) : null }), + ...(parsed.data.status !== undefined && { status: parsed.data.status }), + ...(parsed.data.priority !== undefined && { priority: parsed.data.priority }), + }, + }) + ); return NextResponse.json({ success: true, data: updated }); } catch (error) { @@ -113,7 +121,9 @@ export async function DELETE( if (isAuthError(authResult)) return authResult; try { - const childCount = await prisma.goal.count({ where: { parentGoalId: goalId } }); + const childCount = await withOrgContext(prisma, goal.organizationId, (tx) => + tx.goal.count({ where: { parentGoalId: goalId } }) + ); if (childCount > 0) { return NextResponse.json( { success: false, error: `Cannot delete goal with ${childCount} child goal(s). Remove children first.` }, @@ -121,7 +131,9 @@ export async function DELETE( ); } - await prisma.goal.delete({ where: { id: goalId } }); + await withOrgContext(prisma, goal.organizationId, (tx) => + tx.goal.delete({ where: { id: goalId } }) + ); return NextResponse.json({ success: true, data: null }); } catch (error) { logger.error("DELETE /api/goals/[goalId] error", { goalId, error }); diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 90347a6e..a32da606 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -4,6 +4,7 @@ import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard"; import { parseBodyWithLimit } from "@/lib/api/body-limit"; import { prisma } from "@/lib/prisma"; import { logger } from "@/lib/logger"; +import { withOrgContext } from "@/lib/db/rls-middleware"; const CreateGoalSchema = z.object({ organizationId: z.string().cuid(), @@ -29,17 +30,19 @@ export async function GET(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const goals = await prisma.goal.findMany({ - where: { - organizationId: orgId, - ...(status && { status }), - ...(parentGoalId !== undefined && { parentGoalId: parentGoalId || null }), - }, - orderBy: [{ priority: "desc" }, { createdAt: "asc" }], - include: { - _count: { select: { childGoals: true, agentLinks: true } }, - }, - }); + const goals = await withOrgContext(prisma, orgId, (tx) => + tx.goal.findMany({ + where: { + organizationId: orgId, + ...(status && { status }), + ...(parentGoalId !== undefined && { parentGoalId: parentGoalId || null }), + }, + orderBy: [{ priority: "desc" }, { createdAt: "asc" }], + include: { + _count: { select: { childGoals: true, agentLinks: true } }, + }, + }) + ); return NextResponse.json({ success: true, data: goals }); } catch (error) { @@ -67,18 +70,20 @@ export async function POST(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const goal = await prisma.goal.create({ - data: { - organizationId, - title, - description, - successMetric, - targetDate: targetDate ? new Date(targetDate) : null, - parentGoalId: parentGoalId ?? null, - priority: priority ?? 50, - missionId: missionId ?? null, - }, - }); + const goal = await withOrgContext(prisma, organizationId, (tx) => + tx.goal.create({ + data: { + organizationId, + title, + description, + successMetric, + targetDate: targetDate ? new Date(targetDate) : null, + parentGoalId: parentGoalId ?? null, + priority: priority ?? 50, + missionId: missionId ?? null, + }, + }) + ); return NextResponse.json({ success: true, data: goal }, { status: 201 }); } catch (error) { diff --git a/src/lib/templates/template-engine.ts b/src/lib/templates/template-engine.ts index 5e59d11d..20790069 100644 --- a/src/lib/templates/template-engine.ts +++ b/src/lib/templates/template-engine.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; import { logger } from "@/lib/logger"; export interface TemplatePayload { @@ -277,15 +278,17 @@ export async function importTemplate( } for (const goal of payload.goals) { - const created = await prisma.goal.create({ - data: { - organizationId, - title: goal.title, - description: goal.description, - successMetric: goal.successMetric, - priority: goal.priority, - }, - }); + const created = await withOrgContext(prisma, organizationId, (tx) => + tx.goal.create({ + data: { + organizationId, + title: goal.title, + description: goal.description, + successMetric: goal.successMetric, + priority: goal.priority, + }, + }) + ); await prisma.agentGoalLink.create({ data: { agentId: agent.id, goalId: created.id, role: "CONTRIBUTOR" }, });