From 2a5360fd8956804994bf4824434484448a5caef4 Mon Sep 17 00:00:00 2001 From: buky Date: Sat, 23 May 2026 22:53:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(rls):=20Phase=201=20#3=20=E2=80=94=20Compa?= =?UTF-8?q?nyMission=20RLS=20+=20callsite=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 20260528000000: enable RLS + FORCE on CompanyMission, composite index on (organizationId, id), SELECT/INSERT/UPDATE/DELETE policies for app_user, grants to app_user + admin_user - Wrap prisma.companyMission.findUnique in GET /api/mission with withOrgContext - Wrap prisma.companyMission.upsert in POST/PUT /api/mission with withOrgContext - Wrap getMissionForOrg() in goal-context.ts with withOrgContext (explicit orgId required — called from BullMQ/heartbeat workers where ALS is empty) RLS_ENFORCEMENT_ENABLED=false; wrapping is a no-op until flag flip. --- .../migration.sql | 44 +++++++++++++++++++ src/app/api/mission/route.ts | 17 ++++--- src/lib/goals/goal-context.ts | 5 ++- 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260528000000_rls_phase1_companymission/migration.sql diff --git a/prisma/migrations/20260528000000_rls_phase1_companymission/migration.sql b/prisma/migrations/20260528000000_rls_phase1_companymission/migration.sql new file mode 100644 index 00000000..c75d580d --- /dev/null +++ b/prisma/migrations/20260528000000_rls_phase1_companymission/migration.sql @@ -0,0 +1,44 @@ +-- Phase 1 Migration #3: CompanyMission RLS (TENANT_DIRECT) +-- Apply order: #3 of 14 — internal config table; 1:1 with org +-- 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 +CREATE INDEX IF NOT EXISTS "CompanyMission_organizationId_id_idx" + ON "CompanyMission" ("organizationId", "id"); + +-- 2. Enable RLS +ALTER TABLE "CompanyMission" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "CompanyMission" FORCE ROW LEVEL SECURITY; + +-- 3. Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON "CompanyMission" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "CompanyMission" TO admin_user; + +-- 4. Policies (admin_user has BYPASSRLS — no policies needed for it) +CREATE POLICY companymission_select ON "CompanyMission" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY companymission_insert ON "CompanyMission" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY companymission_update ON "CompanyMission" + 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 companymission_delete ON "CompanyMission" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +-- ========================================================================= +-- Rollback (commented — uncomment to revert) +-- ========================================================================= +-- DROP POLICY IF EXISTS companymission_select ON "CompanyMission"; +-- DROP POLICY IF EXISTS companymission_insert ON "CompanyMission"; +-- DROP POLICY IF EXISTS companymission_update ON "CompanyMission"; +-- DROP POLICY IF EXISTS companymission_delete ON "CompanyMission"; +-- ALTER TABLE "CompanyMission" DISABLE ROW LEVEL SECURITY; +-- DROP INDEX IF EXISTS "CompanyMission_organizationId_id_idx"; diff --git a/src/app/api/mission/route.ts b/src/app/api/mission/route.ts index 214f2abd..141bc4b9 100644 --- a/src/app/api/mission/route.ts +++ b/src/app/api/mission/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 UpsertMissionSchema = z.object({ @@ -23,7 +24,9 @@ export async function GET(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const mission = await prisma.companyMission.findUnique({ where: { organizationId: orgId } }); + const mission = await withOrgContext(prisma, orgId, (tx) => + tx.companyMission.findUnique({ where: { organizationId: orgId } }) + ); return NextResponse.json({ success: true, data: mission }); } catch (error) { logger.error("GET /api/mission error", { orgId, error }); @@ -58,11 +61,13 @@ async function upsertMission(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const mission = await prisma.companyMission.upsert({ - where: { organizationId }, - update: { statement, vision: vision ?? null, values: values ?? [] }, - create: { organizationId, statement, vision: vision ?? null, values: values ?? [] }, - }); + const mission = await withOrgContext(prisma, organizationId, (tx) => + tx.companyMission.upsert({ + where: { organizationId }, + update: { statement, vision: vision ?? null, values: values ?? [] }, + create: { organizationId, statement, vision: vision ?? null, values: values ?? [] }, + }) + ); return NextResponse.json({ success: true, data: mission }, { status: 200 }); } catch (error) { diff --git a/src/lib/goals/goal-context.ts b/src/lib/goals/goal-context.ts index 28df729c..12094aa1 100644 --- a/src/lib/goals/goal-context.ts +++ b/src/lib/goals/goal-context.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import { logger } from "@/lib/logger"; +import { withOrgContext } from "@/lib/db/rls-middleware"; import { getAgentAncestors } from "@/lib/org-chart/hierarchy"; import type { CompanyMission } from "@/generated/prisma"; import type { RuntimeContext } from "@/lib/runtime/types"; @@ -75,7 +76,9 @@ export async function getAgentGoals(agentId: string): Promise { } export async function getMissionForOrg(organizationId: string): Promise { - return prisma.companyMission.findUnique({ where: { organizationId } }); + return withOrgContext(prisma, organizationId, (tx) => + tx.companyMission.findUnique({ where: { organizationId } }) + ); } export async function buildGoalPrompt(agentId: string): Promise {