Skip to content
Open
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
54 changes: 54 additions & 0 deletions docs/adr/0001-agentcard-rls-classification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR-0001: AgentCard RLS Classification (TENANT_INDIRECT via EXISTS)

**Date:** 2026-05-24
**Status:** Accepted

## Context

`AgentCard` has no `organizationId` column (confirmed: `prisma/schema.prisma` line 675–682).
It is a strict 1:1 with `Agent` via `agentId String @unique`, with `onDelete: Cascade`.

The Phase 1 cutover runbook (`docs/rls-phase-1-cutover-runbook.md` §2.3) classifies
`AgentCard` as `TENANT_DIRECT`, which is the standard 4-policy template used for
migrations #1–#11 — all of which have an `organizationId` column that RLS policies
can reference directly. `AgentCard` does not satisfy that precondition.

Additionally, `AgentCard` has an `isPublic` field used for A2A cross-org discovery.
Public cards must remain readable without an org context (e.g. `prismaRead` queries
from unauthenticated A2A endpoints). This is the same pattern flagged for `Agent`
and `Template` in §3.3 of the runbook.

## Decision

Implement `AgentCard` RLS as **TENANT_INDIRECT**: SELECT policy uses
`"isPublic" = true OR EXISTS (SELECT 1 FROM "Agent" WHERE id = agentId AND organizationId = current_setting(...))`;
INSERT/UPDATE/DELETE use the EXISTS clause only.

No `organizationId` column is added to `AgentCard`.

## Consequences

**Good:**
- No schema migration or Prisma model change required.
- `agentId @unique` means the EXISTS subquery is a single-row PK lookup — O(1), no index needed.
- Public cards remain readable cross-org without org context (A2A discovery works immediately).

**Bad:**
- Doc drift: runbook §2.3 still lists `AgentCard` as `TENANT_DIRECT` — must be corrected (Korak 2).
- Introduces a second RLS classification pattern in Phase 1; future readers must understand both.
- Gap until Migration #14: `AgentCard` includes inside `prismaRead.agent.findMany` (discover route)
run without `current_org_id` set; private cards return `null` for the `agentCard` include until
Agent gets `withOrgContext` in #14. Callers already handle `agentCard: null` gracefully.

**Neutral:**
- `upsertAgentCard` (the only direct write) is wrapped in `withAdminBypass` — consistent with
other fire-and-forget system writes; relies on `DATABASE_URL` BYPASSRLS (same assumption
as tech-debt #6).

## Alternatives considered

**Option A — Add `organizationId` to `AgentCard`:** Would align with TENANT_DIRECT template
and eliminate the subquery. Rejected because `AgentCard:Agent` is 1:1 with a UNIQUE FK;
denormalizing `organizationId` adds a redundant column with no query benefit, and requires
a backfill migration plus Prisma schema change for a table that has zero independent access
patterns — every read already goes through the parent `Agent`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- Phase 1 #12 — AgentCard RLS
-- AgentCard has no organizationId column; policies use an EXISTS subquery
-- against Agent (agentId is UNIQUE so the subquery is a single-row PK lookup).
-- isPublic=true rows are readable cross-org (A2A discovery pattern).
-- upsertAgentCard is a fire-and-forget write that relies on DATABASE_URL BYPASSRLS.

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

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

-- Own-org cards + cross-org public cards
CREATE POLICY "ac_select" ON "AgentCard"
FOR SELECT TO app_user
USING (
"isPublic" = true
OR EXISTS (
SELECT 1 FROM "Agent" a
WHERE a.id = "AgentCard"."agentId"
AND a."organizationId" = current_setting('app.current_org_id', true)
)
);

CREATE POLICY "ac_insert" ON "AgentCard"
FOR INSERT TO app_user
WITH CHECK (
EXISTS (
SELECT 1 FROM "Agent" a
WHERE a.id = "AgentCard"."agentId"
AND a."organizationId" = current_setting('app.current_org_id', true)
)
);

CREATE POLICY "ac_update" ON "AgentCard"
FOR UPDATE TO app_user
USING (
EXISTS (
SELECT 1 FROM "Agent" a
WHERE a.id = "AgentCard"."agentId"
AND a."organizationId" = current_setting('app.current_org_id', true)
)
);

CREATE POLICY "ac_delete" ON "AgentCard"
FOR DELETE TO app_user
USING (
EXISTS (
SELECT 1 FROM "Agent" a
WHERE a.id = "AgentCard"."agentId"
AND a."organizationId" = current_setting('app.current_org_id', true)
)
);
137 changes: 137 additions & 0 deletions prisma/migrations/20260606000000_rls_phase1_agentcard/test.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
-- =============================================================================
-- AgentCard RLS — Cross-tenant + isPublic smoke tests
-- Phase 1 #12 | ADR-0001 (TENANT_INDIRECT via EXISTS subquery against Agent)
--
-- PLACEHOLDER — execute manually against staging as admin_user.
-- No automated RLS integration harness exists yet; see the it.skip() block in
-- src/lib/db/__tests__/rls-middleware.test.ts for the TODO when one is wired up.
--
-- Prerequisites:
-- 1. Migration 20260606000000_rls_phase1_agentcard has been applied.
-- 2. Run as admin_user (BYPASSRLS) so fixture setup is not blocked by policies.
-- 3. DATABASE_URL points to staging (NOT production).
--
-- Cleanup: run the CLEANUP block at the bottom after all tests pass.
-- =============================================================================

-- ---------------------------------------------------------------------------
-- SETUP — insert two orgs, two agents (one per org), two AgentCards
-- ---------------------------------------------------------------------------
BEGIN;

INSERT INTO "Organization" (id, name, slug, plan, "createdAt", "updatedAt")
VALUES
('rls-test-org-a', 'RLS Test Org A', 'rls-test-a', 'FREE', NOW(), NOW()),
('rls-test-org-b', 'RLS Test Org B', 'rls-test-b', 'FREE', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

INSERT INTO "User" (id, email, "createdAt", "updatedAt")
VALUES ('rls-test-user-a', 'rls-a@test.internal', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

-- Agent A: belongs to Org A
INSERT INTO "Agent" (id, name, "userId", "organizationId", model, "createdAt", "updatedAt")
VALUES ('rls-agent-a', 'RLS Agent A', 'rls-test-user-a', 'rls-test-org-a', 'deepseek-chat', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

-- Agent B: belongs to Org B
INSERT INTO "Agent" (id, name, "userId", "organizationId", model, "createdAt", "updatedAt")
VALUES ('rls-agent-b', 'RLS Agent B', 'rls-test-user-a', 'rls-test-org-b', 'deepseek-chat', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

-- AgentCard A: private, belongs to Agent A (Org A)
INSERT INTO "AgentCard" ("id", "agentId", "isPublic", "skills", "updatedAt")
VALUES ('rls-card-a', 'rls-agent-a', false, '[]', NOW())
ON CONFLICT ("agentId") DO UPDATE SET "isPublic" = false;

-- AgentCard B: PUBLIC, belongs to Agent B (Org B)
INSERT INTO "AgentCard" ("id", "agentId", "isPublic", "skills", "updatedAt")
VALUES ('rls-card-b', 'rls-agent-b', true, '[]', NOW())
ON CONFLICT ("agentId") DO UPDATE SET "isPublic" = true;

COMMIT;

-- ---------------------------------------------------------------------------
-- TEST 1 — Org A can SELECT its own AgentCard (EXISTS = true)
-- Expected: 1 row returned (rls-card-a)
-- ---------------------------------------------------------------------------
BEGIN;
SET LOCAL "app.current_org_id" = 'rls-test-org-a';
SELECT id, "agentId", "isPublic"
FROM "AgentCard"
WHERE id = 'rls-card-a';
-- Expected: 1 row { id: 'rls-card-a', agentId: 'rls-agent-a', isPublic: false }
ROLLBACK;

-- ---------------------------------------------------------------------------
-- TEST 2 — Org A can SELECT AgentCard from Org B when isPublic = true
-- Expected: 1 row returned (cross-org public read)
-- ---------------------------------------------------------------------------
BEGIN;
SET LOCAL "app.current_org_id" = 'rls-test-org-a';
SELECT id, "agentId", "isPublic"
FROM "AgentCard"
WHERE id = 'rls-card-b';
-- Expected: 1 row { id: 'rls-card-b', agentId: 'rls-agent-b', isPublic: true }
ROLLBACK;

-- Negative check: Org A cannot see a PRIVATE card from Org B
BEGIN;
SET LOCAL "app.current_org_id" = 'rls-test-org-a';
-- Temporarily make card-b private for this sub-test
UPDATE "AgentCard" SET "isPublic" = false WHERE id = 'rls-card-b';
-- Restore will happen in ROLLBACK
SELECT id FROM "AgentCard" WHERE id = 'rls-card-b';
-- Expected: 0 rows (private card from another org is hidden)
ROLLBACK;
-- (ROLLBACK restores isPublic = true on rls-card-b)

-- ---------------------------------------------------------------------------
-- TEST 3 — Org A cannot UPDATE AgentCard belonging to Agent from Org B
-- Expected: UPDATE 0 (RLS hides the row; no 42501 on UPDATE USING)
-- even when the card is public (isPublic = true)
-- ---------------------------------------------------------------------------
BEGIN;
SET LOCAL "app.current_org_id" = 'rls-test-org-a';
UPDATE "AgentCard"
SET skills = '[{"id":"pwned"}]'
WHERE id = 'rls-card-b';
-- Expected: UPDATE 0
-- Verify: card-b is unchanged
SELECT skills FROM "AgentCard" WHERE id = 'rls-card-b';
-- Expected: '[]' (not pwned)
ROLLBACK;

-- ---------------------------------------------------------------------------
-- TEST 4 — Org A cannot INSERT AgentCard with agentId from Org B
-- Expected: ERROR 42501 (new row violates row-level security policy)
-- ---------------------------------------------------------------------------
BEGIN;
SET LOCAL "app.current_org_id" = 'rls-test-org-a';
-- rls-agent-b belongs to Org B — INSERT should be blocked by WITH CHECK
INSERT INTO "AgentCard" ("id", "agentId", "isPublic", "skills", "updatedAt")
VALUES ('rls-card-inject', 'rls-agent-b', false, '[]', NOW());
-- Expected: ERROR 42501
-- If this INSERT succeeds, the INSERT policy is broken — escalate immediately.
ROLLBACK;

-- ---------------------------------------------------------------------------
-- ADMIN BYPASS SANITY CHECK — admin_user sees everything
-- Expected: 2 rows (both cards visible regardless of org context)
-- ---------------------------------------------------------------------------
SELECT id, "agentId", "isPublic"
FROM "AgentCard"
WHERE id IN ('rls-card-a', 'rls-card-b');
-- Run as admin_user (BYPASSRLS) — must return 2 rows.

-- ---------------------------------------------------------------------------
-- CLEANUP — remove all test fixtures
-- ---------------------------------------------------------------------------
-- DELETE FROM "AgentCard" WHERE id IN ('rls-card-a', 'rls-card-b');
-- DELETE FROM "Agent" WHERE id IN ('rls-agent-a', 'rls-agent-b');
-- DELETE FROM "User" WHERE id = 'rls-test-user-a';
-- DELETE FROM "Organization" WHERE id IN ('rls-test-org-a', 'rls-test-org-b');
--
-- Or use CASCADE:
-- DELETE FROM "Organization" WHERE id IN ('rls-test-org-a', 'rls-test-org-b');
-- (Cascades to Agent → AgentCard via onDelete: Cascade)
27 changes: 16 additions & 11 deletions src/lib/a2a/card-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// prismaRead role = DATABASE_READ_URL connection (fallback: DATABASE_URL); BYPASSRLS assumed — see tech-debt #6.
// post-#12: AgentCard.isPublic is always false (upsert never sets it); agentCard include via prismaRead returns null (no org ctx) — callers handle null gracefully.
import { prisma, prismaRead } from "@/lib/prisma";
import { withAdminBypass } from "@/lib/api/tenant-context";

export interface A2ASkill {
id: string;
Expand Down Expand Up @@ -197,15 +200,17 @@ export async function upsertAgentCard(
): Promise<void> {
const card = await generateAgentCard(agentId, userId, baseUrl);

await prisma.agentCard.upsert({
where: { agentId },
create: {
agentId,
skills: JSON.parse(JSON.stringify(card.skills)),
isPublic: false,
},
update: {
skills: JSON.parse(JSON.stringify(card.skills)),
},
});
await withAdminBypass((db) =>
db.agentCard.upsert({
where: { agentId },
create: {
agentId,
skills: JSON.parse(JSON.stringify(card.skills)),
isPublic: false,
},
update: {
skills: JSON.parse(JSON.stringify(card.skills)),
},
})
);
}
Loading