diff --git a/docs/adr/0001-agentcard-rls-classification.md b/docs/adr/0001-agentcard-rls-classification.md new file mode 100644 index 00000000..690eb45b --- /dev/null +++ b/docs/adr/0001-agentcard-rls-classification.md @@ -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`. diff --git a/prisma/migrations/20260606000000_rls_phase1_agentcard/migration.sql b/prisma/migrations/20260606000000_rls_phase1_agentcard/migration.sql new file mode 100644 index 00000000..2c18b5c5 --- /dev/null +++ b/prisma/migrations/20260606000000_rls_phase1_agentcard/migration.sql @@ -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) + ) + ); diff --git a/prisma/migrations/20260606000000_rls_phase1_agentcard/test.sql b/prisma/migrations/20260606000000_rls_phase1_agentcard/test.sql new file mode 100644 index 00000000..1c67c44a --- /dev/null +++ b/prisma/migrations/20260606000000_rls_phase1_agentcard/test.sql @@ -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) diff --git a/src/lib/a2a/card-generator.ts b/src/lib/a2a/card-generator.ts index f9c13763..e746ec34 100644 --- a/src/lib/a2a/card-generator.ts +++ b/src/lib/a2a/card-generator.ts @@ -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; @@ -197,15 +200,17 @@ export async function upsertAgentCard( ): Promise { 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)), + }, + }) + ); }