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
1 change: 1 addition & 0 deletions docs/docs/security/rbac/file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<agent_id>` 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:<org>#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:<id>#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` |
Expand Down
113 changes: 109 additions & 4 deletions ui/e2e/rbac/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, "view" | "comment">;
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 }] : [],
Expand All @@ -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,
Expand All @@ -63,19 +85,81 @@ export async function installChatBootMocks(
): Promise<void> {
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());
Expand Down Expand Up @@ -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`) &&
Expand Down
88 changes: 87 additions & 1 deletion ui/e2e/rbac/chat-navigation-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}) => {
Expand Down
Loading
Loading