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,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";
21 changes: 15 additions & 6 deletions src/app/api/agents/[agentId]/permissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) {
Expand Down
50 changes: 28 additions & 22 deletions src/lib/org-chart/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
Expand Down Expand Up @@ -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 };
Expand All @@ -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,
},
})
);
}
Loading