Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 4 additions & 1 deletion src/app/api/agents/[agentId]/department/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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({
Expand Down
54 changes: 33 additions & 21 deletions src/app/api/departments/[departmentId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 18 additions & 11 deletions src/app/api/departments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -23,13 +24,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
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) {
Expand Down Expand Up @@ -58,15 +61,19 @@ export async function POST(request: NextRequest): Promise<NextResponse> {

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) {
Expand Down
Loading