From e07bebdee52a257426aa917dba4fce4b8ad8f3af Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 20:01:23 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(rls):=20Phase=201=20#12=20=E2=80=94=20?= =?UTF-8?q?AgentCard=20RLS=20+=20callsite=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 20260606000000: enable RLS + FORCE on AgentCard; SELECT policy allows own-org cards (EXISTS subquery against Agent) plus cross-org isPublic=true reads; INSERT/UPDATE/DELETE require EXISTS match; no organizationId column (agentId UNIQUE -> single-row PK lookup) - card-generator: upsertAgentCard wraps agentCard.upsert in withAdminBypass (fire-and-forget write, no org context available) --- .../migration.sql | 53 +++++++++++++++++++ src/lib/a2a/card-generator.ts | 25 +++++---- 2 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260606000000_rls_phase1_agentcard/migration.sql 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/src/lib/a2a/card-generator.ts b/src/lib/a2a/card-generator.ts index f9c13763..f0c15feb 100644 --- a/src/lib/a2a/card-generator.ts +++ b/src/lib/a2a/card-generator.ts @@ -1,4 +1,5 @@ import { prisma, prismaRead } from "@/lib/prisma"; +import { withAdminBypass } from "@/lib/api/tenant-context"; export interface A2ASkill { id: string; @@ -197,15 +198,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)), + }, + }) + ); } From 7d4543d0bf905e97bc26cc9141fcedb0bb1046cf Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 20:09:17 +0200 Subject: [PATCH 2/5] =?UTF-8?q?docs(rls):=20ADR-0001=20=E2=80=94=20AgentCa?= =?UTF-8?q?rd=20RLS=20classification=20(TENANT=5FINDIRECT=20via=20EXISTS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0001-agentcard-rls-classification.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/adr/0001-agentcard-rls-classification.md 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`. From a4837f32aa8544cef4bde83aa6a0dce2104b391a Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 20:10:27 +0200 Subject: [PATCH 3/5] =?UTF-8?q?docs(rls):=20update=20runbook=20=C2=A72.3?= =?UTF-8?q?=20=E2=80=94=20AgentCard=20reclassified=20TENANT=5FINDIRECT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rls-phase-1-cutover-runbook.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/rls-phase-1-cutover-runbook.md b/docs/rls-phase-1-cutover-runbook.md index beb313e5..145eab8a 100644 --- a/docs/rls-phase-1-cutover-runbook.md +++ b/docs/rls-phase-1-cutover-runbook.md @@ -145,7 +145,7 @@ Apply in this exact sequence. Each row = one migration PR. | 9 | `HeartbeatRun` | TENANT_DIRECT | no | Append-only; low risk after HeartbeatConfig/Context pass | | 10 | `ApprovalPolicy` | TENANT_DIRECT | no | Board governance; `ADMIN_USER_IDS` bypass path must work | | 11 | `PolicyDecision` | TENANT_DIRECT | no | FK to ApprovalPolicy; apply immediately after #10 | -| 12 | `AgentCard` | TENANT_DIRECT | **yes** | First `isPublic` table; validates public-read policy works | +| 12 | `AgentCard` | TENANT_INDIRECT (EXISTS via Agent) | **yes** | No `organizationId` column; policy uses EXISTS subquery against `Agent.id` (UNIQUE → single-row PK lookup). First `isPublic` table; see ADR-0001 | | 13 | `Template` | TENANT_DIRECT | **yes** | Marketplace reads; confirm `isPublic=true` rows visible cross-org | | 14 | `Agent` | TENANT_DIRECT | **yes** | Highest traffic; applied last after all simpler tables are stable | @@ -207,7 +207,11 @@ INSERT INTO "{TABLE}" (..., "organizationId") VALUES (..., 'smoke-org-b'); ROLLBACK; ``` -### 3.3 `isPublic` pattern (Agent, AgentCard, Template) +### 3.3 `isPublic` pattern (Agent, Template) + +> **Note — AgentCard:** AgentCard uses TENANT_INDIRECT (EXISTS subquery against `Agent.id`), +> not a direct `organizationId` column. Its `isPublic` smoke test differs: query by `agentId` +> rather than `organizationId`. See ADR-0001 and the AgentCard-specific queries below. ```sql -- Setup: one public row in org B (as admin_user) @@ -462,13 +466,18 @@ OR error.value:*insufficient_privilege* ``` -### A.4 14 TENANT_DIRECT tables at a glance +### A.4 Tables at a glance ``` +TENANT_DIRECT (13 tables): OrganizationMember Invitation CompanyMission Department Goal AgentPermission HeartbeatConfig HeartbeatContext -HeartbeatRun ApprovalPolicy PolicyDecision AgentCard* +HeartbeatRun ApprovalPolicy PolicyDecision Template* Agent* -* isPublic tables — require extended policy (§8.2 of PLAN-V2) +TENANT_INDIRECT (1 table): +AgentCard† (EXISTS subquery via Agent.id — see ADR-0001) + +* isPublic tables — require extended SELECT policy (§8.2 of PLAN-V2) +† No organizationId column; public read via isPublic = true OR EXISTS(Agent) ``` From 8ce180c1a4e3fb5ed7282ab0f781a860aa66dc21 Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 20:22:18 +0200 Subject: [PATCH 4/5] test(rls): cross-tenant + isPublic tests for AgentCard (Phase 1 #12) --- docs/rls-phase-1-cutover-runbook.md | 19 +-- .../test.sql | 137 ++++++++++++++++++ 2 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260606000000_rls_phase1_agentcard/test.sql diff --git a/docs/rls-phase-1-cutover-runbook.md b/docs/rls-phase-1-cutover-runbook.md index 145eab8a..beb313e5 100644 --- a/docs/rls-phase-1-cutover-runbook.md +++ b/docs/rls-phase-1-cutover-runbook.md @@ -145,7 +145,7 @@ Apply in this exact sequence. Each row = one migration PR. | 9 | `HeartbeatRun` | TENANT_DIRECT | no | Append-only; low risk after HeartbeatConfig/Context pass | | 10 | `ApprovalPolicy` | TENANT_DIRECT | no | Board governance; `ADMIN_USER_IDS` bypass path must work | | 11 | `PolicyDecision` | TENANT_DIRECT | no | FK to ApprovalPolicy; apply immediately after #10 | -| 12 | `AgentCard` | TENANT_INDIRECT (EXISTS via Agent) | **yes** | No `organizationId` column; policy uses EXISTS subquery against `Agent.id` (UNIQUE → single-row PK lookup). First `isPublic` table; see ADR-0001 | +| 12 | `AgentCard` | TENANT_DIRECT | **yes** | First `isPublic` table; validates public-read policy works | | 13 | `Template` | TENANT_DIRECT | **yes** | Marketplace reads; confirm `isPublic=true` rows visible cross-org | | 14 | `Agent` | TENANT_DIRECT | **yes** | Highest traffic; applied last after all simpler tables are stable | @@ -207,11 +207,7 @@ INSERT INTO "{TABLE}" (..., "organizationId") VALUES (..., 'smoke-org-b'); ROLLBACK; ``` -### 3.3 `isPublic` pattern (Agent, Template) - -> **Note — AgentCard:** AgentCard uses TENANT_INDIRECT (EXISTS subquery against `Agent.id`), -> not a direct `organizationId` column. Its `isPublic` smoke test differs: query by `agentId` -> rather than `organizationId`. See ADR-0001 and the AgentCard-specific queries below. +### 3.3 `isPublic` pattern (Agent, AgentCard, Template) ```sql -- Setup: one public row in org B (as admin_user) @@ -466,18 +462,13 @@ OR error.value:*insufficient_privilege* ``` -### A.4 Tables at a glance +### A.4 14 TENANT_DIRECT tables at a glance ``` -TENANT_DIRECT (13 tables): OrganizationMember Invitation CompanyMission Department Goal AgentPermission HeartbeatConfig HeartbeatContext -HeartbeatRun ApprovalPolicy PolicyDecision +HeartbeatRun ApprovalPolicy PolicyDecision AgentCard* Template* Agent* -TENANT_INDIRECT (1 table): -AgentCard† (EXISTS subquery via Agent.id — see ADR-0001) - -* isPublic tables — require extended SELECT policy (§8.2 of PLAN-V2) -† No organizationId column; public read via isPublic = true OR EXISTS(Agent) +* isPublic tables — require extended policy (§8.2 of PLAN-V2) ``` 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) From 99459ddb80f4174b8efeb5863f84037b7694f933 Mon Sep 17 00:00:00 2001 From: buky Date: Sun, 24 May 2026 20:26:33 +0200 Subject: [PATCH 5/5] chore(rls): document prismaRead role assumption in card-generator (Phase 1 #12) --- src/lib/a2a/card-generator.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/a2a/card-generator.ts b/src/lib/a2a/card-generator.ts index f0c15feb..e746ec34 100644 --- a/src/lib/a2a/card-generator.ts +++ b/src/lib/a2a/card-generator.ts @@ -1,3 +1,5 @@ +// 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";