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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Phase 1 #11 — PolicyDecision RLS
-- FK to ApprovalPolicy; applied immediately after Migration #10.
-- processTimeouts cross-org cron relies on DATABASE_URL BYPASSRLS (see tech-debt #6).

CREATE INDEX IF NOT EXISTS "PolicyDecision_organizationId_id_idx"
ON "PolicyDecision" ("organizationId", "id");

ALTER TABLE "PolicyDecision" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "PolicyDecision" FORCE ROW LEVEL SECURITY;

GRANT SELECT, INSERT, UPDATE, DELETE ON "PolicyDecision" TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON "PolicyDecision" TO admin_user;

CREATE POLICY "pd_select" ON "PolicyDecision"
FOR SELECT TO app_user
USING ("organizationId" = current_setting('app.current_org_id', true));

CREATE POLICY "pd_insert" ON "PolicyDecision"
FOR INSERT TO app_user
WITH CHECK ("organizationId" = current_setting('app.current_org_id', true));

CREATE POLICY "pd_update" ON "PolicyDecision"
FOR UPDATE TO app_user
USING ("organizationId" = current_setting('app.current_org_id', true));

CREATE POLICY "pd_delete" ON "PolicyDecision"
FOR DELETE TO app_user
USING ("organizationId" = current_setting('app.current_org_id', true));
20 changes: 13 additions & 7 deletions src/app/api/agents/[agentId]/pending-approvals/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAgentOwner, isAuthError } from "@/lib/api/auth-guard";
import { prisma } from "@/lib/prisma";
import { withOrgContext } from "@/lib/db/rls-middleware";
import { logger } from "@/lib/logger";

interface RouteParams {
Expand All @@ -13,14 +14,19 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis
const authResult = await requireAgentOwner(agentId, request);
if (isAuthError(authResult)) return authResult;

const agent = await prisma.agent.findUnique({ where: { id: agentId }, select: { organizationId: true } });
const orgId = agent?.organizationId ?? null;

try {
const decisions = await prisma.policyDecision.findMany({
where: { agentId, status: "PENDING" },
orderBy: { createdAt: "asc" },
include: {
policy: { select: { id: true, name: true, actionPattern: true, approverIds: true, timeoutSeconds: true } },
},
});
const decisions = await withOrgContext(prisma, orgId, (tx) =>
tx.policyDecision.findMany({
where: { agentId, status: "PENDING" },
orderBy: { createdAt: "asc" },
include: {
policy: { select: { id: true, name: true, actionPattern: true, approverIds: true, timeoutSeconds: true } },
},
})
);
return NextResponse.json({ success: true, data: decisions });
} catch (error) {
logger.error("GET /api/agents/[agentId]/pending-approvals error", { agentId, error });
Expand Down
33 changes: 21 additions & 12 deletions src/app/api/decisions/[decisionId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { z } from "zod";
import { requireOrgMember, requireAuth, 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 { withAdminBypass } from "@/lib/api/tenant-context";
import { logger } from "@/lib/logger";
import { resolveDecision } from "@/lib/governance/approval-engine";

Expand All @@ -16,10 +18,12 @@ const ResolveDecisionSchema = z.object({
});

async function loadDecision(decisionId: string) {
return prisma.policyDecision.findUnique({
where: { id: decisionId },
select: { id: true, organizationId: true, status: true },
});
return withAdminBypass((db) =>
db.policyDecision.findUnique({
where: { id: decisionId },
select: { id: true, organizationId: true, status: true },
})
);
}

export async function GET(request: NextRequest, { params }: RouteParams): Promise<NextResponse> {
Expand All @@ -32,10 +36,12 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis
if (isAuthError(authResult)) return authResult;

try {
const full = await prisma.policyDecision.findUnique({
where: { id: decisionId },
include: { policy: { select: { id: true, name: true, actionPattern: true, approverIds: true } } },
});
const full = await withOrgContext(prisma, decision.organizationId, (tx) =>
tx.policyDecision.findUnique({
where: { id: decisionId },
include: { policy: { select: { id: true, name: true, actionPattern: true, approverIds: true } } },
})
);
return NextResponse.json({ success: true, data: full });
} catch (error) {
logger.error("GET /api/decisions/[decisionId] error", { decisionId, error });
Expand Down Expand Up @@ -79,6 +85,7 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi
decisionId,
parsed.data.resolution,
userAuthResult.userId,
decision.organizationId,
parsed.data.resolverNote,
);
return NextResponse.json({ success: true, data: resolved });
Expand All @@ -105,10 +112,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro
);
}

const cancelled = await prisma.policyDecision.update({
where: { id: decisionId },
data: { status: "CANCELLED", resolvedAt: new Date() },
});
const cancelled = await withOrgContext(prisma, decision.organizationId, (tx) =>
tx.policyDecision.update({
where: { id: decisionId },
data: { status: "CANCELLED", resolvedAt: new Date() },
})
);
return NextResponse.json({ success: true, data: cancelled });
} catch (error) {
logger.error("DELETE /api/decisions/[decisionId] error", { decisionId, error });
Expand Down
17 changes: 10 additions & 7 deletions src/app/api/policies/[policyId]/decisions/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requireOrgMember, isAuthError } from "@/lib/api/auth-guard";
import { prisma } from "@/lib/prisma";
import { withOrgContext } from "@/lib/db/rls-middleware";
import { withAdminBypass } from "@/lib/api/tenant-context";
import { logger } from "@/lib/logger";

Expand All @@ -24,13 +25,15 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis
if (isAuthError(authResult)) return authResult;

try {
const decisions = await prisma.policyDecision.findMany({
where: {
policyId,
...(status && { status }),
},
orderBy: { createdAt: "desc" },
});
const decisions = await withOrgContext(prisma, policy.organizationId, (tx) =>
tx.policyDecision.findMany({
where: {
policyId,
...(status && { status }),
},
orderBy: { createdAt: "desc" },
})
);
return NextResponse.json({ success: true, data: decisions });
} catch (error) {
logger.error("GET /api/policies/[policyId]/decisions error", { policyId, error });
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/policies/[policyId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro
if (isAuthError(authResult)) return authResult;

try {
const pendingCount = await prisma.policyDecision.count({ where: { policyId, status: "PENDING" } });
const pendingCount = await withOrgContext(prisma, policy.organizationId, (tx) =>
tx.policyDecision.count({ where: { policyId, status: "PENDING" } })
);
if (pendingCount > 0) {
return NextResponse.json(
{ success: false, error: `Cannot delete policy with ${pendingCount} pending decision(s).` },
Expand Down
14 changes: 7 additions & 7 deletions src/lib/governance/__tests__/approval-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe("resolveDecision", () => {
const resolved = makeDecision({ status: "APPROVED", resolvedBy: "user-1", resolvedAt: new Date() });
mockDecisionUpdate.mockResolvedValue(resolved);

const result = await resolveDecision("decision-1", "APPROVED", "user-1", "Looks good");
const result = await resolveDecision("decision-1", "APPROVED", "user-1", "org-1", "Looks good");

expect(result.decision.status).toBe("APPROVED");
expect(mockDecisionUpdate).toHaveBeenCalledWith(
Expand All @@ -194,15 +194,15 @@ describe("resolveDecision", () => {
it("throws when decision is already resolved", async () => {
mockDecisionFindUnique.mockResolvedValue(makeDecision({ status: "APPROVED" }));

await expect(resolveDecision("decision-1", "REJECTED", "user-1")).rejects.toThrow(
await expect(resolveDecision("decision-1", "REJECTED", "user-1", "org-1")).rejects.toThrow(
"PolicyDecision decision-1 is already APPROVED",
);
});

it("throws when decision not found", async () => {
mockDecisionFindUnique.mockResolvedValue(null);

await expect(resolveDecision("nonexistent", "APPROVED", "user-1")).rejects.toThrow(
await expect(resolveDecision("nonexistent", "APPROVED", "user-1", "org-1")).rejects.toThrow(
"PolicyDecision nonexistent not found",
);
});
Expand Down Expand Up @@ -271,7 +271,7 @@ describe("waitForDecision", () => {
const resolved = makeDecision({ status: "APPROVED" });
mockDecisionFindUnique.mockResolvedValue(resolved);

const result = await waitForDecision("decision-1", 5000, 10);
const result = await waitForDecision("decision-1", null, 5000, 10);

expect(result.status).toBe("APPROVED");
expect(mockDecisionFindUnique).toHaveBeenCalledTimes(1);
Expand All @@ -282,7 +282,7 @@ describe("waitForDecision", () => {
const resolved = makeDecision({ status: "REJECTED" });
mockDecisionFindUnique.mockResolvedValueOnce(pending).mockResolvedValueOnce(resolved);

const result = await waitForDecision("decision-1", 5000, 1);
const result = await waitForDecision("decision-1", null, 5000, 1);

expect(result.status).toBe("REJECTED");
expect(mockDecisionFindUnique).toHaveBeenCalledTimes(2);
Expand All @@ -291,12 +291,12 @@ describe("waitForDecision", () => {
it("throws when max wait time exceeded", async () => {
mockDecisionFindUnique.mockResolvedValue(makeDecision({ status: "PENDING" }));

await expect(waitForDecision("decision-1", 50, 10)).rejects.toThrow("waitForDecision timed out");
await expect(waitForDecision("decision-1", null, 50, 10)).rejects.toThrow("waitForDecision timed out");
});

it("throws when decision not found", async () => {
mockDecisionFindUnique.mockResolvedValue(null);

await expect(waitForDecision("decision-1", 5000, 10)).rejects.toThrow("PolicyDecision decision-1 not found");
await expect(waitForDecision("decision-1", null, 5000, 10)).rejects.toThrow("PolicyDecision decision-1 not found");
});
});
67 changes: 40 additions & 27 deletions src/lib/governance/approval-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ export async function requestApproval(
action: string,
context?: Record<string, unknown>,
): Promise<RequestApprovalResult> {
const existing = await prisma.policyDecision.findFirst({
where: { policyId, agentId, action, status: "PENDING" },
});
const existing = await withOrgContext(prisma, organizationId, (tx) =>
tx.policyDecision.findFirst({
where: { policyId, agentId, action, status: "PENDING" },
})
);

if (existing) {
return { decision: existing, alreadyPending: true };
Expand All @@ -84,17 +86,19 @@ export async function requestApproval(
? new Date(Date.now() + policy.timeoutSeconds * 1000)
: null;

const decision = await prisma.policyDecision.create({
data: {
policyId,
agentId,
organizationId,
action,
context: context !== undefined ? (context as Prisma.InputJsonValue) : Prisma.JsonNull,
status: "PENDING",
expiresAt,
},
});
const decision = await withOrgContext(prisma, organizationId, (tx) =>
tx.policyDecision.create({
data: {
policyId,
agentId,
organizationId,
action,
context: context !== undefined ? (context as Prisma.InputJsonValue) : Prisma.JsonNull,
status: "PENDING",
expiresAt,
},
})
);

logger.info("Approval requested", { decisionId: decision.id, agentId, action, policyId });

Expand All @@ -109,23 +113,28 @@ export async function resolveDecision(
decisionId: string,
resolution: "APPROVED" | "REJECTED",
resolvedBy: string,
organizationId: string,
resolverNote?: string,
): Promise<ResolveDecisionResult> {
const existing = await prisma.policyDecision.findUnique({ where: { id: decisionId } });
const existing = await withOrgContext(prisma, organizationId, (tx) =>
tx.policyDecision.findUnique({ where: { id: decisionId } })
);
if (!existing) throw new Error(`PolicyDecision ${decisionId} not found`);
if (existing.status !== "PENDING") {
throw new Error(`PolicyDecision ${decisionId} is already ${existing.status}`);
}

const decision = await prisma.policyDecision.update({
where: { id: decisionId },
data: {
status: resolution,
resolvedBy,
resolvedAt: new Date(),
resolverNote: resolverNote ?? null,
},
});
const decision = await withOrgContext(prisma, organizationId, (tx) =>
tx.policyDecision.update({
where: { id: decisionId },
data: {
status: resolution,
resolvedBy,
resolvedAt: new Date(),
resolverNote: resolverNote ?? null,
},
})
);

logger.info("Decision resolved", { decisionId, resolution, resolvedBy });

Expand All @@ -137,8 +146,9 @@ export async function resolveDecision(
* according to each policy's timeoutApprove flag.
*
* Cross-org cron — deliberately uses no withOrgContext. Relies on the
* DATABASE_URL connection having BYPASSRLS (Phase 0b) so the ApprovalPolicy
* include resolves across all tenants.
* DATABASE_URL connection having BYPASSRLS (Phase 0b) so both the
* PolicyDecision findMany and ApprovalPolicy include resolve across all tenants.
* See tech-debt #6 for pre-flag-flip verification steps.
*/
export async function processTimeouts(): Promise<ProcessTimeoutsResult> {
const expired = await prisma.policyDecision.findMany({
Expand Down Expand Up @@ -176,13 +186,16 @@ export async function processTimeouts(): Promise<ProcessTimeoutsResult> {
*/
export async function waitForDecision(
decisionId: string,
organizationId: string | null = null,
maxWaitMs = 300_000,
pollIntervalMs = 3_000,
): Promise<PolicyDecision> {
const deadline = Date.now() + maxWaitMs;

while (Date.now() < deadline) {
const decision = await prisma.policyDecision.findUnique({ where: { id: decisionId } });
const decision = await withOrgContext(prisma, organizationId, (tx) =>
tx.policyDecision.findUnique({ where: { id: decisionId } })
);
if (!decision) throw new Error(`PolicyDecision ${decisionId} not found`);
if (decision.status !== "PENDING") return decision;

Expand Down
Loading