diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index d036fc78..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"3055829f-1a89-4359-93cc-ce9cd60f8636","pid":7243,"procStart":"Mon Apr 27 14:20:44 2026","acquiredAt":1777746434478} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6766f2d6..bfc94f42 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ pr-guardian-phase1.patch # Local utility scripts (contain secrets, keep out of repo) rotate-postgres-password.sh +.claude/scheduled_tasks.lock diff --git a/prisma/migrations/20260529000000_rls_phase1_department/migration.sql b/prisma/migrations/20260529000000_rls_phase1_department/migration.sql new file mode 100644 index 00000000..4efc4889 --- /dev/null +++ b/prisma/migrations/20260529000000_rls_phase1_department/migration.sql @@ -0,0 +1,44 @@ +-- Phase 1 Migration #4: Department RLS (TENANT_DIRECT) +-- Apply order: #4 of 14 โ€” org-chart hierarchy table; has parentId self-ref (same org only) +-- 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 "Department_organizationId_id_idx" + ON "Department" ("organizationId", "id"); + +-- 2. Enable RLS +ALTER TABLE "Department" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Department" FORCE ROW LEVEL SECURITY; + +-- 3. Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON "Department" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "Department" TO admin_user; + +-- 4. Policies (admin_user has BYPASSRLS โ€” no policies needed for it) +CREATE POLICY department_select ON "Department" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY department_insert ON "Department" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY department_update ON "Department" + 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 department_delete ON "Department" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +-- ========================================================================= +-- Rollback (commented โ€” uncomment to revert) +-- ========================================================================= +-- DROP POLICY IF EXISTS department_select ON "Department"; +-- DROP POLICY IF EXISTS department_insert ON "Department"; +-- DROP POLICY IF EXISTS department_update ON "Department"; +-- DROP POLICY IF EXISTS department_delete ON "Department"; +-- ALTER TABLE "Department" DISABLE ROW LEVEL SECURITY; +-- DROP INDEX IF EXISTS "Department_organizationId_id_idx"; diff --git a/src/app/api/agents/[agentId]/department/route.ts b/src/app/api/agents/[agentId]/department/route.ts index ee02512a..bd9025df 100644 --- a/src/app/api/agents/[agentId]/department/route.ts +++ b/src/app/api/agents/[agentId]/department/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"; interface RouteParams { params: Promise<{ agentId: string }>; @@ -56,7 +57,9 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi const { departmentId } = parsed.data; try { - const dept = await prisma.department.findUnique({ where: { id: departmentId }, select: { id: true } }); + const dept = await withAdminBypass((db) => + db.department.findUnique({ where: { id: departmentId }, select: { id: true } }) + ); if (!dept) return NextResponse.json({ success: false, error: "Department not found" }, { status: 404 }); const updated = await prisma.agent.update({ diff --git a/src/app/api/departments/[departmentId]/route.ts b/src/app/api/departments/[departmentId]/route.ts index fd16ab47..175fbbcf 100644 --- a/src/app/api/departments/[departmentId]/route.ts +++ b/src/app/api/departments/[departmentId]/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<{ departmentId: string }>; @@ -16,10 +18,12 @@ const UpdateDepartmentSchema = z.object({ }); async function loadDepartment(departmentId: string) { - return prisma.department.findUnique({ - where: { id: departmentId }, - select: { id: true, organizationId: true, name: true, description: true, parentId: true, createdAt: true, updatedAt: true }, - }); + return withAdminBypass((db) => + db.department.findUnique({ + where: { id: departmentId }, + select: { id: true, organizationId: true, name: true, description: true, parentId: true, createdAt: true, updatedAt: true }, + }) + ); } export async function GET( @@ -35,13 +39,15 @@ export async function GET( if (isAuthError(authResult)) return authResult; try { - const full = await prisma.department.findUnique({ - where: { id: departmentId }, - include: { - children: { select: { id: true, name: true, description: true } }, - agents: { select: { id: true, name: true, model: true } }, - }, - }); + const full = await withOrgContext(prisma, dept.organizationId, (tx) => + tx.department.findUnique({ + where: { id: departmentId }, + include: { + children: { select: { id: true, name: true, description: true } }, + agents: { select: { id: true, name: true, model: true } }, + }, + }) + ); return NextResponse.json({ success: true, data: full }); } catch (error) { @@ -81,20 +87,24 @@ export async function PATCH( if (parentId === departmentId) { return NextResponse.json({ success: false, error: "Department cannot be its own parent" }, { status: 422 }); } - const parent = await prisma.department.findUnique({ where: { id: parentId }, select: { organizationId: true } }); + const parent = await withOrgContext(prisma, dept.organizationId, (tx) => + tx.department.findUnique({ where: { id: parentId }, select: { organizationId: true } }) + ); if (!parent || parent.organizationId !== dept.organizationId) { return NextResponse.json({ success: false, error: "Parent department not found in this org" }, { status: 422 }); } } - const updated = await prisma.department.update({ - where: { id: departmentId }, - data: { - ...(name !== undefined && { name }), - ...(description !== undefined && { description }), - ...(parentId !== undefined && { parentId }), - }, - }); + const updated = await withOrgContext(prisma, dept.organizationId, (tx) => + tx.department.update({ + where: { id: departmentId }, + data: { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(parentId !== undefined && { parentId }), + }, + }) + ); return NextResponse.json({ success: true, data: updated }); } catch (error) { @@ -124,7 +134,9 @@ export async function DELETE( ); } - await prisma.department.delete({ where: { id: departmentId } }); + await withOrgContext(prisma, dept.organizationId, (tx) => + tx.department.delete({ where: { id: departmentId } }) + ); return NextResponse.json({ success: true, data: null }); } catch (error) { diff --git a/src/app/api/departments/route.ts b/src/app/api/departments/route.ts index bf3f0529..f1d82939 100644 --- a/src/app/api/departments/route.ts +++ b/src/app/api/departments/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 CreateDepartmentSchema = z.object({ organizationId: z.string().cuid(), @@ -23,13 +24,15 @@ export async function GET(request: NextRequest): Promise { if (isAuthError(authResult)) return authResult; try { - const departments = await prisma.department.findMany({ - where: { organizationId: orgId }, - orderBy: { createdAt: "asc" }, - include: { - _count: { select: { agents: true, children: true } }, - }, - }); + const departments = await withOrgContext(prisma, orgId, (tx) => + tx.department.findMany({ + where: { organizationId: orgId }, + orderBy: { createdAt: "asc" }, + include: { + _count: { select: { agents: true, children: true } }, + }, + }) + ); return NextResponse.json({ success: true, data: departments }); } catch (error) { @@ -58,15 +61,19 @@ export async function POST(request: NextRequest): Promise { try { if (parentId) { - const parent = await prisma.department.findUnique({ where: { id: parentId }, select: { organizationId: true } }); + const parent = await withOrgContext(prisma, organizationId, (tx) => + tx.department.findUnique({ where: { id: parentId }, select: { organizationId: true } }) + ); if (!parent || parent.organizationId !== organizationId) { return NextResponse.json({ success: false, error: "Parent department not found in this org" }, { status: 422 }); } } - const department = await prisma.department.create({ - data: { name, description, organizationId, parentId: parentId ?? null }, - }); + const department = await withOrgContext(prisma, organizationId, (tx) => + tx.department.create({ + data: { name, description, organizationId, parentId: parentId ?? null }, + }) + ); return NextResponse.json({ success: true, data: department }, { status: 201 }); } catch (error) {