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,43 @@
-- RLS Phase 1 #10 — ApprovalPolicy (TENANT_DIRECT)
-- Board governance table. Admin/cron ops (processTimeouts) rely on
-- DATABASE_URL BYPASSRLS and do NOT use withOrgContext.

-- 1. Composite index for RLS performance (CRITICAL)
CREATE INDEX IF NOT EXISTS "ApprovalPolicy_organizationId_id_idx"
ON "ApprovalPolicy" ("organizationId", "id");

-- 2. Enable RLS with FORCE (so table owner also obeys policies)
ALTER TABLE "ApprovalPolicy" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ApprovalPolicy" FORCE ROW LEVEL SECURITY;

-- 3. Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON "ApprovalPolicy" TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON "ApprovalPolicy" TO admin_user;

-- 4. Policies (admin_user has BYPASSRLS — no policies needed for it)
CREATE POLICY approvalpolicy_select ON "ApprovalPolicy"
FOR SELECT TO app_user
USING ("organizationId" = current_setting('app.current_org_id', true));

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

CREATE POLICY approvalpolicy_update ON "ApprovalPolicy"
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 approvalpolicy_delete ON "ApprovalPolicy"
FOR DELETE TO app_user
USING ("organizationId" = current_setting('app.current_org_id', true));

-- =========================================================================
-- Rollback (uncomment to revert)
-- =========================================================================
-- DROP POLICY IF EXISTS approvalpolicy_select ON "ApprovalPolicy";
-- DROP POLICY IF EXISTS approvalpolicy_insert ON "ApprovalPolicy";
-- DROP POLICY IF EXISTS approvalpolicy_update ON "ApprovalPolicy";
-- DROP POLICY IF EXISTS approvalpolicy_delete ON "ApprovalPolicy";
-- ALTER TABLE "ApprovalPolicy" DISABLE ROW LEVEL SECURITY;
-- DROP INDEX IF EXISTS "ApprovalPolicy_organizationId_id_idx";
11 changes: 7 additions & 4 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 { withAdminBypass } from "@/lib/api/tenant-context";
import { logger } from "@/lib/logger";

interface RouteParams {
Expand All @@ -11,10 +12,12 @@ export async function GET(request: NextRequest, { params }: RouteParams): Promis
const { policyId } = await params;
const status = request.nextUrl.searchParams.get("status") ?? undefined;

const policy = await prisma.approvalPolicy.findUnique({
where: { id: policyId },
select: { id: true, organizationId: true },
});
const policy = await withAdminBypass((db) =>
db.approvalPolicy.findUnique({
where: { id: policyId },
select: { id: true, organizationId: true },
})
);
if (!policy) return NextResponse.json({ success: false, error: "Policy not found" }, { status: 404 });

const authResult = await requireOrgMember(policy.organizationId, request);
Expand Down
50 changes: 30 additions & 20 deletions src/app/api/policies/[policyId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ 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 { withAdminBypass } from "@/lib/api/tenant-context";
import { logger } from "@/lib/logger";

interface RouteParams {
Expand All @@ -19,10 +21,12 @@ const UpdatePolicySchema = z.object({
});

async function loadPolicy(policyId: string) {
return prisma.approvalPolicy.findUnique({
where: { id: policyId },
select: { id: true, organizationId: true },
});
return withAdminBypass((db) =>
db.approvalPolicy.findUnique({
where: { id: policyId },
select: { id: true, organizationId: true },
})
);
}

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

try {
const full = await prisma.approvalPolicy.findUnique({
where: { id: policyId },
include: { _count: { select: { decisions: true } } },
});
const full = await withOrgContext(prisma, policy.organizationId, (tx) =>
tx.approvalPolicy.findUnique({
where: { id: policyId },
include: { _count: { select: { decisions: true } } },
})
);
return NextResponse.json({ success: true, data: full });
} catch (error) {
logger.error("GET /api/policies/[policyId] error", { policyId, error });
Expand Down Expand Up @@ -68,17 +74,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams): Prom
}

try {
const updated = await prisma.approvalPolicy.update({
where: { id: policyId },
data: {
...(parsed.data.name !== undefined && { name: parsed.data.name }),
...(parsed.data.actionPattern !== undefined && { actionPattern: parsed.data.actionPattern }),
...(parsed.data.approverIds !== undefined && { approverIds: parsed.data.approverIds }),
...(parsed.data.timeoutSeconds !== undefined && { timeoutSeconds: parsed.data.timeoutSeconds }),
...(parsed.data.timeoutApprove !== undefined && { timeoutApprove: parsed.data.timeoutApprove }),
...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }),
},
});
const updated = await withOrgContext(prisma, policy.organizationId, (tx) =>
tx.approvalPolicy.update({
where: { id: policyId },
data: {
...(parsed.data.name !== undefined && { name: parsed.data.name }),
...(parsed.data.actionPattern !== undefined && { actionPattern: parsed.data.actionPattern }),
...(parsed.data.approverIds !== undefined && { approverIds: parsed.data.approverIds }),
...(parsed.data.timeoutSeconds !== undefined && { timeoutSeconds: parsed.data.timeoutSeconds }),
...(parsed.data.timeoutApprove !== undefined && { timeoutApprove: parsed.data.timeoutApprove }),
...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }),
},
})
);
return NextResponse.json({ success: true, data: updated });
} catch (error) {
logger.error("PATCH /api/policies/[policyId] error", { policyId, error });
Expand All @@ -104,7 +112,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams): Pro
);
}

await prisma.approvalPolicy.delete({ where: { id: policyId } });
await withOrgContext(prisma, policy.organizationId, (tx) =>
tx.approvalPolicy.delete({ where: { id: policyId } })
);
return NextResponse.json({ success: true, data: null });
} catch (error) {
logger.error("DELETE /api/policies/[policyId] error", { policyId, error });
Expand Down
43 changes: 24 additions & 19 deletions src/app/api/policies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 CreatePolicySchema = z.object({
Expand All @@ -27,14 +28,16 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
if (isAuthError(authResult)) return authResult;

try {
const policies = await prisma.approvalPolicy.findMany({
where: {
organizationId: orgId,
...(agentId && { agentId }),
},
orderBy: { createdAt: "desc" },
include: { _count: { select: { decisions: true } } },
});
const policies = await withOrgContext(prisma, orgId, (tx) =>
tx.approvalPolicy.findMany({
where: {
organizationId: orgId,
...(agentId && { agentId }),
},
orderBy: { createdAt: "desc" },
include: { _count: { select: { decisions: true } } },
})
);

return NextResponse.json({ success: true, data: policies });
} catch (error) {
Expand Down Expand Up @@ -67,17 +70,19 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ success: false, error: "Agent not found" }, { status: 404 });
}

const policy = await prisma.approvalPolicy.create({
data: {
agentId,
organizationId,
name,
actionPattern,
approverIds,
timeoutSeconds: timeoutSeconds ?? null,
timeoutApprove: timeoutApprove ?? false,
},
});
const policy = await withOrgContext(prisma, organizationId, (tx) =>
tx.approvalPolicy.create({
data: {
agentId,
organizationId,
name,
actionPattern,
approverIds,
timeoutSeconds: timeoutSeconds ?? null,
timeoutApprove: timeoutApprove ?? false,
},
})
);

return NextResponse.json({ success: true, data: policy }, { status: 201 });
} catch (error) {
Expand Down
28 changes: 19 additions & 9 deletions src/lib/governance/approval-engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma";
import { withOrgContext } from "@/lib/db/rls-middleware";
import { logger } from "@/lib/logger";
import { Prisma } from "@/generated/prisma";
import type { ApprovalPolicy, PolicyDecision } from "@/generated/prisma";
Expand Down Expand Up @@ -30,16 +31,19 @@ export interface ProcessTimeoutsResult {
export async function checkPolicies(
agentId: string,
action: string,
organizationId?: string | null,
): Promise<PolicyCheckResult> {
try {
const policy = await prisma.approvalPolicy.findFirst({
where: {
agentId,
isActive: true,
actionPattern: { in: [action, "*"] },
},
orderBy: { actionPattern: "asc" }, // exact match (non-"*") sorts before "*"
});
const policy = await withOrgContext(prisma, organizationId ?? null, (tx) =>
tx.approvalPolicy.findFirst({
where: {
agentId,
isActive: true,
actionPattern: { in: [action, "*"] },
},
orderBy: { actionPattern: "asc" }, // exact match (non-"*") sorts before "*"
})
);

return { requiresApproval: policy !== null, policy };
} catch (error) {
Expand Down Expand Up @@ -68,7 +72,9 @@ export async function requestApproval(
return { decision: existing, alreadyPending: true };
}

const policy = await prisma.approvalPolicy.findUnique({ where: { id: policyId } });
const policy = await withOrgContext(prisma, organizationId, (tx) =>
tx.approvalPolicy.findUnique({ where: { id: policyId } })
);
if (!policy) {
throw new Error(`ApprovalPolicy ${policyId} not found`);
}
Expand Down Expand Up @@ -129,6 +135,10 @@ export async function resolveDecision(
/**
* Finds all PENDING decisions whose expiresAt is in the past and resolves them
* 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.
*/
export async function processTimeouts(): Promise<ProcessTimeoutsResult> {
const expired = await prisma.policyDecision.findMany({
Expand Down
Loading