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
46 changes: 46 additions & 0 deletions prisma/migrations/20260530000000_rls_phase1_goal/migration.sql
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 4 additions & 1 deletion src/app/api/agents/[agentId]/goals/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";
import { getAgentGoals } from "@/lib/goals/goal-context";

interface RouteParams {
Expand Down Expand Up @@ -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 });
}
Expand Down
64 changes: 38 additions & 26 deletions src/app/api/goals/[goalId]/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<{ goalId: string }>;
Expand All @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -113,15 +121,19 @@ 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.` },
{ status: 409 },
);
}

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 });
Expand Down
51 changes: 28 additions & 23 deletions src/app/api/goals/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 CreateGoalSchema = z.object({
organizationId: z.string().cuid(),
Expand All @@ -29,17 +30,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
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) {
Expand Down Expand Up @@ -67,18 +70,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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) {
Expand Down
21 changes: 12 additions & 9 deletions src/lib/templates/template-engine.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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" },
});
Expand Down
Loading