diff --git a/docs/docs/security/rbac/file-map.md b/docs/docs/security/rbac/file-map.md index 067a68841c..b5195b64a3 100644 --- a/docs/docs/security/rbac/file-map.md +++ b/docs/docs/security/rbac/file-map.md @@ -31,6 +31,7 @@ When you need to change something in the auth path, this table tells you which f | Dynamic agents JWT validation & userinfo | `ai_platform_engineering/dynamic_agents/src/dynamic_agents/auth/auth.py` | | Dynamic Agents Web UI backend execution proxy auth and UI shell: session/bearer authentication, stable subject and role propagation, `X-User-Context` browser-session fallback when the server-side Keycloak token cache is missing, backend bearer forwarding when available, W3C `traceparent` forwarding, no `OIDC_REQUIRED_DYNAMIC_AGENTS_GROUP`/admin-only UI gate for the Agents page, Conversations tab shown only when OpenFGA admin audit-log access is granted, the Models tab uses the LLM provider credential surface plus the OpenFGA-filtered model list, and conversation-scoped file proxies gated by `dynamic_agent#invoke` | `ui/src/lib/da-proxy.ts`, `ui/src/lib/auth-config.ts`, `ui/src/components/layout/AppHeader.tsx`, `ui/src/app/(app)/dynamic-agents/page.tsx`, `ui/src/app/(app)/dynamic-agents/__tests__/page.test.tsx`, `ui/src/components/dynamic-agents/LLMProvidersTab.tsx`, `ui/src/components/dynamic-agents/__tests__/LLMProvidersTab.test.tsx`, `ui/src/components/layout/__tests__/AppHeader.test.tsx`, `ui/src/app/api/dynamic-agents/conversations/[id]/files/list/route.ts`, `ui/src/app/api/dynamic-agents/conversations/[id]/files/content/route.ts`, `ui/src/lib/__tests__/da-proxy-auth-result.test.ts`, `ui/src/lib/__tests__/auth-config.test.ts` | | Dynamic Agents Web UI backend OpenFGA execution, picker, and conversation binding gates: subject-first, email-fallback `can_use agent:` checks for start/invoke/resume/cancel, chat agent selection, subagent selection, and new conversation agent bindings; implicit-owner-or-explicit conversation checks on chat routes; authz trace creation and `openfga_rebac` audit emission | `ui/src/lib/rbac/openfga-agent-authz.ts`, `ui/src/lib/rbac/authz-tracing.ts`, `ui/src/lib/rbac/resource-authz.ts`, `ui/src/lib/rbac/conversation-implicit-authz.ts`, `ui/src/app/api/v1/chat/_conversation-authz.ts`, `ui/src/app/api/dynamic-agents/available/route.ts`, `ui/src/app/api/dynamic-agents/available-subagents/route.ts`, `ui/src/app/api/chat/conversations/route.ts`, `ui/src/lib/rbac/__tests__/resource-authz.test.ts`, `ui/src/lib/rbac/__tests__/openfga-agent-authz.test.ts`, `ui/src/lib/rbac/__tests__/authz-tracing.test.ts`, `ui/src/lib/rbac/__tests__/audit-tracing.test.ts`, `ui/src/app/api/v1/chat/stream/start/route.ts`, `ui/src/app/api/v1/chat/invoke/route.ts`, `ui/src/app/api/v1/chat/stream/resume/route.ts`, `ui/src/app/api/v1/chat/stream/cancel/route.ts`, `ui/src/app/api/v1/chat/__tests__/routes.test.ts`, `ui/src/app/api/dynamic-agents/__tests__/route-rbac.test.ts`, `ui/src/app/api/__tests__/chat-conversations-agent-auth.test.ts` | +| Chat conversation list sharing metadata — `GET /api/chat/conversations` bounds Mongo candidates, filters through ReBAC, marks `viewer_has_shared_access`, and returns a display-only `access_level` for the sidebar/share-button UX. Direct and team recipients still derive read-only vs edit from `sharing_access` / `sharing.team_permissions`; detail, message, turn, and share routes keep using `requireConversationAccess` for authoritative enforcement. | `ui/src/app/api/chat/conversations/route.ts`, `ui/src/lib/rbac/conversation-implicit-authz.ts`, `ui/src/lib/api-middleware.ts`, `ui/src/components/layout/Sidebar.tsx`, `ui/src/components/chat/ShareButton.tsx`, `ui/src/app/api/__tests__/chat-conversations-client-type.test.ts`, `ui/src/app/api/__tests__/chat-conversations-agent-auth.test.ts`, `ui/src/app/api/__tests__/chat-conversations-remaining-rbac.test.ts`, `ui/e2e/rbac/chat-share-exposed.spec.ts` | | Connections & Secrets credential management: feature flag, signed-in-only and `organization:#can_use` page gate, MongoDB envelope store, env/ESO OAuth connector startup bootstrap, browser guardrails for raw retrieval, service-to-service retrieve/exchange APIs with service JWT validation, JWT-`sub` provider-key exchange, future AgentGateway credential injector route, and `secret_ref:provider_connection:#can_use`, browser-safe OAuth profile validators and automatic refresh endpoints that return only redacted provider/refresh metadata, deep-linked user My Secrets/My Connections tabs, user-visible enabled OAuth connector list with Connect/Relink and Check Profile actions, per-user OAuth scope selection at connect time (My Connections "Advanced settings" lets a user narrow within the connector's allowed `scopes`; the connect route accepts `?scopes=`, `startConnection` bounds it via `boundScopes`, and the choice is persisted as `requestedScopes`/`grantedScopes` on `provider_connections` so relink pre-fills it), Dynamic Agents LLM provider credentials saved as OpenFGA-governed credential secrets, OpenFGA-backed global admin secret metadata management with per-secret row-level audit details, deep-linked OAuth connector/admin secret tabs, Dynamic Agents MCP provider credential-ref resolution, and Jira MCP provider-token header consumption | `ui/src/lib/feature-flags/credentials.ts`, `ui/src/lib/credentials/`, `ui/src/lib/seed-config.ts`, `ui/src/app/(app)/credentials/page.tsx`, `ui/src/app/(app)/credentials/__tests__/page.test.tsx`, `ui/src/app/api/credentials/`, `ui/src/app/api/credentials/inject/[provider]/route.ts`, `ui/src/app/api/credentials/inject/[provider]/__tests__/route.test.ts`, `ui/src/app/api/credentials/connections/[connection_id]/profile/route.ts`, `ui/src/app/api/credentials/connections/[connection_id]/profile/__tests__/route.test.ts`, `ui/src/app/api/credentials/connections/[connection_id]/refresh/route.ts`, `ui/src/app/api/credentials/connections/[connection_id]/refresh/__tests__/route.test.ts`, `ui/src/app/api/admin/credentials/secrets/route.ts`, `ui/src/app/api/admin/credentials/secrets/[secret_id]/route.ts`, `ui/src/app/api/admin/credentials/oauth-connectors/route.ts`, `ui/src/app/api/admin/credentials/oauth-connectors/[connector_id]/route.ts`, `ui/src/app/api/admin/credentials/audit/route.ts`, `ui/src/components/credentials/`, `ui/src/components/dynamic-agents/LLMProvidersTab.tsx`, `docker-compose.dev.yaml`, `docker-compose.yaml`, `charts/ai-platform-engineering/charts/caipe-ui/values.yaml`, `charts/ai-platform-engineering/values.yaml`, `ai_platform_engineering/dynamic_agents/src/dynamic_agents/services/credential_exchange.py`, `ai_platform_engineering/dynamic_agents/src/dynamic_agents/services/mcp_client.py`, `ai_platform_engineering/dynamic_agents/src/dynamic_agents/models.py`, `ai_platform_engineering/agents/jira/mcp/mcp_jira/api/client.py`, `ai_platform_engineering/agents/jira/mcp/tests/test_client.py` | | Remaining OpenFGA cutovers for conversations, skills, workflow runs, MCP servers, and RAG tools: shared/search/trash conversation candidate filtering, conversation share/pin/archive/restore resource checks, skill nested route/import authorization, workflow-run parent-config checks, self-service `mcp_server` owner/team tuples, and concrete RAG `tool` checks | `ui/src/app/api/chat/shared/route.ts`, `ui/src/app/api/chat/search/route.ts`, `ui/src/app/api/chat/conversations/trash/route.ts`, `ui/src/app/api/chat/conversations/[id]/share/route.ts`, `ui/src/app/api/chat/conversations/[id]/pin/route.ts`, `ui/src/app/api/chat/conversations/[id]/archive/route.ts`, `ui/src/app/api/chat/conversations/[id]/restore/route.ts`, `ui/src/app/api/__tests__/chat-conversations-remaining-rbac.test.ts`, `ui/src/lib/agent-skill-visibility.ts`, `ui/src/app/api/skills/configs/route.ts`, `ui/src/app/api/skills/configs/import-zip/route.ts`, `ui/src/app/api/skills/configs/[id]/revisions/route.ts`, `ui/src/app/api/skills/configs/[id]/revisions/[revisionId]/route.ts`, `ui/src/app/api/skills/configs/[id]/revisions/[revisionId]/restore/route.ts`, `ui/src/app/api/skills/configs/[id]/export/route.ts`, `ui/src/app/api/skills/configs/[id]/clone/route.ts`, `ui/src/app/api/__tests__/agent-skill-subroutes-rbac.test.ts`, `ui/src/app/api/skills/configs/__tests__/route-rbac.test.ts`, `ui/src/app/api/skills/configs/import-zip/__tests__/route.test.ts`, `ui/src/app/api/workflow-runs/route.ts`, `ui/src/app/api/workflow-runs/[id]/resume/route.ts`, `ui/src/app/api/workflow-runs/[id]/cancel/route.ts`, `ui/src/app/api/__tests__/workflow-runs-rbac.test.ts`, `ui/src/app/api/mcp-servers/route.ts`, `ui/src/lib/rbac/openfga-owned-resources.ts`, `ui/src/app/api/__tests__/mcp-servers-rbac.test.ts`, `ui/src/app/api/rag/tools/route.ts`, `ui/src/app/api/rag/tools/[toolId]/route.ts` | | Web UI message/metadata persistence — forwards cookie-session or first-party bearer access tokens to the backend and uses implicit-owner-or-explicit conversation read/write checks, including Slack OBO thread metadata updates | `ui/src/app/api/chat/conversations/[id]/messages/route.ts`, `ui/src/app/api/chat/conversations/[id]/metadata/route.ts`, `ui/src/app/api/__tests__/chat-conversation-metadata-auth.test.ts`, `ui/src/app/api/__tests__/owner-id-denormalization.test.ts` | diff --git a/ui/e2e/rbac/_helpers.ts b/ui/e2e/rbac/_helpers.ts index 45cf44e9dd..0d0216a892 100644 --- a/ui/e2e/rbac/_helpers.ts +++ b/ui/e2e/rbac/_helpers.ts @@ -22,21 +22,40 @@ type TestCredentials = { type ChatBootMocksOptions = { conversationId?: string; ownerEmail?: string; + title?: string; /** When true (default), GET /api/chat/conversations returns the fixture conversation. */ seedExistingConversation?: boolean; /** Artificial delay for the conversation list GET (simulates Sidebar + /chat racing). */ conversationListDelayMs?: number; /** When set, seeds an agent participant on the conversation fixture. */ agentId?: string; + sharing?: { + is_public?: boolean; + public_permission?: "view" | "comment"; + shared_with?: string[]; + shared_with_teams?: string[]; + team_permissions?: Record; + share_link_enabled?: boolean; + }; + viewerHasSharedAccess?: boolean; + accessLevel?: "owner" | "shared" | "shared_readonly" | "admin_audit"; onConversationListRequest?: (url: URL) => void; onConversationCreate?: () => void; }; -function chatConversationFixture(id: string, ownerEmail: string, agentId?: string) { +function chatConversationFixture( + id: string, + ownerEmail: string, + agentId?: string, + options: Pick< + ChatBootMocksOptions, + "accessLevel" | "sharing" | "title" | "viewerHasSharedAccess" + > = {}, +) { const now = new Date().toISOString(); return { _id: id, - title: "RBAC E2E Conversation", + title: options.title ?? "RBAC E2E Conversation", client_type: "webui", owner_id: ownerEmail, participants: agentId ? [{ type: "agent", id: agentId }] : [], @@ -48,7 +67,10 @@ function chatConversationFixture(id: string, ownerEmail: string, agentId?: strin shared_with: [], shared_with_teams: [], share_link_enabled: false, + ...options.sharing, }, + viewer_has_shared_access: options.viewerHasSharedAccess, + access_level: options.accessLevel, tags: [], is_archived: false, is_pinned: false, @@ -63,19 +85,81 @@ export async function installChatBootMocks( ): Promise { const conversationId = options.conversationId ?? "rbac-e2e-conversation"; const ownerEmail = options.ownerEmail ?? env.user.email; - const conversation = chatConversationFixture(conversationId, ownerEmail, options.agentId); + const conversation = chatConversationFixture(conversationId, ownerEmail, options.agentId, { + accessLevel: options.accessLevel, + sharing: options.sharing, + title: options.title, + viewerHasSharedAccess: options.viewerHasSharedAccess, + }); const seedExistingConversation = options.seedExistingConversation !== false; const conversationListDelayMs = options.conversationListDelayMs ?? 0; let created = seedExistingConversation; + const directSharePermission = options.accessLevel === "shared_readonly" ? "view" : "comment"; await page.route("**/api/admin/platform-config", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ success: true, data: { default_agent_id: null } }), + body: JSON.stringify({ + success: true, + data: { + default_agent_id: options.agentId ?? null, + release_notes: { enabled: false }, + }, + }), }); }); + if (options.agentId) { + const agent = { + _id: options.agentId, + name: "RBAC E2E Agent", + description: "Mocked dynamic agent for chat browser regressions", + enabled: true, + skills: [], + ui: {}, + }; + + await page.route("**/api/dynamic-agents**", async (route) => { + const request = route.request(); + const requestUrl = new URL(request.url()); + const method = request.method(); + const path = requestUrl.pathname; + + if (path === "/api/dynamic-agents/available" && method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: [agent] }), + }); + return; + } + + if (path === `/api/dynamic-agents/agents/${options.agentId}` && method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: agent }), + }); + return; + } + + if (path === "/api/dynamic-agents" && method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + data: { items: [agent], total: 1, page: 1, page_size: 20 }, + }), + }); + return; + } + + await route.continue(); + }); + } + await page.route("**/api/chat/conversations**", async (route) => { const request = route.request(); const requestUrl = new URL(request.url()); @@ -128,6 +212,27 @@ export async function installChatBootMocks( return; } + if (path === `/api/chat/conversations/${conversationId}/share` && method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + data: { + sharing: conversation.sharing, + access_list: (conversation.sharing.shared_with ?? []).map((email: string) => ({ + conversation_id: conversationId, + granted_by: ownerEmail, + granted_to: email, + permission: directSharePermission, + granted_at: new Date().toISOString(), + })), + }, + }), + }); + return; + } + if ( (path === `/api/chat/conversations/${conversationId}/turns` || path === `/api/chat/conversations/${conversationId}/messages`) && diff --git a/ui/e2e/rbac/chat-navigation-regression.spec.ts b/ui/e2e/rbac/chat-navigation-regression.spec.ts index 80ccc1773d..b49fb24b6f 100644 --- a/ui/e2e/rbac/chat-navigation-regression.spec.ts +++ b/ui/e2e/rbac/chat-navigation-regression.spec.ts @@ -14,6 +14,8 @@ const CHAT_MEMBER_SESSION = { email: "member@caipe.local", subject: "playwright-chat-member-sub", }; +const CHAT_AGENT_ID = "agent-rbac-e2e"; +const SHARED_OWNER_EMAIL = "owner@caipe.local"; function minimalSessionEnv() { return { @@ -30,7 +32,10 @@ async function bootChatSession( ) { test.skip(!process.env.NEXTAUTH_SECRET, "NEXTAUTH_SECRET required for chat navigation SSR."); const env = minimalSessionEnv(); - await installChatBootMocks(page, env, options); + await installChatBootMocks(page, env, { + agentId: CHAT_AGENT_ID, + ...options, + }); await installTestSession(page, env, { email: CHAT_MEMBER_SESSION.email, subject: CHAT_MEMBER_SESSION.subject, @@ -68,6 +73,87 @@ test.describe("mocked RBAC e2e — chat navigation regression", () => { expect(createCallCount).toBe(0); }); + test("copies the link for read-only shared conversation recipients", async ({ + context, + page, + }) => { + const conversationId = "rbac-shared-recipient-conv"; + const conversationTitle = "Readonly Shared Recipient Sidebar Chat"; + const sharedByText = `Shared by ${SHARED_OWNER_EMAIL}`; + const env = await bootChatSession(page, { + accessLevel: "shared_readonly", + conversationId, + ownerEmail: SHARED_OWNER_EMAIL, + sharing: { shared_with: [CHAT_MEMBER_SESSION.email] }, + title: conversationTitle, + viewerHasSharedAccess: true, + }); + + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: env.baseUrl, + }); + + await page.goto(`/chat/${conversationId}`, { waitUntil: "domcontentloaded" }); + await dismissReleaseUpgradeDialog(page); + + const sidebarConversationTitle = page.getByTitle(conversationTitle); + await expect(sidebarConversationTitle).toBeVisible(); + + const recipientShareButton = page.getByRole("button", { name: sharedByText }); + await expect(recipientShareButton).toBeVisible(); + await recipientShareButton.hover({ force: true }); + + await expect(page.getByText(sharedByText)).toBeVisible(); + await expect(page.getByText("Click to copy link")).toBeVisible(); + + await recipientShareButton.click({ force: true }); + await expect(page.getByText("Link copied")).toBeVisible(); + await expect(page.getByRole("dialog", { name: /shared conversation/i })).toHaveCount(0); + + const copiedUrl = await page.evaluate(() => navigator.clipboard.readText()); + expect(copiedUrl).toBe(`${env.baseUrl}/chat/${conversationId}`); + }); + + test("opens sharing details for edit-mode shared conversation recipients", async ({ + page, + }) => { + const conversationId = "rbac-edit-shared-recipient-conv"; + const conversationTitle = "Edit Shared Recipient Sidebar Chat"; + const sharedByText = `Shared by ${SHARED_OWNER_EMAIL}`; + const env = await bootChatSession(page, { + accessLevel: "shared", + conversationId, + ownerEmail: SHARED_OWNER_EMAIL, + sharing: { shared_with: [CHAT_MEMBER_SESSION.email] }, + title: conversationTitle, + viewerHasSharedAccess: true, + }); + + await page.goto(`/chat/${conversationId}`, { waitUntil: "domcontentloaded" }); + await dismissReleaseUpgradeDialog(page); + + const sidebarConversationTitle = page.getByTitle(conversationTitle); + await expect(sidebarConversationTitle).toBeVisible(); + + const recipientShareButton = page.getByRole("button", { name: sharedByText }); + await expect(recipientShareButton).toBeVisible(); + await recipientShareButton.hover({ force: true }); + + await expect(page.getByText(sharedByText)).toBeVisible(); + await expect(page.getByText("Click to copy link")).toHaveCount(0); + + await recipientShareButton.click({ force: true }); + const dialog = page.getByRole("dialog", { name: "Shared Conversation" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Shared by")).toBeVisible(); + await expect(dialog.getByText(SHARED_OWNER_EMAIL)).toBeVisible(); + await expect(dialog.getByText("Share Link")).toBeVisible(); + await expect(dialog.locator('input[readonly]').first()).toHaveValue(`${env.baseUrl}/chat/${conversationId}`); + await expect(dialog.getByText("Can edit")).toBeVisible(); + await expect(dialog.getByPlaceholder("Search by email or team name...")).toHaveCount(0); + await expect(dialog.getByRole("switch", { name: "Share with everyone" })).toHaveCount(0); + }); + test("waits for a slow conversation list fetch instead of creating a duplicate chat", async ({ page, }) => { diff --git a/ui/e2e/rbac/chat-share-exposed.spec.ts b/ui/e2e/rbac/chat-share-exposed.spec.ts index 53dec6f2bf..d49554b371 100644 --- a/ui/e2e/rbac/chat-share-exposed.spec.ts +++ b/ui/e2e/rbac/chat-share-exposed.spec.ts @@ -14,13 +14,15 @@ * - Private conversations that should never be shared are not displayed * - API requests are made with correct scoping parameters * - Empty states render per tab when there are no matching conversations + * - Related list/search/trash endpoints do not expose unshared private chats + * - The grant API rejects conversation discovery grants to everyone * * All API calls are mocked so no live backend is required. * Enable with: RUN_RBAC_REGRESSION=1 npx playwright test --config=playwright.rbac.config.ts */ -import { expect, test } from "@playwright/test"; -import { fulfillJson, installMockedRbacApp, mockedRbacEnabled } from "./_mocked-rbac"; +import { expect, test, type Page } from "@playwright/test"; +import { fulfillJson, installMockedRbacApp, mockedRbacEnabled, postJson } from "./_mocked-rbac"; // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -58,7 +60,27 @@ function makeConversation( }; } -function paginatedResponse(items: ReturnType[]) { +type ConversationFixture = ReturnType; + +type ApiResult = { + status: number; + body: T; +}; + +type ConversationListBody = { + success?: boolean; + data?: { + items?: ConversationFixture[]; + }; +}; + +type GrantRequestBody = { + resource?: { type?: string; id?: string }; + grantee?: { type?: string; id?: string }; + capability?: string; +}; + +function paginatedResponse(items: ConversationFixture[]) { return { success: true, data: { items, total: items.length, page: 1, page_size: 20 }, @@ -68,15 +90,52 @@ function paginatedResponse(items: ReturnType[]) { // ─── Helpers ───────────────────────────────────────────────────────────────── type SharedApiOptions = { - items?: ReturnType[]; + items?: ConversationFixture[]; + conversationItems?: ConversationFixture[]; + searchItems?: ConversationFixture[]; + trashItems?: ConversationFixture[]; onRequest?: (url: URL) => void; + onConversationRequest?: (url: URL) => void; + onSearchRequest?: (url: URL) => void; + onTrashRequest?: (url: URL) => void; }; +async function fetchJson(page: Page, path: string, init?: RequestInit): Promise> { + return page.evaluate( + async ({ path: requestPath, init: requestInit }) => { + const response = await fetch(requestPath, requestInit); + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + return { status: response.status, body }; + }, + { path, init }, + ) as Promise>; +} + +async function postJsonFromPage( + page: Page, + path: string, + body: unknown, +): Promise> { + return fetchJson(page, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + async function installHomePageMocks( page: Parameters[0], options: SharedApiOptions = {}, ) { const sharedItems = options.items ?? []; + const conversationItems = options.conversationItems ?? []; + const searchItems = options.searchItems ?? []; + const trashItems = options.trashItems ?? []; // Force MongoDB storage mode so the SharedConversations section renders. // The server injects __APP_CONFIG__ via an inline