diff --git a/prisma/migrations/20260531000000_rls_phase1_agentpermissiongrant/migration.sql b/prisma/migrations/20260531000000_rls_phase1_agentpermissiongrant/migration.sql new file mode 100644 index 00000000..af141129 --- /dev/null +++ b/prisma/migrations/20260531000000_rls_phase1_agentpermissiongrant/migration.sql @@ -0,0 +1,42 @@ +-- RLS Phase 1 #6 — AgentPermissionGrant (TENANT_DIRECT) +-- Security-sensitive: governs A2A permission delegation between agents. + +-- 1. Composite index for RLS performance (CRITICAL) +CREATE INDEX IF NOT EXISTS "AgentPermissionGrant_organizationId_id_idx" + ON "AgentPermissionGrant" ("organizationId", "id"); + +-- 2. Enable RLS with FORCE (so table owner also obeys policies) +ALTER TABLE "AgentPermissionGrant" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "AgentPermissionGrant" FORCE ROW LEVEL SECURITY; + +-- 3. Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON "AgentPermissionGrant" TO app_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON "AgentPermissionGrant" TO admin_user; + +-- 4. Policies (admin_user has BYPASSRLS — no policies needed for it) +CREATE POLICY agentpermissiongrant_select ON "AgentPermissionGrant" + FOR SELECT TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY agentpermissiongrant_insert ON "AgentPermissionGrant" + FOR INSERT TO app_user + WITH CHECK ("organizationId" = current_setting('app.current_org_id', true)); + +CREATE POLICY agentpermissiongrant_update ON "AgentPermissionGrant" + 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 agentpermissiongrant_delete ON "AgentPermissionGrant" + FOR DELETE TO app_user + USING ("organizationId" = current_setting('app.current_org_id', true)); + +-- ========================================================================= +-- Rollback (uncomment to revert) +-- ========================================================================= +-- DROP POLICY IF EXISTS agentpermissiongrant_select ON "AgentPermissionGrant"; +-- DROP POLICY IF EXISTS agentpermissiongrant_insert ON "AgentPermissionGrant"; +-- DROP POLICY IF EXISTS agentpermissiongrant_update ON "AgentPermissionGrant"; +-- DROP POLICY IF EXISTS agentpermissiongrant_delete ON "AgentPermissionGrant"; +-- ALTER TABLE "AgentPermissionGrant" DISABLE ROW LEVEL SECURITY; +-- DROP INDEX IF EXISTS "AgentPermissionGrant_organizationId_id_idx"; diff --git a/src/app/api/agents/[agentId]/permissions/route.ts b/src/app/api/agents/[agentId]/permissions/route.ts index 08c03e16..9d46bb70 100644 --- a/src/app/api/agents/[agentId]/permissions/route.ts +++ b/src/app/api/agents/[agentId]/permissions/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { requireAgentOwner, 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"; import { grantPermission } from "@/lib/org-chart/hierarchy"; @@ -28,13 +29,21 @@ export async function GET( if (isAuthError(authResult)) return authResult; try { - const grants = await prisma.agentPermissionGrant.findMany({ - where: { granteeAgentId: agentId }, - orderBy: { createdAt: "desc" }, - include: { - grantor: { select: { id: true, name: true } }, - }, + const agent = await prisma.agent.findUnique({ + where: { id: agentId }, + select: { organizationId: true }, }); + const orgId = agent?.organizationId ?? null; + + const grants = await withOrgContext(prisma, orgId, (tx) => + tx.agentPermissionGrant.findMany({ + where: { granteeAgentId: agentId }, + orderBy: { createdAt: "desc" }, + include: { + grantor: { select: { id: true, name: true } }, + }, + }) + ); return NextResponse.json({ success: true, data: grants }); } catch (error) { diff --git a/src/lib/org-chart/hierarchy.ts b/src/lib/org-chart/hierarchy.ts index 73b899d6..ba379398 100644 --- a/src/lib/org-chart/hierarchy.ts +++ b/src/lib/org-chart/hierarchy.ts @@ -1,4 +1,6 @@ import { prisma } from "@/lib/prisma"; +import { withOrgContext } from "@/lib/db/rls-middleware"; +import { withAdminBypass } from "@/lib/api/tenant-context"; import type { AgentPermissionGrant } from "@/generated/prisma"; export async function getAgentAncestors(agentId: string, maxDepth = 10): Promise { @@ -55,18 +57,20 @@ export async function checkA2APermission( ? { OR: [{ scope: null as string | null }, { scope }] } : { scope: null as string | null }; - const grant = await prisma.agentPermissionGrant.findFirst({ - where: { - granteeAgentId: agentId, - grantorAgentId: { in: ancestors }, - permission, - AND: [ - scopeCondition, - { OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] }, - ], - }, - select: { grantorAgentId: true }, - }); + const grant = await withAdminBypass((db) => + db.agentPermissionGrant.findFirst({ + where: { + granteeAgentId: agentId, + grantorAgentId: { in: ancestors }, + permission, + AND: [ + scopeCondition, + { OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] }, + ], + }, + select: { grantorAgentId: true }, + }) + ); if (grant) return { allowed: true, grantedBy: grant.grantorAgentId }; return { allowed: false }; @@ -86,14 +90,16 @@ export async function grantPermission( throw new Error("Grantor must be an ancestor of grantee to delegate permissions"); } - return prisma.agentPermissionGrant.create({ - data: { - grantorAgentId, - granteeAgentId, - organizationId, - permission, - scope: scope ?? null, - expiresAt: expiresAt ?? null, - }, - }); + return withOrgContext(prisma, organizationId, (tx) => + tx.agentPermissionGrant.create({ + data: { + grantorAgentId, + granteeAgentId, + organizationId, + permission, + scope: scope ?? null, + expiresAt: expiresAt ?? null, + }, + }) + ); }