From 08dcdc0dc609532a1cacf5ab07ac0c4da858a5d8 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 18:32:27 +0545 Subject: [PATCH 01/48] feat: MCP settings paget pu --- src/App.tsx | 280 +++++++----- src/api/services/permissions.ts | 105 ++++- src/api/services/playbooks.ts | 32 +- src/api/types/permissions.ts | 3 +- src/components/MCP/McpTabsLinks.tsx | 94 ++++ src/components/MCP/PermissionAccessCard.tsx | 210 +++++++++ src/components/MCP/PlaybooksTable.tsx | 185 ++++++++ src/components/MCP/SubjectSelectorModal.tsx | 235 ++++++++++ src/components/MCP/ViewsTable.tsx | 184 ++++++++ src/pages/Settings/mcp/McpOverviewPage.tsx | 30 ++ src/pages/Settings/mcp/McpPlaybooksPage.tsx | 416 ++++++++++++++++++ src/pages/Settings/mcp/McpViewsPage.tsx | 463 ++++++++++++++++++++ src/services/permissions/features.ts | 3 +- src/ui/FormControls/Switch.tsx | 21 +- 14 files changed, 2145 insertions(+), 116 deletions(-) create mode 100644 src/components/MCP/McpTabsLinks.tsx create mode 100644 src/components/MCP/PermissionAccessCard.tsx create mode 100644 src/components/MCP/PlaybooksTable.tsx create mode 100644 src/components/MCP/SubjectSelectorModal.tsx create mode 100644 src/components/MCP/ViewsTable.tsx create mode 100644 src/pages/Settings/mcp/McpOverviewPage.tsx create mode 100644 src/pages/Settings/mcp/McpPlaybooksPage.tsx create mode 100644 src/pages/Settings/mcp/McpViewsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index cb70bfd0b1..6b7179a2e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -210,6 +210,18 @@ const NotificationsSilencedPage = dynamic( ) ); +const McpOverviewPage = dynamic( + () => import("@flanksource-ui/pages/Settings/mcp/McpOverviewPage") +); + +const McpPlaybooksPage = dynamic( + () => import("@flanksource-ui/pages/Settings/mcp/McpPlaybooksPage") +); + +const McpViewsPage = dynamic( + () => import("@flanksource-ui/pages/Settings/mcp/McpViewsPage") +); + const UsersPage = dynamic(() => import("@flanksource-ui/pages/UsersPage").then((m) => m.UsersPage) ); @@ -380,112 +392,139 @@ const settingsNav: SettingsNavigationItems = { name: "Settings", icon: AdjustmentsIcon, checkPath: false, - submenu: [ - { - name: "Connections", - href: "/settings/connections", - icon: BsLink, - featureName: features["settings.connections"], - resourceName: tables.connections - }, - { - name: "Permissions", - href: "/settings/permissions", - icon: RiShieldUserFill, - featureName: features["settings.permissions"], - resourceName: tables.permissions - }, - { - name: "Scopes", - href: "/settings/scopes", - icon: FaCrosshairs, - featureName: features["settings.permissions"], - resourceName: tables.scopes - }, - ...(process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true" - ? [] - : [ - { - name: "Users", - href: "/settings/users", - icon: HiUser, - featureName: features["settings.users"], - resourceName: tables.identities - } - ]), - ...schemaResourceTypes - // remove catalog_scraper from settings - .filter((resource) => resource.table !== "config_scrapers") - .map((x) => ({ - ...x, - href: `/settings/${x.table}` - })), - { - name: "Jobs History", - href: "/settings/jobs", - icon: FaTasks, - featureName: features["settings.job_history"], - resourceName: tables.database - }, - { - name: "Feature Flags", - href: "/settings/feature-flags", - icon: BsToggles, - featureName: features["settings.feature_flags"], - resourceName: tables.database - }, - { - name: "Log Backends", - href: "/settings/log-backends", - icon: LogsIcon, - featureName: features["logs"], - resourceName: tables.database - }, - { - name: "Event Queue", - href: "/settings/event-queue-status", - icon: FaTasks, - featureName: features["settings.event_queue_status"], - resourceName: tables.database - }, - { - name: "Agents", - href: "/settings/agents", - icon: MdOutlineSupportAgent, - featureName: features.agents, - resourceName: tables.database - }, - { - name: "Tokens", - href: "/settings/tokens", - icon: VscKey, - featureName: features.agents, - resourceName: tables.database - }, - { - name: "Notifications", - href: "/notifications/rules", - icon: FaBell, - featureName: features["settings.notifications"], - resourceName: tables.notifications - }, - { - name: "Integrations", - href: "/settings/integrations", - icon: MdOutlineIntegrationInstructions, - featureName: features["settings.integrations"], - resourceName: tables.database - }, - { - name: "Views", - href: "/settings/views", - icon: ({ className }: { className: string }) => ( - - ), - featureName: features.views, - resourceName: tables.views + submenu: (() => { + const sortedSubmenu = [ + { + name: "Connections", + href: "/settings/connections", + icon: BsLink, + featureName: features["settings.connections"], + resourceName: tables.connections + }, + { + name: "Permissions", + href: "/settings/permissions", + icon: RiShieldUserFill, + featureName: features["settings.permissions"], + resourceName: tables.permissions + }, + { + name: "Scopes", + href: "/settings/scopes", + icon: FaCrosshairs, + featureName: features["settings.permissions"], + resourceName: tables.scopes + }, + ...(process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true" + ? [] + : [ + { + name: "Users", + href: "/settings/users", + icon: HiUser, + featureName: features["settings.users"], + resourceName: tables.identities + } + ]), + ...schemaResourceTypes + // remove catalog_scraper from settings + .filter((resource) => resource.table !== "config_scrapers") + .map((x) => ({ + ...x, + href: `/settings/${x.table}` + })), + { + name: "Jobs History", + href: "/settings/jobs", + icon: FaTasks, + featureName: features["settings.job_history"], + resourceName: tables.database + }, + { + name: "MCP", + href: "/settings/mcp", + icon: ({ className }: { className: string }) => ( + + ), + featureName: features["settings.mcp"], + resourceName: tables.database + }, + { + name: "Feature Flags", + href: "/settings/feature-flags", + icon: BsToggles, + featureName: features["settings.feature_flags"], + resourceName: tables.database + }, + { + name: "Log Backends", + href: "/settings/log-backends", + icon: LogsIcon, + featureName: features["logs"], + resourceName: tables.database + }, + { + name: "Event Queue", + href: "/settings/event-queue-status", + icon: FaTasks, + featureName: features["settings.event_queue_status"], + resourceName: tables.database + }, + { + name: "Agents", + href: "/settings/agents", + icon: MdOutlineSupportAgent, + featureName: features.agents, + resourceName: tables.database + }, + { + name: "Tokens", + href: "/settings/tokens", + icon: VscKey, + featureName: features.agents, + resourceName: tables.database + }, + { + name: "Notifications", + href: "/notifications/rules", + icon: FaBell, + featureName: features["settings.notifications"], + resourceName: tables.notifications + }, + { + name: "Integrations", + href: "/settings/integrations", + icon: MdOutlineIntegrationInstructions, + featureName: features["settings.integrations"], + resourceName: tables.database + }, + { + name: "Views", + href: "/settings/views", + icon: ({ className }: { className: string }) => ( + + ), + featureName: features.views, + resourceName: tables.views + } + ].sort((v1, v2) => stringSortHelper(v1.name, v2.name)); + + const jobsHistoryIndex = sortedSubmenu.findIndex( + (item) => item.name === "Jobs History" + ); + const mcpIndex = sortedSubmenu.findIndex((item) => item.name === "MCP"); + + if ( + jobsHistoryIndex !== -1 && + mcpIndex !== -1 && + mcpIndex !== jobsHistoryIndex + 1 + ) { + const [mcpItem] = sortedSubmenu.splice(mcpIndex, 1); + sortedSubmenu.splice(jobsHistoryIndex + 1, 0, mcpItem); } - ].sort((v1, v2) => stringSortHelper(v1.name, v2.name)) + + return sortedSubmenu; + })() }; const CANARY_API = "/api/canary/api/summary"; @@ -841,6 +880,37 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { /> + + } /> + , + tables.database, + "read", + true + )} + /> + , + tables.database, + "read", + true + )} + /> + , + tables.database, + "read", + true + )} + /> + + {settingsNav.submenu .filter((v) => (v as SchemaResourceType).table) .map((x) => { diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 49acc18283..d1885d8d94 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -1,4 +1,4 @@ -import { IncidentCommander } from "../axios"; +import { apiBase, IncidentCommander } from "../axios"; import { resolvePostGrestRequestWithPagination } from "../resolve"; import { PermissionsSummary, PermissionTable } from "../types/permissions"; import { AVATAR_INFO } from "@flanksource-ui/constants"; @@ -17,8 +17,14 @@ export type FetchPermissionsInput = { connectionId?: string; subject?: string; action?: string; - subject_type?: "playbook" | "team" | "person" | "notification" | "component"; direction?: "inbound" | "outbound"; + subject_type?: + | "playbook" + | "team" + | "person" + | "notification" + | "component" + | "role"; }; function composeQueryParamForFetchPermissions({ @@ -149,3 +155,98 @@ export function recheckPermission(id: string) { error: null }); } + +export type BulkApplySelection = { + mode: "selective" | "all" | "allExcept"; + ids: string[]; +}; + +export type BulkApplyScope = { + table: "playbooks" | "views"; + filters?: Record; +}; + +export type BulkApplyPermissionRequest = { + action: "allow" | "deny"; + selection: BulkApplySelection; + scope: BulkApplyScope; +}; + +export async function bulkApplyPermission(payload: BulkApplyPermissionRequest) { + const response = await apiBase.post("/permission/bulk-apply", payload); + return response.data; +} + +export type BulkApplyMcpPermissionRequest = { + object: "mcp"; + action: "mcp:use"; + changes: Array<{ + user_id: string; + effect: "allow" | "deny" | "remove"; + }>; +}; + +export async function bulkApplyMcpPermission( + payload: BulkApplyMcpPermissionRequest +) { + const response = await apiBase.post("/permission/bulk-apply", payload); + return response.data; +} + +export async function fetchMcpPlaybookPermissions() { + const response = await IncidentCommander.get( + "/permissions_summary?select=*&action=eq.mcp:run&playbook_id=not.is.null&deleted_at=is.null&limit=5000" + ); + + return response.data ?? []; +} + +export async function fetchMcpViewPermissions() { + const response = await IncidentCommander.get( + "/permissions_summary?select=*&action=eq.mcp:run&deleted_at=is.null&limit=5000" + ); + + return response.data ?? []; +} + +export type PermissionSubject = { + id: string; + name: string; + type: "team" | "permission_subject_group" | "person" | "role"; +}; + +export async function fetchPermissionSubjectsPaginated({ + search = "", + pageIndex = 0, + pageSize = 20 +}: { + search?: string; + pageIndex?: number; + pageSize?: number; +}) { + const query = search.trim(); + + let url = + "/permission_subjects?select=id,name,type&type=neq.role&order=name.asc"; + url += `&limit=${pageSize}&offset=${pageIndex * pageSize}`; + + if (query) { + url += `&name=ilike.*${encodeURIComponent(query)}*`; + } + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get(url, { + headers: { + Prefer: "count=exact" + } + }) + ); +} + +export async function fetchPermissionSubjects() { + const response = await IncidentCommander.get( + "/permission_subjects?select=id,name,type&type=neq.role&order=name.asc&limit=5000" + ); + + return response.data ?? []; +} diff --git a/src/api/services/playbooks.ts b/src/api/services/playbooks.ts index c9a0be84ca..94ea0dacdc 100644 --- a/src/api/services/playbooks.ts +++ b/src/api/services/playbooks.ts @@ -27,11 +27,41 @@ export async function getAllPlaybooksSpecs() { export async function getAllPlaybookNames() { const res = await IncidentCommander.get( - `/playbook_names?select=id,name,title,icon,category&order=title.asc` + `/playbook_names?select=id,name,namespace,title,icon,category,description&order=title.asc` ); return res.data ?? []; } +export async function getPlaybookNamesPaginated({ + pageIndex, + pageSize, + sortBy, + sortOrder +}: { + pageIndex: number; + pageSize: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; +}) { + let url = + "/playbook_names?select=id,name,namespace,title,icon,category,description"; + url += `&limit=${pageSize}&offset=${pageIndex * pageSize}`; + + if (sortBy) { + url += `&order=${encodeURIComponent(`${sortBy}.${sortOrder ?? "asc"}`)}`; + } else { + url += `&order=${encodeURIComponent("title.asc")}`; + } + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get(url, { + headers: { + Prefer: "count=exact" + } + }) + ); +} + export async function getPlaybookSpec(id: string) { const res = await IncidentCommander.get( `/playbooks?id=eq.${id}&select=*,created_by(${AVATAR_INFO})` diff --git a/src/api/types/permissions.ts b/src/api/types/permissions.ts index 73dbb6a0f3..87347ed68f 100644 --- a/src/api/types/permissions.ts +++ b/src/api/types/permissions.ts @@ -47,7 +47,8 @@ export type PermissionTable = { | "team" | "person" | "notification" - | "component"; + | "component" + | "role"; created_by: string; updated_by: string; created_at: string; diff --git a/src/components/MCP/McpTabsLinks.tsx b/src/components/MCP/McpTabsLinks.tsx new file mode 100644 index 0000000000..6c9b6e3762 --- /dev/null +++ b/src/components/MCP/McpTabsLinks.tsx @@ -0,0 +1,94 @@ +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import TabbedLinks from "@flanksource-ui/ui/Tabs/TabbedLinks"; +import clsx from "clsx"; +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import ConfigSidebar from "../Configs/Sidebar/ConfigSidebar"; +import { ErrorBoundary } from "../ErrorBoundary"; + +type McpTabsLinksProps = { + activeTab: "Overview" | "Playbooks" | "Views"; + children: React.ReactNode; + className?: string; + onRefresh?: () => void; + loading?: boolean; +}; + +export default function McpTabsLinks({ + activeTab, + children, + className, + onRefresh = () => {}, + loading = false +}: McpTabsLinksProps) { + const [searchParams] = useSearchParams(); + + const tabLinks = useMemo(() => { + const query = searchParams.toString(); + const search = query ? `?${query}` : ""; + + return [ + { + label: "Overview", + path: "/settings/mcp/overview", + key: "Overview", + search + }, + { + label: "Playbooks", + path: "/settings/mcp/playbooks", + key: "Playbooks", + search + }, + { + label: "Views", + path: "/settings/mcp/views", + key: "Views", + search + } + ]; + }, [searchParams]); + + return ( + <> + + + MCP + , + {activeTab} + ]} + /> + } + onRefresh={onRefresh} + loading={loading} + contentClass="p-0 h-full overflow-y-hidden" + > +
+
+ + {children} + +
+ +
+
+ + ); +} diff --git a/src/components/MCP/PermissionAccessCard.tsx b/src/components/MCP/PermissionAccessCard.tsx new file mode 100644 index 0000000000..1a52a0517d --- /dev/null +++ b/src/components/MCP/PermissionAccessCard.tsx @@ -0,0 +1,210 @@ +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Card, + CardContent, + CardHeader +} from "@flanksource-ui/components/ui/card"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; + +type PermissionAccessCardProps = { + entity: { + id: string; + name: string; + namespace?: string; + icon?: string; + }; + users: PermissionsSummary[]; + groups: PermissionsSummary[]; + subjectLookup?: Record; + globalOverride?: "allow" | "none" | "deny"; + onGlobalOverrideChange: (value: "allow" | "none" | "deny") => void; + onAllowSelective: () => void; + isMutating?: boolean; +}; + +function PermissionGroupItem({ + permission, + subjectLookup +}: { + permission: PermissionsSummary; + subjectLookup?: Record; +}) { + if (permission.team) { + return ( +
+ + Team: {permission.team.name} +
+ ); + } + + const lookup = permission.subject + ? subjectLookup?.[permission.subject] + : undefined; + const groupName = lookup?.name || permission.group?.name || "Unknown group"; + + return ( +
+ {groupName} +
+ ); +} + +function PermissionUserItem({ + permission +}: { + permission: PermissionsSummary; +}) { + if (!permission.person) { + return ( +
+ {permission.subject || "Unknown user"} +
+ ); + } + + return ( +
+ +
+ {permission.person.name} + {permission.person.email && ( + {permission.person.email} + )} +
+
+ ); +} + +export default function PermissionAccessCard({ + entity, + users, + groups, + subjectLookup, + globalOverride = "none", + onGlobalOverrideChange, + onAllowSelective, + isMutating = false +}: PermissionAccessCardProps) { + return ( + + +
+
+ +
+ +
+
+
+ {entity.name} +
+ {entity.namespace ? ( +
+ {entity.namespace} +
+ ) : null} +
+ +
+
+ { + const mappedValue = + value === "Deny all" + ? "deny" + : value === "Allow all" + ? "allow" + : "none"; + + onGlobalOverrideChange(mappedValue); + }} + className="h-auto" + itemsClassName="" + getActiveItemClassName={(option) => + option === "Allow all" + ? "bg-blue-50 text-blue-700 ring-blue-200" + : option === "Deny all" + ? "bg-red-50 text-red-700 ring-red-200" + : undefined + } + /> +
+
+
+
+
+ + +
+
+
+ Groups +
+ {groups.length > 0 ? ( +
+ {groups.map((groupPermission) => ( + + ))} +
+ ) : ( +
No groups have access
+ )} +
+ +
+
+ Users +
+ {users.length > 0 ? ( +
+ {users.map((userPermission) => ( + + ))} +
+ ) : ( +
No users have access
+ )} +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/MCP/PlaybooksTable.tsx b/src/components/MCP/PlaybooksTable.tsx new file mode 100644 index 0000000000..8e8e245f1c --- /dev/null +++ b/src/components/MCP/PlaybooksTable.tsx @@ -0,0 +1,185 @@ +import { bulkApplyMcpPermission } from "@flanksource-ui/api/services/permissions"; +import { PlaybookNames } from "@flanksource-ui/api/types/playbooks"; +import { Button } from "@flanksource-ui/components"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import useMrtBulkSelection from "@flanksource-ui/ui/MRTDataTable/Hooks/useMrtBulkSelection"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { ChangeEvent, useCallback } from "react"; + +type PlaybooksTableProps = { + data: PlaybookNames[]; + isLoading?: boolean; + pageCount?: number; + totalRowCount?: number; +}; + +const columns: MRT_ColumnDef[] = [ + { + header: "Name", + accessorKey: "name" + }, + { + header: "Namespace", + accessorKey: "namespace" + }, + { + header: "Title", + accessorKey: "title" + }, + { + header: "Category", + accessorKey: "category" + }, + { + header: "Description", + accessorKey: "description" + } +]; + +export default function PlaybooksTable({ + data, + isLoading, + pageCount, + totalRowCount = 0 +}: PlaybooksTableProps) { + const { + rowSelection, + hasSelectedRows, + selectionSummary, + onRowSelectionChange, + onSelectAllChange, + clearSelection, + buildPayload + } = useMrtBulkSelection({ + rows: data, + totalRowCount, + getRowId: (row) => row.id, + selectionScope: { table: "playbooks" } + }); + + const triggerBulkAction = useCallback( + async (action: "allow" | "deny") => { + const selection = buildPayload(); + + if (selection.mode !== "selective") { + toastError( + "This endpoint only accepts explicit user changes. Please select rows directly instead of using Select All." + ); + return; + } + + if (selection.ids.length === 0) { + toastError("No rows selected"); + return; + } + + try { + await bulkApplyMcpPermission({ + object: "mcp", + action: "mcp:use", + changes: selection.ids.map((id) => ({ + user_id: id, + effect: action + })) + }); + + toastSuccess( + `Applied ${action} to ${selection.ids.length} selected rows` + ); + + clearSelection(); + } catch (error) { + toastError(error as any); + } + }, + [buildPayload, clearSelection] + ); + + return ( + <> +
+
+ + {selectionSummary} + + + +
+
+
+ row.id} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + mantineSelectCheckboxProps={{ + size: "xs", + radius: "lg", + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb" + } + } + }} + mantineSelectAllCheckboxProps={{ + size: "xs", + radius: "lg", + onChange: (event: ChangeEvent) => { + onSelectAllChange(event.currentTarget.checked); + }, + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb" + } + } + }} + /> +
+ + ); +} diff --git a/src/components/MCP/SubjectSelectorModal.tsx b/src/components/MCP/SubjectSelectorModal.tsx new file mode 100644 index 0000000000..450fe1b75e --- /dev/null +++ b/src/components/MCP/SubjectSelectorModal.tsx @@ -0,0 +1,235 @@ +import { + fetchPermissionSubjectsPaginated, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { Badge } from "@flanksource-ui/components/ui/badge"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { Checkbox } from "@flanksource-ui/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const PAGE_SIZE = 20; + +type SubjectSelectorModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + onAllow: (selection: PermissionSubject[]) => Promise | void; + preselectedSubjectIds?: string[]; + isSubmitting?: boolean; +}; + +function typeLabel(type: PermissionSubject["type"]) { + if (type === "permission_subject_group") { + return "group"; + } + + return type; +} + +export default function SubjectSelectorModal({ + open, + onOpenChange, + title, + description, + onAllow, + preselectedSubjectIds = [], + isSubmitting = false +}: SubjectSelectorModalProps) { + const [search, setSearch] = useState(""); + const [pageIndex, setPageIndex] = useState(0); + const [selectedIds, setSelectedIds] = useState>({}); + const [selectedSubjects, setSelectedSubjects] = useState< + Record + >({}); + + useEffect(() => { + setPageIndex(0); + }, [search]); + + useEffect(() => { + if (!open) { + setSearch(""); + setPageIndex(0); + setSelectedIds({}); + setSelectedSubjects({}); + return; + } + + const preselectedMap: Record = {}; + for (const id of preselectedSubjectIds) { + preselectedMap[id] = true; + } + setSelectedIds(preselectedMap); + }, [open, preselectedSubjectIds]); + + const { data, isLoading } = useQuery({ + queryKey: ["mcp", "subject-selector", search, pageIndex], + queryFn: async () => + fetchPermissionSubjectsPaginated({ + search, + pageIndex, + pageSize: PAGE_SIZE + }), + enabled: open + }); + + const subjects = (data?.data ?? []) as PermissionSubject[]; + const totalEntries = data?.totalEntries ?? 0; + const pageCount = Math.ceil(totalEntries / PAGE_SIZE); + + useEffect(() => { + if (subjects.length === 0) { + return; + } + + setSelectedSubjects((prev) => { + const next = { ...prev }; + for (const subject of subjects) { + if (selectedIds[subject.id]) { + next[subject.id] = subject; + } + } + return next; + }); + }, [subjects, selectedIds]); + + const selectedCount = useMemo( + () => Object.keys(selectedIds).length, + [selectedIds] + ); + + const toggleSubject = (subject: PermissionSubject, checked: boolean) => { + setSelectedIds((prev) => { + if (checked) { + return { ...prev, [subject.id]: true }; + } + + const next = { ...prev }; + delete next[subject.id]; + return next; + }); + + setSelectedSubjects((prev) => { + if (checked) { + return { ...prev, [subject.id]: subject }; + } + + const next = { ...prev }; + delete next[subject.id]; + return next; + }); + }; + + return ( + + + + {title} + {description ? ( + {description} + ) : null} + + + setSearch(event.target.value)} + /> + +
+ {isLoading ? ( +
Loading...
+ ) : subjects.length > 0 ? ( + subjects.map((subject) => ( + + )) + ) : ( +
No subjects found
+ )} +
+ +
+
+ Selected: {selectedCount} subject{selectedCount === 1 ? "" : "s"} +
+
+ + + Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount || 0} + + +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/MCP/ViewsTable.tsx b/src/components/MCP/ViewsTable.tsx new file mode 100644 index 0000000000..7cf311c841 --- /dev/null +++ b/src/components/MCP/ViewsTable.tsx @@ -0,0 +1,184 @@ +import { + bulkApplyPermission, + BulkApplyScope +} from "@flanksource-ui/api/services/permissions"; +import { View } from "@flanksource-ui/api/services/views"; +import { Button } from "@flanksource-ui/components"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import useMrtBulkSelection from "@flanksource-ui/ui/MRTDataTable/Hooks/useMrtBulkSelection"; +import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { ChangeEvent, useCallback } from "react"; + +type ViewsTableProps = { + data: View[]; + isLoading?: boolean; + pageCount?: number; + totalRowCount?: number; + filters?: Record; +}; + +const columns: MRT_ColumnDef[] = [ + { + header: "Name", + accessorKey: "name" + }, + { + header: "Namespace", + accessorKey: "namespace" + }, + { + header: "Source", + accessorKey: "source" + }, + { + header: "Created", + accessorKey: "created_at", + Cell: MRTDateCell, + sortingFn: "datetime" + }, + { + header: "Updated", + accessorKey: "updated_at", + Cell: MRTDateCell, + sortingFn: "datetime" + } +]; + +export default function ViewsTable({ + data, + isLoading, + pageCount, + totalRowCount = 0, + filters = {} +}: ViewsTableProps) { + const { + rowSelection, + hasSelectedRows, + selectionSummary, + onRowSelectionChange, + onSelectAllChange, + clearSelection, + buildPayload + } = useMrtBulkSelection({ + rows: data, + totalRowCount, + getRowId: (row) => row.id, + selectionScope: { table: "views" } + }); + + const triggerBulkAction = useCallback( + async (action: "allow" | "deny") => { + const selection = buildPayload(); + const scope: BulkApplyScope = { + table: "views", + filters + }; + + try { + await bulkApplyPermission({ + action, + selection, + scope + }); + + toastSuccess( + `action=${action}, ids=${selection.ids.join(",") || "none"}, mode=${selection.mode}` + ); + + clearSelection(); + } catch (error) { + toastError(error as any); + } + }, + [buildPayload, clearSelection, filters] + ); + + return ( + <> +
+
+ + {selectionSummary} + + + +
+
+
+ row.id} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + mantineSelectCheckboxProps={{ + size: "xs", + radius: "lg", + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb" + } + } + }} + mantineSelectAllCheckboxProps={{ + size: "xs", + radius: "lg", + onChange: (event: ChangeEvent) => { + onSelectAllChange(event.currentTarget.checked); + }, + styles: { + icon: { + display: "none" + }, + input: { + opacity: 0.9, + borderWidth: 1, + borderColor: "#d1d5db", + backgroundColor: "#f9fafb" + } + } + }} + /> +
+ + ); +} diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx new file mode 100644 index 0000000000..13176ddb01 --- /dev/null +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -0,0 +1,30 @@ +import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; +import { useRef, useState } from "react"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; + +const MCP_ACTIONS_FILTER = "mcp____run:1,mcp____use:1"; + +export default function McpOverviewPage() { + const [isLoading, setIsLoading] = useState(false); + const refetchFunctionRef = useRef<(() => void) | null>(null); + + return ( + refetchFunctionRef.current?.()} + > +
+
+ { + refetchFunctionRef.current = refetch; + }} + /> +
+
+
+ ); +} diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx new file mode 100644 index 0000000000..2829448404 --- /dev/null +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -0,0 +1,416 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + addPermission, + deletePermission, + fetchMcpPlaybookPermissions, + updatePermission, + fetchPermissionSubjects, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import PermissionAccessCard from "@flanksource-ui/components/MCP/PermissionAccessCard"; +import SubjectSelectorModal from "@flanksource-ui/components/MCP/SubjectSelectorModal"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; + +type PermissionBuckets = { + users: PermissionsSummary[]; + groups: PermissionsSummary[]; +}; + +const EVERYONE_SUBJECT_ID = "everyone"; +const EVERYONE_SUBJECT_TYPE = "role"; + +export default function McpPlaybooksPage() { + const { user } = useUser(); + const [selectedPlaybookId, setSelectedPlaybookId] = useState( + null + ); + const [mutatingPlaybookId, setMutatingPlaybookId] = useState( + null + ); + + const { + data: playbooks = [], + isLoading: isPlaybooksLoading, + refetch: refetchPlaybooks, + isRefetching: isPlaybooksRefetching + } = useQuery({ + queryKey: ["mcp", "playbooks", "all"], + queryFn: getAllPlaybookNames + }); + + const { + data: playbookPermissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: ["mcp", "playbooks", "permissions"], + queryFn: fetchMcpPlaybookPermissions + }); + + const { data: permissionSubjects = [] } = useQuery({ + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchPermissionSubjects + }); + + const subjectLookup = useMemo(() => { + return Object.fromEntries( + permissionSubjects.map((subject) => [ + subject.id, + { name: subject.name, type: subject.type } + ]) + ); + }, [permissionSubjects]); + + const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = + useMutation({ + mutationFn: async ({ + playbookId, + override + }: { + playbookId: string; + override: "allow" | "none" | "deny"; + }) => { + const existingOverrides = playbookPermissions.filter( + (permission) => + permission.playbook_id === playbookId && + permission.action === "mcp:run" && + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID && + permission.id && + permission.source === "mcp_settings" + ); + + if (override === "none") { + await Promise.all( + existingOverrides.map((permission) => + deletePermission(permission.id) + ) + ); + return; + } + + const targetDeny = override === "deny"; + const existingOverride = existingOverrides[0]; + + if (existingOverride) { + if (existingOverride.deny !== targetDeny) { + await updatePermission({ + id: existingOverride.id, + deny: targetDeny + } as any); + } + return; + } + + await addPermission({ + playbook_id: playbookId, + action: "mcp:run", + subject: EVERYONE_SUBJECT_ID, + subject_type: EVERYONE_SUBJECT_TYPE, + deny: targetDeny, + source: "mcp_settings", + created_by: user?.id! + } as any); + }, + onSuccess: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { mutateAsync: allowSelectiveAccess, isLoading: isAllowingSelective } = + useMutation({ + mutationFn: async ({ + playbookId, + subjects + }: { + playbookId: string; + subjects: PermissionSubject[]; + }) => { + const normalizeSubject = (subject: PermissionSubject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + return `${subjectType}:${subject.id}`; + }; + + const desiredKeys = new Set( + subjects.map(normalizeSubject).filter(Boolean) as string[] + ); + + const existingPermissions = playbookPermissions.filter( + (permission) => + permission.playbook_id === playbookId && + permission.action === "mcp:run" && + permission.deny !== true && + permission.subject && + permission.id && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") && + (permission.source === "mcp_settings" || permission.source === "UI") + ); + + const existingKeys = new Set( + existingPermissions.map( + (permission) => `${permission.subject_type}:${permission.subject}` + ) + ); + + const payloads = subjects + .map((subject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + const key = `${subjectType}:${subject.id}`; + if (existingKeys.has(key)) { + return null; + } + + return { + playbook_id: playbookId, + action: "mcp:run", + subject: subject.id, + subject_type: subjectType, + deny: false, + source: "mcp_settings" as const, + created_by: user?.id + }; + }) + .filter(Boolean); + + const deleteIds = existingPermissions + .filter( + (permission) => + !desiredKeys.has( + `${permission.subject_type}:${permission.subject}` + ) + ) + .map((permission) => permission.id); + + await Promise.all([ + ...payloads.map((payload) => addPermission(payload as any)), + ...deleteIds.map((id) => deletePermission(id)) + ]); + + return { + added: payloads.length, + removed: deleteIds.length + }; + }, + onSuccess: ({ added, removed }) => { + if (added === 0 && removed === 0) { + toastSuccess("No permission changes"); + } else { + toastSuccess(`Updated permissions: +${added} / -${removed}`); + } + + setSelectedPlaybookId(null); + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const permissionsByPlaybook = useMemo(() => { + const map = new Map(); + + for (const permission of playbookPermissions) { + if (!permission.playbook_id || permission.deny === true) { + continue; + } + + if ( + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID + ) { + continue; + } + + const current = map.get(permission.playbook_id) ?? { + users: [], + groups: [] + }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } + + map.set(permission.playbook_id, current); + } + + return map; + }, [playbookPermissions]); + + const globalOverrideByPlaybook = useMemo(() => { + const map = new Map(); + + for (const permission of playbookPermissions) { + if ( + !permission.playbook_id || + permission.action !== "mcp:run" || + permission.subject_type !== EVERYONE_SUBJECT_TYPE || + permission.subject !== EVERYONE_SUBJECT_ID + ) { + continue; + } + + map.set( + permission.playbook_id, + permission.deny === true ? "deny" : "allow" + ); + } + + return map; + }, [playbookPermissions]); + + const selectedPlaybook = useMemo(() => { + return playbooks.find((playbook) => playbook.id === selectedPlaybookId); + }, [playbooks, selectedPlaybookId]); + + const loading = + isPlaybooksLoading || + isPermissionsLoading || + isPlaybooksRefetching || + isPermissionsRefetching || + isUpdatingGlobalOverride || + isAllowingSelective; + + return ( + { + refetchPlaybooks(); + refetchPermissions(); + }} + > +
+
+

+ Playbook permissions +

+

+ Control which playbooks MCP clients can invoke through this gateway. +

+
+ +
+ {playbooks.map((playbook) => { + const permissions = permissionsByPlaybook.get(playbook.id) ?? { + users: [], + groups: [] + }; + + return ( + { + setMutatingPlaybookId(playbook.id); + setGlobalOverride( + { playbookId: playbook.id, override }, + { + onSettled: () => { + setMutatingPlaybookId((current) => + current === playbook.id ? null : current + ); + } + } + ); + }} + onAllowSelective={() => setSelectedPlaybookId(playbook.id)} + /> + ); + })} +
+
+ + { + if (!open) { + setSelectedPlaybookId(null); + } + }} + title={`Allow access: ${selectedPlaybook?.title || selectedPlaybook?.name || ""}`} + description="Select users or groups to allow this playbook for MCP usage." + preselectedSubjectIds={ + selectedPlaybook + ? playbookPermissions + .filter( + (permission) => + permission.playbook_id === selectedPlaybook.id && + permission.deny !== true && + permission.subject && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") + ) + .map((permission) => permission.subject!) + : [] + } + isSubmitting={isAllowingSelective} + onAllow={async (subjects) => { + if (!selectedPlaybook) { + return; + } + + await allowSelectiveAccess({ + playbookId: selectedPlaybook.id, + subjects + }); + }} + /> +
+ ); +} diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx new file mode 100644 index 0000000000..0e891704f5 --- /dev/null +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -0,0 +1,463 @@ +import { + addPermission, + deletePermission, + fetchMcpViewPermissions, + updatePermission, + fetchPermissionSubjects, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { getAllViews, View } from "@flanksource-ui/api/services/views"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import PermissionAccessCard from "@flanksource-ui/components/MCP/PermissionAccessCard"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorModal from "@flanksource-ui/components/MCP/SubjectSelectorModal"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; + +type PermissionBuckets = { + users: PermissionsSummary[]; + groups: PermissionsSummary[]; +}; + +const EVERYONE_SUBJECT_ID = "everyone"; +const EVERYONE_SUBJECT_TYPE = "role"; + +function permissionMatchesView(permission: PermissionsSummary, view: View) { + const viewRefs = permission.object_selector?.views ?? []; + + return viewRefs.some((viewRef) => { + if (!viewRef.name) { + return false; + } + + if (viewRef.namespace) { + return viewRef.namespace === view.namespace && viewRef.name === view.name; + } + + return viewRef.name === view.name; + }); +} + +export default function McpViewsPage() { + const { user } = useUser(); + const [selectedViewId, setSelectedViewId] = useState(null); + const [mutatingViewId, setMutatingViewId] = useState(null); + + const { + isLoading: isViewsLoading, + data: viewsResponse, + refetch: refetchViews, + isRefetching: isViewsRefetching + } = useQuery({ + queryKey: ["mcp", "views", "all"], + queryFn: async () => getAllViews(undefined, 0, 1000) + }); + + const views = viewsResponse?.data ?? []; + + const { + data: viewPermissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: ["mcp", "views", "permissions"], + queryFn: fetchMcpViewPermissions + }); + + const { data: permissionSubjects = [] } = useQuery({ + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchPermissionSubjects + }); + + const subjectLookup = useMemo(() => { + return Object.fromEntries( + permissionSubjects.map((subject) => [ + subject.id, + { name: subject.name, type: subject.type } + ]) + ); + }, [permissionSubjects]); + + const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = + useMutation({ + mutationFn: async ({ + view, + override + }: { + view: View; + override: "allow" | "none" | "deny"; + }) => { + const existingOverrides = viewPermissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID && + permission.id && + permission.source === "mcp_settings" && + permissionMatchesView(permission, view) + ); + + if (override === "none") { + await Promise.all( + existingOverrides.map((permission) => + deletePermission(permission.id) + ) + ); + return; + } + + const targetDeny = override === "deny"; + const existingOverride = existingOverrides[0]; + + if (existingOverride) { + if (existingOverride.deny !== targetDeny) { + await updatePermission({ + id: existingOverride.id, + deny: targetDeny + } as any); + } + return; + } + + await addPermission({ + object_selector: { + views: [{ name: view.name, namespace: view.namespace }] + }, + action: "mcp:run", + subject: EVERYONE_SUBJECT_ID, + subject_type: EVERYONE_SUBJECT_TYPE, + deny: targetDeny, + source: "mcp_settings", + created_by: user?.id! + } as any); + }, + onSuccess: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { mutateAsync: allowSelectiveAccess, isLoading: isAllowingSelective } = + useMutation({ + mutationFn: async ({ + view, + subjects + }: { + view: View; + subjects: PermissionSubject[]; + }) => { + const normalizeSubject = (subject: PermissionSubject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + return `${subjectType}:${subject.id}`; + }; + + const desiredKeys = new Set( + subjects.map(normalizeSubject).filter(Boolean) as string[] + ); + + const existingPermissions = viewPermissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.deny !== true && + permission.subject && + permission.id && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") && + (permission.source === "mcp_settings" || + permission.source === "UI") && + permissionMatchesView(permission, view) + ); + + const existingKeys = new Set( + existingPermissions.map( + (permission) => `${permission.subject_type}:${permission.subject}` + ) + ); + + const viewSelector = [{ name: view.name, namespace: view.namespace }]; + + const payloads = subjects + .map((subject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + const key = `${subjectType}:${subject.id}`; + if (existingKeys.has(key)) { + return null; + } + + return { + object_selector: { views: viewSelector }, + action: "mcp:run", + subject: subject.id, + subject_type: subjectType, + deny: false, + source: "mcp_settings" as const, + created_by: user?.id + }; + }) + .filter(Boolean); + + const deleteIds = existingPermissions + .filter( + (permission) => + !desiredKeys.has( + `${permission.subject_type}:${permission.subject}` + ) + ) + .map((permission) => permission.id); + + await Promise.all([ + ...payloads.map((payload) => addPermission(payload as any)), + ...deleteIds.map((id) => deletePermission(id)) + ]); + + return { + added: payloads.length, + removed: deleteIds.length + }; + }, + onSuccess: ({ added, removed }) => { + if (added === 0 && removed === 0) { + toastSuccess("No permission changes"); + } else { + toastSuccess(`Updated permissions: +${added} / -${removed}`); + } + + setSelectedViewId(null); + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const permissionsByView = useMemo(() => { + const map = new Map(); + + const viewsByNamespacedRef = new Map(); + const viewsByName = new Map(); + + for (const view of views) { + viewsByNamespacedRef.set(`${view.namespace || ""}/${view.name}`, view); + + const existingViews = viewsByName.get(view.name) ?? []; + existingViews.push(view); + viewsByName.set(view.name, existingViews); + } + + for (const permission of viewPermissions) { + if (permission.deny === true) { + continue; + } + + if ( + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID + ) { + continue; + } + + const viewRefs = permission.object_selector?.views ?? []; + if (viewRefs.length === 0) { + continue; + } + + for (const viewRef of viewRefs) { + const matchedView = viewRef.namespace + ? viewsByNamespacedRef.get(`${viewRef.namespace}/${viewRef.name}`) + : viewsByName.get(viewRef.name)?.[0]; + + if (!matchedView) { + continue; + } + + const current = map.get(matchedView.id) ?? { users: [], groups: [] }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } + + map.set(matchedView.id, current); + } + } + + return map; + }, [viewPermissions, views]); + + const globalOverrideByView = useMemo(() => { + const map = new Map(); + + for (const permission of viewPermissions) { + if ( + permission.action !== "mcp:run" || + permission.subject_type !== EVERYONE_SUBJECT_TYPE || + permission.subject !== EVERYONE_SUBJECT_ID + ) { + continue; + } + + const viewRefs = permission.object_selector?.views ?? []; + for (const viewRef of viewRefs) { + const view = views.find( + (v) => + v.name === viewRef.name && + (viewRef.namespace ? v.namespace === viewRef.namespace : true) + ); + + if (view) { + map.set(view.id, permission.deny === true ? "deny" : "allow"); + } + } + } + + return map; + }, [viewPermissions, views]); + + const selectedView = useMemo(() => { + return views.find((view) => view.id === selectedViewId); + }, [selectedViewId, views]); + + const loading = + isViewsLoading || + isPermissionsLoading || + isViewsRefetching || + isPermissionsRefetching || + isUpdatingGlobalOverride || + isAllowingSelective; + + return ( + { + refetchViews(); + refetchPermissions(); + }} + > +
+
+

+ View permissions +

+

+ Control which views MCP clients can invoke through this gateway. +

+
+ +
+ {views.map((view) => { + const permissions = permissionsByView.get(view.id) ?? { + users: [], + groups: [] + }; + + return ( + { + setMutatingViewId(view.id); + setGlobalOverride( + { view, override }, + { + onSettled: () => { + setMutatingViewId((current) => + current === view.id ? null : current + ); + } + } + ); + }} + onAllowSelective={() => setSelectedViewId(view.id)} + /> + ); + })} +
+
+ + { + if (!open) { + setSelectedViewId(null); + } + }} + title={`Allow access: ${selectedView?.spec?.title || selectedView?.name || ""}`} + description="Select users or groups to allow this view for MCP usage." + preselectedSubjectIds={ + selectedView + ? viewPermissions + .filter( + (permission) => + permission.deny !== true && + permission.subject && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") && + permissionMatchesView(permission, selectedView) + ) + .map((permission) => permission.subject!) + : [] + } + isSubmitting={isAllowingSelective} + onAllow={async (subjects) => { + if (!selectedView) { + return; + } + + await allowSelectiveAccess({ + view: selectedView, + subjects + }); + }} + /> +
+ ); +} diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index 22ca5c9817..7d6c330ffb 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -24,7 +24,8 @@ export const features = { "settings.notifications": "settings.notifications", "settings.playbooks": "settings.playbooks", "settings.integrations": "settings.integrations", - "settings.permissions": "settings.permissions" + "settings.permissions": "settings.permissions", + "settings.mcp": "settings.mcp" } as const; export const featureToParentMap = { diff --git a/src/ui/FormControls/Switch.tsx b/src/ui/FormControls/Switch.tsx index fe37b69292..69260d71f7 100644 --- a/src/ui/FormControls/Switch.tsx +++ b/src/ui/FormControls/Switch.tsx @@ -7,6 +7,8 @@ type Props = { options: T[]; className?: string; itemsClassName?: string; + activeItemClassName?: string; + getActiveItemClassName?: (option: T) => string | undefined; } & Omit, "onChange">; export function Switch({ @@ -15,9 +17,14 @@ export function Switch({ value, className, itemsClassName = "flex-1", + activeItemClassName, + getActiveItemClassName, ...props }: Props) { - const [activeOption, setActiveOption] = useState(() => value ?? options[0]); + const fallbackOption = options[0]; + const [activeOption, setActiveOption] = useState( + () => value ?? fallbackOption + ); const activeClasses = "p-1.5 lg:pl-2.5 lg:pr-3.5 rounded-md flex items-center text-sm font-medium bg-white shadow-sm ring-1 ring-black ring-opacity-5"; const inActiveClasses = @@ -28,8 +35,8 @@ export function Switch({ } useEffect(() => { - setActiveOption(value ?? options[0]); - }, [options, value]); + setActiveOption(value ?? fallbackOption); + }, [fallbackOption, value]); return (
({ key={option.toString()} > {option} From 3b771f01a0088787af15e02875c798a89e5c5ca7 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 18:32:46 +0545 Subject: [PATCH 02/48] chore: remove playbook and view table for MCP --- src/App.tsx | 246 ++++++++++++-------------- src/api/services/permissions.ts | 39 +--- src/components/MCP/PlaybooksTable.tsx | 185 ------------------- src/components/MCP/ViewsTable.tsx | 184 ------------------- 4 files changed, 115 insertions(+), 539 deletions(-) delete mode 100644 src/components/MCP/PlaybooksTable.tsx delete mode 100644 src/components/MCP/ViewsTable.tsx diff --git a/src/App.tsx b/src/App.tsx index 6b7179a2e3..b7b5efa31c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -392,139 +392,121 @@ const settingsNav: SettingsNavigationItems = { name: "Settings", icon: AdjustmentsIcon, checkPath: false, - submenu: (() => { - const sortedSubmenu = [ - { - name: "Connections", - href: "/settings/connections", - icon: BsLink, - featureName: features["settings.connections"], - resourceName: tables.connections - }, - { - name: "Permissions", - href: "/settings/permissions", - icon: RiShieldUserFill, - featureName: features["settings.permissions"], - resourceName: tables.permissions - }, - { - name: "Scopes", - href: "/settings/scopes", - icon: FaCrosshairs, - featureName: features["settings.permissions"], - resourceName: tables.scopes - }, - ...(process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true" - ? [] - : [ - { - name: "Users", - href: "/settings/users", - icon: HiUser, - featureName: features["settings.users"], - resourceName: tables.identities - } - ]), - ...schemaResourceTypes - // remove catalog_scraper from settings - .filter((resource) => resource.table !== "config_scrapers") - .map((x) => ({ - ...x, - href: `/settings/${x.table}` - })), - { - name: "Jobs History", - href: "/settings/jobs", - icon: FaTasks, - featureName: features["settings.job_history"], - resourceName: tables.database - }, - { - name: "MCP", - href: "/settings/mcp", - icon: ({ className }: { className: string }) => ( - - ), - featureName: features["settings.mcp"], - resourceName: tables.database - }, - { - name: "Feature Flags", - href: "/settings/feature-flags", - icon: BsToggles, - featureName: features["settings.feature_flags"], - resourceName: tables.database - }, - { - name: "Log Backends", - href: "/settings/log-backends", - icon: LogsIcon, - featureName: features["logs"], - resourceName: tables.database - }, - { - name: "Event Queue", - href: "/settings/event-queue-status", - icon: FaTasks, - featureName: features["settings.event_queue_status"], - resourceName: tables.database - }, - { - name: "Agents", - href: "/settings/agents", - icon: MdOutlineSupportAgent, - featureName: features.agents, - resourceName: tables.database - }, - { - name: "Tokens", - href: "/settings/tokens", - icon: VscKey, - featureName: features.agents, - resourceName: tables.database - }, - { - name: "Notifications", - href: "/notifications/rules", - icon: FaBell, - featureName: features["settings.notifications"], - resourceName: tables.notifications - }, - { - name: "Integrations", - href: "/settings/integrations", - icon: MdOutlineIntegrationInstructions, - featureName: features["settings.integrations"], - resourceName: tables.database - }, - { - name: "Views", - href: "/settings/views", - icon: ({ className }: { className: string }) => ( - - ), - featureName: features.views, - resourceName: tables.views - } - ].sort((v1, v2) => stringSortHelper(v1.name, v2.name)); - - const jobsHistoryIndex = sortedSubmenu.findIndex( - (item) => item.name === "Jobs History" - ); - const mcpIndex = sortedSubmenu.findIndex((item) => item.name === "MCP"); - - if ( - jobsHistoryIndex !== -1 && - mcpIndex !== -1 && - mcpIndex !== jobsHistoryIndex + 1 - ) { - const [mcpItem] = sortedSubmenu.splice(mcpIndex, 1); - sortedSubmenu.splice(jobsHistoryIndex + 1, 0, mcpItem); + submenu: [ + { + name: "Connections", + href: "/settings/connections", + icon: BsLink, + featureName: features["settings.connections"], + resourceName: tables.connections + }, + { + name: "Permissions", + href: "/settings/permissions", + icon: RiShieldUserFill, + featureName: features["settings.permissions"], + resourceName: tables.permissions + }, + { + name: "Scopes", + href: "/settings/scopes", + icon: FaCrosshairs, + featureName: features["settings.permissions"], + resourceName: tables.scopes + }, + ...(process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true" + ? [] + : [ + { + name: "Users", + href: "/settings/users", + icon: HiUser, + featureName: features["settings.users"], + resourceName: tables.identities + } + ]), + ...schemaResourceTypes + // remove catalog_scraper from settings + .filter((resource) => resource.table !== "config_scrapers") + .map((x) => ({ + ...x, + href: `/settings/${x.table}` + })), + { + name: "Jobs History", + href: "/settings/jobs", + icon: FaTasks, + featureName: features["settings.job_history"], + resourceName: tables.database + }, + { + name: "MCP", + href: "/settings/mcp", + icon: ({ className }: { className: string }) => ( + + ), + featureName: features["settings.mcp"], + resourceName: tables.database + }, + { + name: "Feature Flags", + href: "/settings/feature-flags", + icon: BsToggles, + featureName: features["settings.feature_flags"], + resourceName: tables.database + }, + { + name: "Log Backends", + href: "/settings/log-backends", + icon: LogsIcon, + featureName: features["logs"], + resourceName: tables.database + }, + { + name: "Event Queue", + href: "/settings/event-queue-status", + icon: FaTasks, + featureName: features["settings.event_queue_status"], + resourceName: tables.database + }, + { + name: "Agents", + href: "/settings/agents", + icon: MdOutlineSupportAgent, + featureName: features.agents, + resourceName: tables.database + }, + { + name: "Tokens", + href: "/settings/tokens", + icon: VscKey, + featureName: features.agents, + resourceName: tables.database + }, + { + name: "Notifications", + href: "/notifications/rules", + icon: FaBell, + featureName: features["settings.notifications"], + resourceName: tables.notifications + }, + { + name: "Integrations", + href: "/settings/integrations", + icon: MdOutlineIntegrationInstructions, + featureName: features["settings.integrations"], + resourceName: tables.database + }, + { + name: "Views", + href: "/settings/views", + icon: ({ className }: { className: string }) => ( + + ), + featureName: features.views, + resourceName: tables.views } - - return sortedSubmenu; - })() + ].sort((v1, v2) => stringSortHelper(v1.name, v2.name)) }; const CANARY_API = "/api/canary/api/summary"; diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index d1885d8d94..9bac9ee910 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -1,4 +1,4 @@ -import { apiBase, IncidentCommander } from "../axios"; +import { IncidentCommander } from "../axios"; import { resolvePostGrestRequestWithPagination } from "../resolve"; import { PermissionsSummary, PermissionTable } from "../types/permissions"; import { AVATAR_INFO } from "@flanksource-ui/constants"; @@ -156,43 +156,6 @@ export function recheckPermission(id: string) { }); } -export type BulkApplySelection = { - mode: "selective" | "all" | "allExcept"; - ids: string[]; -}; - -export type BulkApplyScope = { - table: "playbooks" | "views"; - filters?: Record; -}; - -export type BulkApplyPermissionRequest = { - action: "allow" | "deny"; - selection: BulkApplySelection; - scope: BulkApplyScope; -}; - -export async function bulkApplyPermission(payload: BulkApplyPermissionRequest) { - const response = await apiBase.post("/permission/bulk-apply", payload); - return response.data; -} - -export type BulkApplyMcpPermissionRequest = { - object: "mcp"; - action: "mcp:use"; - changes: Array<{ - user_id: string; - effect: "allow" | "deny" | "remove"; - }>; -}; - -export async function bulkApplyMcpPermission( - payload: BulkApplyMcpPermissionRequest -) { - const response = await apiBase.post("/permission/bulk-apply", payload); - return response.data; -} - export async function fetchMcpPlaybookPermissions() { const response = await IncidentCommander.get( "/permissions_summary?select=*&action=eq.mcp:run&playbook_id=not.is.null&deleted_at=is.null&limit=5000" diff --git a/src/components/MCP/PlaybooksTable.tsx b/src/components/MCP/PlaybooksTable.tsx deleted file mode 100644 index 8e8e245f1c..0000000000 --- a/src/components/MCP/PlaybooksTable.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { bulkApplyMcpPermission } from "@flanksource-ui/api/services/permissions"; -import { PlaybookNames } from "@flanksource-ui/api/types/playbooks"; -import { Button } from "@flanksource-ui/components"; -import { - toastError, - toastSuccess -} from "@flanksource-ui/components/Toast/toast"; -import useMrtBulkSelection from "@flanksource-ui/ui/MRTDataTable/Hooks/useMrtBulkSelection"; -import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; -import { MRT_ColumnDef } from "mantine-react-table"; -import { ChangeEvent, useCallback } from "react"; - -type PlaybooksTableProps = { - data: PlaybookNames[]; - isLoading?: boolean; - pageCount?: number; - totalRowCount?: number; -}; - -const columns: MRT_ColumnDef[] = [ - { - header: "Name", - accessorKey: "name" - }, - { - header: "Namespace", - accessorKey: "namespace" - }, - { - header: "Title", - accessorKey: "title" - }, - { - header: "Category", - accessorKey: "category" - }, - { - header: "Description", - accessorKey: "description" - } -]; - -export default function PlaybooksTable({ - data, - isLoading, - pageCount, - totalRowCount = 0 -}: PlaybooksTableProps) { - const { - rowSelection, - hasSelectedRows, - selectionSummary, - onRowSelectionChange, - onSelectAllChange, - clearSelection, - buildPayload - } = useMrtBulkSelection({ - rows: data, - totalRowCount, - getRowId: (row) => row.id, - selectionScope: { table: "playbooks" } - }); - - const triggerBulkAction = useCallback( - async (action: "allow" | "deny") => { - const selection = buildPayload(); - - if (selection.mode !== "selective") { - toastError( - "This endpoint only accepts explicit user changes. Please select rows directly instead of using Select All." - ); - return; - } - - if (selection.ids.length === 0) { - toastError("No rows selected"); - return; - } - - try { - await bulkApplyMcpPermission({ - object: "mcp", - action: "mcp:use", - changes: selection.ids.map((id) => ({ - user_id: id, - effect: action - })) - }); - - toastSuccess( - `Applied ${action} to ${selection.ids.length} selected rows` - ); - - clearSelection(); - } catch (error) { - toastError(error as any); - } - }, - [buildPayload, clearSelection] - ); - - return ( - <> -
-
- - {selectionSummary} - - - -
-
-
- row.id} - rowSelection={rowSelection} - onRowSelectionChange={onRowSelectionChange} - mantineSelectCheckboxProps={{ - size: "xs", - radius: "lg", - styles: { - icon: { - display: "none" - }, - input: { - opacity: 0.9, - borderWidth: 1, - borderColor: "#d1d5db", - backgroundColor: "#f9fafb" - } - } - }} - mantineSelectAllCheckboxProps={{ - size: "xs", - radius: "lg", - onChange: (event: ChangeEvent) => { - onSelectAllChange(event.currentTarget.checked); - }, - styles: { - icon: { - display: "none" - }, - input: { - opacity: 0.9, - borderWidth: 1, - borderColor: "#d1d5db", - backgroundColor: "#f9fafb" - } - } - }} - /> -
- - ); -} diff --git a/src/components/MCP/ViewsTable.tsx b/src/components/MCP/ViewsTable.tsx deleted file mode 100644 index 7cf311c841..0000000000 --- a/src/components/MCP/ViewsTable.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { - bulkApplyPermission, - BulkApplyScope -} from "@flanksource-ui/api/services/permissions"; -import { View } from "@flanksource-ui/api/services/views"; -import { Button } from "@flanksource-ui/components"; -import { - toastError, - toastSuccess -} from "@flanksource-ui/components/Toast/toast"; -import useMrtBulkSelection from "@flanksource-ui/ui/MRTDataTable/Hooks/useMrtBulkSelection"; -import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; -import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; -import { MRT_ColumnDef } from "mantine-react-table"; -import { ChangeEvent, useCallback } from "react"; - -type ViewsTableProps = { - data: View[]; - isLoading?: boolean; - pageCount?: number; - totalRowCount?: number; - filters?: Record; -}; - -const columns: MRT_ColumnDef[] = [ - { - header: "Name", - accessorKey: "name" - }, - { - header: "Namespace", - accessorKey: "namespace" - }, - { - header: "Source", - accessorKey: "source" - }, - { - header: "Created", - accessorKey: "created_at", - Cell: MRTDateCell, - sortingFn: "datetime" - }, - { - header: "Updated", - accessorKey: "updated_at", - Cell: MRTDateCell, - sortingFn: "datetime" - } -]; - -export default function ViewsTable({ - data, - isLoading, - pageCount, - totalRowCount = 0, - filters = {} -}: ViewsTableProps) { - const { - rowSelection, - hasSelectedRows, - selectionSummary, - onRowSelectionChange, - onSelectAllChange, - clearSelection, - buildPayload - } = useMrtBulkSelection({ - rows: data, - totalRowCount, - getRowId: (row) => row.id, - selectionScope: { table: "views" } - }); - - const triggerBulkAction = useCallback( - async (action: "allow" | "deny") => { - const selection = buildPayload(); - const scope: BulkApplyScope = { - table: "views", - filters - }; - - try { - await bulkApplyPermission({ - action, - selection, - scope - }); - - toastSuccess( - `action=${action}, ids=${selection.ids.join(",") || "none"}, mode=${selection.mode}` - ); - - clearSelection(); - } catch (error) { - toastError(error as any); - } - }, - [buildPayload, clearSelection, filters] - ); - - return ( - <> -
-
- - {selectionSummary} - - - -
-
-
- row.id} - rowSelection={rowSelection} - onRowSelectionChange={onRowSelectionChange} - mantineSelectCheckboxProps={{ - size: "xs", - radius: "lg", - styles: { - icon: { - display: "none" - }, - input: { - opacity: 0.9, - borderWidth: 1, - borderColor: "#d1d5db", - backgroundColor: "#f9fafb" - } - } - }} - mantineSelectAllCheckboxProps={{ - size: "xs", - radius: "lg", - onChange: (event: ChangeEvent) => { - onSelectAllChange(event.currentTarget.checked); - }, - styles: { - icon: { - display: "none" - }, - input: { - opacity: 0.9, - borderWidth: 1, - borderColor: "#d1d5db", - backgroundColor: "#f9fafb" - } - } - }} - /> -
- - ); -} From 8574f7c425735c11472760ee60edeb2ef4cf0736 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 18:09:39 +0545 Subject: [PATCH 03/48] chore: refactor file paths --- src/api/services/permissions.ts | 8 + .../PermissionAccessCard.tsx | 0 .../SubjectSelectorModal.tsx | 0 src/pages/Settings/mcp/McpOverviewPage.tsx | 416 +++++++++++++++++- src/pages/Settings/mcp/McpPlaybooksPage.tsx | 4 +- src/pages/Settings/mcp/McpViewsPage.tsx | 4 +- 6 files changed, 412 insertions(+), 20 deletions(-) rename src/components/{MCP => Permissions}/PermissionAccessCard.tsx (100%) rename src/components/{MCP => Permissions}/SubjectSelectorModal.tsx (100%) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 9bac9ee910..bc11946391 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -172,6 +172,14 @@ export async function fetchMcpViewPermissions() { return response.data ?? []; } +export async function fetchMcpUserPermissions() { + const response = await IncidentCommander.get( + "/permissions_summary?select=*&action=eq.mcp:use&person_id=not.is.null&deleted_at=is.null&limit=5000" + ); + + return response.data ?? []; +} + export type PermissionSubject = { id: string; name: string; diff --git a/src/components/MCP/PermissionAccessCard.tsx b/src/components/Permissions/PermissionAccessCard.tsx similarity index 100% rename from src/components/MCP/PermissionAccessCard.tsx rename to src/components/Permissions/PermissionAccessCard.tsx diff --git a/src/components/MCP/SubjectSelectorModal.tsx b/src/components/Permissions/SubjectSelectorModal.tsx similarity index 100% rename from src/components/MCP/SubjectSelectorModal.tsx rename to src/components/Permissions/SubjectSelectorModal.tsx diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx index 13176ddb01..69d0da55d5 100644 --- a/src/pages/Settings/mcp/McpOverviewPage.tsx +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -1,30 +1,414 @@ -import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; -import { useRef, useState } from "react"; +import { getPersons } from "@flanksource-ui/api/services/users"; +import { + addPermission, + deletePermission, + fetchMcpUserPermissions, + fetchPermissionSubjects, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; -const MCP_ACTIONS_FILTER = "mcp____run:1,mcp____use:1"; +type PermissionBuckets = { + users: PermissionsSummary[]; + groups: PermissionsSummary[]; +}; + +const EVERYONE_SUBJECT_ID = "everyone"; +const EVERYONE_SUBJECT_TYPE = "role"; export default function McpOverviewPage() { - const [isLoading, setIsLoading] = useState(false); - const refetchFunctionRef = useRef<(() => void) | null>(null); + const { user } = useUser(); + const [selectedUserId, setSelectedUserId] = useState(null); + const [mutatingUserId, setMutatingUserId] = useState(null); + + const { + data: usersResponse, + isLoading: isUsersLoading, + refetch: refetchUsers, + isRefetching: isUsersRefetching + } = useQuery({ + queryKey: ["mcp", "users", "all"], + queryFn: getPersons + }); + + const users = usersResponse?.data ?? []; + + const { + data: userPermissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: ["mcp", "users", "permissions"], + queryFn: fetchMcpUserPermissions + }); + + const { data: permissionSubjects = [] } = useQuery({ + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchPermissionSubjects + }); + + const subjectLookup = useMemo(() => { + return Object.fromEntries( + permissionSubjects.map((subject) => [ + subject.id, + { name: subject.name, type: subject.type } + ]) + ); + }, [permissionSubjects]); + + const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = + useMutation({ + mutationFn: async ({ + personId, + override + }: { + personId: string; + override: "allow" | "none" | "deny"; + }) => { + const existingOverrides = userPermissions.filter( + (permission) => + permission.person_id === personId && + permission.action === "mcp:use" && + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID && + permission.id && + permission.source === "mcp_settings" + ); + + if (override === "none") { + await Promise.all( + existingOverrides.map((permission) => + deletePermission(permission.id) + ) + ); + return; + } + + const targetDeny = override === "deny"; + const existingOverride = existingOverrides[0]; + + if (existingOverride) { + if (existingOverride.deny !== targetDeny) { + await updatePermission({ + id: existingOverride.id, + deny: targetDeny + } as any); + } + return; + } + + await addPermission({ + person_id: personId, + object: "mcp", + action: "mcp:use", + subject: EVERYONE_SUBJECT_ID, + subject_type: EVERYONE_SUBJECT_TYPE, + deny: targetDeny, + source: "mcp_settings", + created_by: user?.id! + } as any); + }, + onSuccess: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { mutateAsync: allowSelectiveAccess, isLoading: isAllowingSelective } = + useMutation({ + mutationFn: async ({ + personId, + subjects + }: { + personId: string; + subjects: PermissionSubject[]; + }) => { + const normalizeSubject = (subject: PermissionSubject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + return `${subjectType}:${subject.id}`; + }; + + const desiredKeys = new Set( + subjects.map(normalizeSubject).filter(Boolean) as string[] + ); + + const existingPermissions = userPermissions.filter( + (permission) => + permission.person_id === personId && + permission.action === "mcp:use" && + permission.deny !== true && + permission.subject && + permission.id && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") && + (permission.source === "mcp_settings" || permission.source === "UI") + ); + + const existingKeys = new Set( + existingPermissions.map( + (permission) => `${permission.subject_type}:${permission.subject}` + ) + ); + + const payloads = subjects + .map((subject) => { + const subjectType = + subject.type === "person" + ? "person" + : subject.type === "team" + ? "team" + : subject.type === "permission_subject_group" + ? "group" + : null; + + if (!subjectType) { + return null; + } + + const key = `${subjectType}:${subject.id}`; + if (existingKeys.has(key)) { + return null; + } + + return { + person_id: personId, + object: "mcp" as const, + action: "mcp:use", + subject: subject.id, + subject_type: subjectType, + deny: false, + source: "mcp_settings" as const, + created_by: user?.id + }; + }) + .filter(Boolean); + + const deleteIds = existingPermissions + .filter( + (permission) => + !desiredKeys.has( + `${permission.subject_type}:${permission.subject}` + ) + ) + .map((permission) => permission.id); + + await Promise.all([ + ...payloads.map((payload) => addPermission(payload as any)), + ...deleteIds.map((id) => deletePermission(id)) + ]); + + return { + added: payloads.length, + removed: deleteIds.length + }; + }, + onSuccess: ({ added, removed }) => { + if (added === 0 && removed === 0) { + toastSuccess("No permission changes"); + } else { + toastSuccess(`Updated permissions: +${added} / -${removed}`); + } + + setSelectedUserId(null); + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const permissionsByUser = useMemo(() => { + const map = new Map(); + + for (const permission of userPermissions) { + if (!permission.person_id || permission.deny === true) { + continue; + } + + if ( + permission.subject_type === EVERYONE_SUBJECT_TYPE && + permission.subject === EVERYONE_SUBJECT_ID + ) { + continue; + } + + const current = map.get(permission.person_id) ?? { + users: [], + groups: [] + }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } + + map.set(permission.person_id, current); + } + + return map; + }, [userPermissions]); + + const globalOverrideByUser = useMemo(() => { + const map = new Map(); + + for (const permission of userPermissions) { + if ( + !permission.person_id || + permission.action !== "mcp:use" || + permission.subject_type !== EVERYONE_SUBJECT_TYPE || + permission.subject !== EVERYONE_SUBJECT_ID + ) { + continue; + } + + map.set( + permission.person_id, + permission.deny === true ? "deny" : "allow" + ); + } + + return map; + }, [userPermissions]); + + const selectedUser = useMemo(() => { + return users.find((item) => item.id === selectedUserId); + }, [selectedUserId, users]); + + const loading = + isUsersLoading || + isPermissionsLoading || + isUsersRefetching || + isPermissionsRefetching || + isUpdatingGlobalOverride || + isAllowingSelective; return ( refetchFunctionRef.current?.()} + loading={loading} + onRefresh={() => { + refetchUsers(); + refetchPermissions(); + }} > -
-
- { - refetchFunctionRef.current = refetch; - }} - /> +
+
+

+ User permissions +

+

+ Control which users can be used by MCP clients through this gateway. +

+
+ +
+ {users.map((person) => { + const permissions = permissionsByUser.get(person.id) ?? { + users: [], + groups: [] + }; + + return ( + { + setMutatingUserId(person.id); + setGlobalOverride( + { personId: person.id, override }, + { + onSettled: () => { + setMutatingUserId((current) => + current === person.id ? null : current + ); + } + } + ); + }} + onAllowSelective={() => setSelectedUserId(person.id)} + /> + ); + })}
+ + { + if (!open) { + setSelectedUserId(null); + } + }} + title={`Allow access: ${selectedUser?.name || ""}`} + description="Select users or groups to allow this user for MCP usage." + preselectedSubjectIds={ + selectedUser + ? userPermissions + .filter( + (permission) => + permission.person_id === selectedUser.id && + permission.deny !== true && + permission.subject && + (permission.subject_type === "person" || + permission.subject_type === "team" || + permission.subject_type === "group") + ) + .map((permission) => permission.subject!) + : [] + } + isSubmitting={isAllowingSelective} + onAllow={async (subjects) => { + if (!selectedUser) { + return; + } + + await allowSelectiveAccess({ + personId: selectedUser.id, + subjects + }); + }} + /> ); } diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index 2829448404..2a07b3153a 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -8,8 +8,8 @@ import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; -import PermissionAccessCard from "@flanksource-ui/components/MCP/PermissionAccessCard"; -import SubjectSelectorModal from "@flanksource-ui/components/MCP/SubjectSelectorModal"; +import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; +import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; import { toastError, diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 0e891704f5..07c692046e 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -8,9 +8,9 @@ import { } from "@flanksource-ui/api/services/permissions"; import { getAllViews, View } from "@flanksource-ui/api/services/views"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; -import PermissionAccessCard from "@flanksource-ui/components/MCP/PermissionAccessCard"; +import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; -import SubjectSelectorModal from "@flanksource-ui/components/MCP/SubjectSelectorModal"; +import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; import { toastError, toastSuccess From 7371529eb951a30bd8880bb68f4cd8319369da89 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 18:20:24 +0545 Subject: [PATCH 04/48] fix(permissions): fix SubjectSelectorModal bugs and UX issues - Fix re-render loop: init selected state only on open transition via prevOpenRef, read preselectedSubjectIds through a stable ref so parent re-renders don't wipe user selections - Fix incomplete onAllow payload: add fetchPermissionSubjectsByIds and seed selectedSubjects upfront so preselected subjects on other pages are always included - Allow applying empty selection (remove last subject) by disabling Apply only when selection is unchanged from initial, not when count is zero - Debounce search input with useDebouncedValue(300ms) to avoid per-keystroke API calls - Remove unnecessary useMemo around selectedCount - Add PAGE_SIZE to query key - Replace Avatar with type-appropriate icon for team/group subjects - Replace typeLabel function with a TYPE_LABELS record map - Wrap subjects derivation in useMemo to fix exhaustive-deps lint warning --- src/api/services/permissions.ts | 10 ++ .../Permissions/SubjectSelectorModal.tsx | 127 ++++++++++++++---- 2 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index bc11946391..948b1e9d1b 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -214,6 +214,16 @@ export async function fetchPermissionSubjectsPaginated({ ); } +export async function fetchPermissionSubjectsByIds(ids: string[]) { + if (ids.length === 0) { + return []; + } + const response = await IncidentCommander.get( + `/permission_subjects?select=id,name,type&id=in.(${ids.join(",")})&limit=${ids.length}` + ); + return response.data ?? []; +} + export async function fetchPermissionSubjects() { const response = await IncidentCommander.get( "/permission_subjects?select=id,name,type&type=neq.role&order=name.asc&limit=5000" diff --git a/src/components/Permissions/SubjectSelectorModal.tsx b/src/components/Permissions/SubjectSelectorModal.tsx index 450fe1b75e..5f8ee86f0a 100644 --- a/src/components/Permissions/SubjectSelectorModal.tsx +++ b/src/components/Permissions/SubjectSelectorModal.tsx @@ -1,4 +1,5 @@ import { + fetchPermissionSubjectsByIds, fetchPermissionSubjectsPaginated, PermissionSubject } from "@flanksource-ui/api/services/permissions"; @@ -14,12 +15,21 @@ import { DialogTitle } from "@flanksource-ui/components/ui/dialog"; import { Input } from "@flanksource-ui/components/ui/input"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; import { Avatar } from "@flanksource-ui/ui/Avatar"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { HiUser, HiUserGroup } from "react-icons/hi"; const PAGE_SIZE = 20; +const TYPE_LABELS: Record = { + person: "person", + team: "team", + role: "role", + permission_subject_group: "group" +}; + type SubjectSelectorModalProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -30,12 +40,21 @@ type SubjectSelectorModalProps = { isSubmitting?: boolean; }; -function typeLabel(type: PermissionSubject["type"]) { - if (type === "permission_subject_group") { - return "group"; +function SubjectIcon({ subject }: { subject: PermissionSubject }) { + if (subject.type === "person") { + return ; } - return type; + // teams and groups get a group icon instead of an initials-based avatar + return ( + + {subject.type === "team" ? ( + + ) : ( + + )} + + ); } export default function SubjectSelectorModal({ @@ -54,11 +73,21 @@ export default function SubjectSelectorModal({ Record >({}); - useEffect(() => { - setPageIndex(0); - }, [search]); + // Track previous open value so we only initialise state on the + // false → true transition, not on every render. + const prevOpenRef = useRef(false); + // Keep a stable ref to the latest preselectedSubjectIds so the + // open-transition effect below doesn't need it in its dep array. + const preselectedSubjectIdsRef = useRef(preselectedSubjectIds); + preselectedSubjectIdsRef.current = preselectedSubjectIds; + // Snapshot of selected IDs at the moment the modal opened, used to + // detect whether the selection has actually changed. + const initialSelectedIdsRef = useRef>({}); useEffect(() => { + const wasOpen = prevOpenRef.current; + prevOpenRef.current = open; + if (!open) { setSearch(""); setPageIndex(0); @@ -67,28 +96,73 @@ export default function SubjectSelectorModal({ return; } - const preselectedMap: Record = {}; - for (const id of preselectedSubjectIds) { - preselectedMap[id] = true; + if (!wasOpen) { + // Modal just opened — pre-check the supplied IDs. Full subject + // objects are fetched separately by the query below; here we + // only mark the IDs as selected so checkboxes render correctly + // immediately. + const idMap: Record = {}; + for (const id of preselectedSubjectIdsRef.current) { + idMap[id] = true; + } + initialSelectedIdsRef.current = idMap; + setSelectedIds(idMap); + } + // Intentionally only depend on `open` — we read preselectedSubjectIds + // through a ref to avoid resetting user selections on parent re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Fetch full subject objects for every preselected ID so that + // `selectedSubjects` is complete even when those subjects live on a + // different page of results. + useQuery({ + queryKey: ["permission-subjects-by-ids", preselectedSubjectIds], + queryFn: () => fetchPermissionSubjectsByIds(preselectedSubjectIds), + enabled: open && preselectedSubjectIds.length > 0, + staleTime: 60_000, + onSuccess: (data) => { + setSelectedSubjects((prev) => { + const next = { ...prev }; + for (const subject of data) { + next[subject.id] = subject; + } + return next; + }); } - setSelectedIds(preselectedMap); - }, [open, preselectedSubjectIds]); + }); + + const debouncedSearch = useDebouncedValue(search, 300) ?? ""; + + useEffect(() => { + setPageIndex(0); + }, [debouncedSearch]); const { data, isLoading } = useQuery({ - queryKey: ["mcp", "subject-selector", search, pageIndex], + queryKey: [ + "mcp", + "subject-selector", + debouncedSearch, + pageIndex, + PAGE_SIZE + ], queryFn: async () => fetchPermissionSubjectsPaginated({ - search, + search: debouncedSearch, pageIndex, pageSize: PAGE_SIZE }), enabled: open }); - const subjects = (data?.data ?? []) as PermissionSubject[]; + const subjects = useMemo( + () => (data?.data ?? []) as PermissionSubject[], + [data?.data] + ); const totalEntries = data?.totalEntries ?? 0; const pageCount = Math.ceil(totalEntries / PAGE_SIZE); + // Enrich selectedSubjects as new pages are loaded. useEffect(() => { if (subjects.length === 0) { return; @@ -105,17 +179,21 @@ export default function SubjectSelectorModal({ }); }, [subjects, selectedIds]); - const selectedCount = useMemo( - () => Object.keys(selectedIds).length, - [selectedIds] - ); + const selectedCount = Object.keys(selectedIds).length; + + // Apply is a no-op only when the selection is identical to what was + // preselected on open (same IDs, same count). Submitting an empty + // selection is valid — it means "remove everyone". + const initialIds = initialSelectedIdsRef.current; + const hasChanged = + selectedCount !== Object.keys(initialIds).length || + Object.keys(selectedIds).some((id) => !initialIds[id]); const toggleSubject = (subject: PermissionSubject, checked: boolean) => { setSelectedIds((prev) => { if (checked) { return { ...prev, [subject.id]: true }; } - const next = { ...prev }; delete next[subject.id]; return next; @@ -125,7 +203,6 @@ export default function SubjectSelectorModal({ if (checked) { return { ...prev, [subject.id]: subject }; } - const next = { ...prev }; delete next[subject.id]; return next; @@ -164,7 +241,7 @@ export default function SubjectSelectorModal({ toggleSubject(subject, checked === true) } /> - +
{subject.name}
@@ -172,7 +249,7 @@ export default function SubjectSelectorModal({
- {typeLabel(subject.type)} + {TYPE_LABELS[subject.type] ?? subject.type}
@@ -223,7 +300,7 @@ export default function SubjectSelectorModal({ +
+ +
-
+ )} ); diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index 2a07b3153a..20e6da67b1 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -25,7 +25,7 @@ type PermissionBuckets = { }; const EVERYONE_SUBJECT_ID = "everyone"; -const EVERYONE_SUBJECT_TYPE = "role"; +const EVERYONE_SUBJECT_TYPE = "group"; export default function McpPlaybooksPage() { const { user } = useUser(); diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 07c692046e..752c3daf64 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -25,7 +25,7 @@ type PermissionBuckets = { }; const EVERYONE_SUBJECT_ID = "everyone"; -const EVERYONE_SUBJECT_TYPE = "role"; +const EVERYONE_SUBJECT_TYPE = "group"; function permissionMatchesView(permission: PermissionsSummary, view: View) { const viewRefs = permission.object_selector?.views ?? []; From d65d3331193851bfca6afee52a6de9396412820d Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 19:01:10 +0545 Subject: [PATCH 06/48] feat(mcp): redesign overview subject access controls --- src/api/services/permissions.ts | 10 +- src/components/Permissions/UserAccessCard.tsx | 101 ++++ src/pages/Settings/mcp/McpOverviewPage.tsx | 431 +++++------------- 3 files changed, 230 insertions(+), 312 deletions(-) create mode 100644 src/components/Permissions/UserAccessCard.tsx diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 948b1e9d1b..ab27a0443b 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -174,7 +174,7 @@ export async function fetchMcpViewPermissions() { export async function fetchMcpUserPermissions() { const response = await IncidentCommander.get( - "/permissions_summary?select=*&action=eq.mcp:use&person_id=not.is.null&deleted_at=is.null&limit=5000" + "/permissions_summary?select=*&action=eq.mcp:use&object=eq.mcp&deleted_at=is.null&limit=5000" ); return response.data ?? []; @@ -231,3 +231,11 @@ export async function fetchPermissionSubjects() { return response.data ?? []; } + +export async function fetchAllPermissionSubjects() { + const response = await IncidentCommander.get( + "/permission_subjects?select=id,name,type&order=type.asc,name.asc&limit=5000" + ); + + return response.data ?? []; +} diff --git a/src/components/Permissions/UserAccessCard.tsx b/src/components/Permissions/UserAccessCard.tsx new file mode 100644 index 0000000000..0afd0509a4 --- /dev/null +++ b/src/components/Permissions/UserAccessCard.tsx @@ -0,0 +1,101 @@ +import { Card, CardHeader } from "@flanksource-ui/components/ui/card"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; +import { HiUser, HiUserGroup } from "react-icons/hi"; + +type UserAccessCardProps = { + user: { + id: string; + name?: string; + email?: string; + avatar?: string; + type?: "team" | "permission_subject_group" | "person" | "role"; + }; + action: string; + object: string; + access: "deny" | "default" | "allow"; + onChangeAccess: (access: "deny" | "default" | "allow") => void; + isMutating?: boolean; +}; + +export default function UserAccessCard({ + user, + action, + object, + access, + onChangeAccess, + isMutating = false +}: UserAccessCardProps) { + return ( + + +
+
+ {user.type === "person" || !user.type ? ( + + ) : ( + + {user.type === "team" ? ( + + ) : ( + + )} + + )} +
+
+ {user.name} +
+ {user.email ? ( +
+ {user.email} +
+ ) : null} +
+
+ +
+ { + const mapped = + value === "Deny" + ? "deny" + : value === "Allow" + ? "allow" + : "default"; + onChangeAccess(mapped); + }} + className="h-auto" + itemsClassName="" + aria-label={`${action} on ${object} for ${user.name || user.id}`} + getActiveItemClassName={(option) => + option === "Allow" + ? "bg-blue-50 text-blue-700 ring-blue-200" + : option === "Deny" + ? "bg-red-50 text-red-700 ring-red-200" + : "bg-gray-50 text-gray-700 ring-gray-200" + } + /> +
+
+
+
+ ); +} diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx index 69d0da55d5..cbf92cf1f3 100644 --- a/src/pages/Settings/mcp/McpOverviewPage.tsx +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -1,49 +1,56 @@ -import { getPersons } from "@flanksource-ui/api/services/users"; import { addPermission, deletePermission, + fetchAllPermissionSubjects, fetchMcpUserPermissions, - fetchPermissionSubjects, PermissionSubject, updatePermission } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; -import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; +import UserAccessCard from "@flanksource-ui/components/Permissions/UserAccessCard"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; -import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; -import { - toastError, - toastSuccess -} from "@flanksource-ui/components/Toast/toast"; +import { toastError } from "@flanksource-ui/components/Toast/toast"; import { useUser } from "@flanksource-ui/context"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; -type PermissionBuckets = { - users: PermissionsSummary[]; - groups: PermissionsSummary[]; -}; +const MCP_OBJECT = "mcp"; +const MCP_ACTION = "mcp:use"; + +function isMcpUserAccessPermission(permission: PermissionsSummary) { + return ( + permission.action === MCP_ACTION && + permission.object === MCP_OBJECT && + !!permission.subject && + !!permission.id && + (permission.source === "mcp_settings" || permission.source === "UI") + ); +} -const EVERYONE_SUBJECT_ID = "everyone"; -const EVERYONE_SUBJECT_TYPE = "role"; +function mapPermissionSubjectType(type: PermissionSubject["type"]) { + if (type === "permission_subject_group") { + return "group" as const; + } + + return type; +} export default function McpOverviewPage() { const { user } = useUser(); - const [selectedUserId, setSelectedUserId] = useState(null); - const [mutatingUserId, setMutatingUserId] = useState(null); + const [mutatingSubjectId, setMutatingSubjectId] = useState( + null + ); const { - data: usersResponse, + data: subjects = [], isLoading: isUsersLoading, refetch: refetchUsers, isRefetching: isUsersRefetching } = useQuery({ - queryKey: ["mcp", "users", "all"], - queryFn: getPersons + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchAllPermissionSubjects }); - const users = usersResponse?.data ?? []; - const { data: userPermissions = [], isLoading: isPermissionsLoading, @@ -54,189 +61,80 @@ export default function McpOverviewPage() { queryFn: fetchMcpUserPermissions }); - const { data: permissionSubjects = [] } = useQuery({ - queryKey: ["mcp", "permission-subjects", "all"], - queryFn: fetchPermissionSubjects - }); + const permissionsByUser = useMemo(() => { + const map = new Map(); + + for (const permission of userPermissions) { + if (!isMcpUserAccessPermission(permission)) { + continue; + } + + const subjectId = permission.subject!; + const current = map.get(subjectId) ?? []; + current.push(permission); + map.set(subjectId, current); + } - const subjectLookup = useMemo(() => { - return Object.fromEntries( - permissionSubjects.map((subject) => [ - subject.id, - { name: subject.name, type: subject.type } - ]) - ); - }, [permissionSubjects]); + return map; + }, [userPermissions]); - const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = + const { mutate: setUserAccess, isLoading: isUpdatingUserAccess } = useMutation({ mutationFn: async ({ - personId, - override + subjectId, + subjectType, + access }: { - personId: string; - override: "allow" | "none" | "deny"; + subjectId: string; + subjectType: PermissionSubject["type"]; + access: "deny" | "default" | "allow"; }) => { - const existingOverrides = userPermissions.filter( - (permission) => - permission.person_id === personId && - permission.action === "mcp:use" && - permission.subject_type === EVERYONE_SUBJECT_TYPE && - permission.subject === EVERYONE_SUBJECT_ID && - permission.id && - permission.source === "mcp_settings" - ); + const existingPermissions = ( + permissionsByUser.get(subjectId) ?? [] + ).filter((permission) => permission.source === "mcp_settings"); - if (override === "none") { + if (access === "default") { await Promise.all( - existingOverrides.map((permission) => - deletePermission(permission.id) + existingPermissions.map((permission) => + deletePermission(permission.id!) ) ); return; } - const targetDeny = override === "deny"; - const existingOverride = existingOverrides[0]; + const targetDeny = access === "deny"; + const primaryPermission = existingPermissions[0]; + const duplicatePermissionIds = existingPermissions + .slice(1) + .map((permission) => permission.id!); + + if (!primaryPermission) { + await addPermission({ + object: MCP_OBJECT, + action: MCP_ACTION, + subject: subjectId, + subject_type: mapPermissionSubjectType(subjectType), + deny: targetDeny, + source: "mcp_settings", + created_by: user?.id! + } as any); - if (existingOverride) { - if (existingOverride.deny !== targetDeny) { - await updatePermission({ - id: existingOverride.id, - deny: targetDeny - } as any); - } return; } - await addPermission({ - person_id: personId, - object: "mcp", - action: "mcp:use", - subject: EVERYONE_SUBJECT_ID, - subject_type: EVERYONE_SUBJECT_TYPE, - deny: targetDeny, - source: "mcp_settings", - created_by: user?.id! - } as any); - }, - onSuccess: () => { - refetchPermissions(); - }, - onError: (error) => { - toastError(error as any); - } - }); - - const { mutateAsync: allowSelectiveAccess, isLoading: isAllowingSelective } = - useMutation({ - mutationFn: async ({ - personId, - subjects - }: { - personId: string; - subjects: PermissionSubject[]; - }) => { - const normalizeSubject = (subject: PermissionSubject) => { - const subjectType = - subject.type === "person" - ? "person" - : subject.type === "team" - ? "team" - : subject.type === "permission_subject_group" - ? "group" - : null; - - if (!subjectType) { - return null; - } - - return `${subjectType}:${subject.id}`; - }; - - const desiredKeys = new Set( - subjects.map(normalizeSubject).filter(Boolean) as string[] - ); - - const existingPermissions = userPermissions.filter( - (permission) => - permission.person_id === personId && - permission.action === "mcp:use" && - permission.deny !== true && - permission.subject && - permission.id && - (permission.subject_type === "person" || - permission.subject_type === "team" || - permission.subject_type === "group") && - (permission.source === "mcp_settings" || permission.source === "UI") - ); - - const existingKeys = new Set( - existingPermissions.map( - (permission) => `${permission.subject_type}:${permission.subject}` - ) - ); - - const payloads = subjects - .map((subject) => { - const subjectType = - subject.type === "person" - ? "person" - : subject.type === "team" - ? "team" - : subject.type === "permission_subject_group" - ? "group" - : null; - - if (!subjectType) { - return null; - } - - const key = `${subjectType}:${subject.id}`; - if (existingKeys.has(key)) { - return null; - } - - return { - person_id: personId, - object: "mcp" as const, - action: "mcp:use", - subject: subject.id, - subject_type: subjectType, - deny: false, - source: "mcp_settings" as const, - created_by: user?.id - }; - }) - .filter(Boolean); - - const deleteIds = existingPermissions - .filter( - (permission) => - !desiredKeys.has( - `${permission.subject_type}:${permission.subject}` - ) - ) - .map((permission) => permission.id); - await Promise.all([ - ...payloads.map((payload) => addPermission(payload as any)), - ...deleteIds.map((id) => deletePermission(id)) + ...(primaryPermission.deny !== targetDeny + ? [ + updatePermission({ + id: primaryPermission.id, + deny: targetDeny + } as any) + ] + : []), + ...duplicatePermissionIds.map((id) => deletePermission(id)) ]); - - return { - added: payloads.length, - removed: deleteIds.length - }; }, - onSuccess: ({ added, removed }) => { - if (added === 0 && removed === 0) { - toastSuccess("No permission changes"); - } else { - toastSuccess(`Updated permissions: +${added} / -${removed}`); - } - - setSelectedUserId(null); + onSuccess: () => { refetchPermissions(); }, onError: (error) => { @@ -244,74 +142,12 @@ export default function McpOverviewPage() { } }); - const permissionsByUser = useMemo(() => { - const map = new Map(); - - for (const permission of userPermissions) { - if (!permission.person_id || permission.deny === true) { - continue; - } - - if ( - permission.subject_type === EVERYONE_SUBJECT_TYPE && - permission.subject === EVERYONE_SUBJECT_ID - ) { - continue; - } - - const current = map.get(permission.person_id) ?? { - users: [], - groups: [] - }; - - if (permission.subject_type === "person") { - current.users.push(permission); - } else if ( - permission.subject_type === "team" || - permission.subject_type === "group" - ) { - current.groups.push(permission); - } - - map.set(permission.person_id, current); - } - - return map; - }, [userPermissions]); - - const globalOverrideByUser = useMemo(() => { - const map = new Map(); - - for (const permission of userPermissions) { - if ( - !permission.person_id || - permission.action !== "mcp:use" || - permission.subject_type !== EVERYONE_SUBJECT_TYPE || - permission.subject !== EVERYONE_SUBJECT_ID - ) { - continue; - } - - map.set( - permission.person_id, - permission.deny === true ? "deny" : "allow" - ); - } - - return map; - }, [userPermissions]); - - const selectedUser = useMemo(() => { - return users.find((item) => item.id === selectedUserId); - }, [selectedUserId, users]); - const loading = isUsersLoading || isPermissionsLoading || isUsersRefetching || isPermissionsRefetching || - isUpdatingGlobalOverride || - isAllowingSelective; + isUpdatingUserAccess; return (

- User permissions + Subject permissions

- Control which users can be used by MCP clients through this gateway. + Control which users, teams, groups, or roles can use MCP through + this gateway.

-
- {users.map((person) => { - const permissions = permissionsByUser.get(person.id) ?? { - users: [], - groups: [] - }; +
+ {subjects.map((subject) => { + const permissions = permissionsByUser.get(subject.id) ?? []; + + const activePermission = + permissions.find( + (permission) => permission.source === "mcp_settings" + ) ?? permissions[0]; + + const access = !activePermission + ? "default" + : activePermission.deny === true + ? "deny" + : "allow"; return ( - { - setMutatingUserId(person.id); - setGlobalOverride( - { personId: person.id, override }, + action={MCP_ACTION} + object={MCP_OBJECT} + access={access} + isMutating={mutatingSubjectId === subject.id} + onChangeAccess={(access) => { + setMutatingSubjectId(subject.id); + setUserAccess( + { + subjectId: subject.id, + subjectType: subject.type, + access + }, { onSettled: () => { - setMutatingUserId((current) => - current === person.id ? null : current + setMutatingSubjectId((current) => + current === subject.id ? null : current ); } } ); }} - onAllowSelective={() => setSelectedUserId(person.id)} /> ); })}
- - { - if (!open) { - setSelectedUserId(null); - } - }} - title={`Allow access: ${selectedUser?.name || ""}`} - description="Select users or groups to allow this user for MCP usage." - preselectedSubjectIds={ - selectedUser - ? userPermissions - .filter( - (permission) => - permission.person_id === selectedUser.id && - permission.deny !== true && - permission.subject && - (permission.subject_type === "person" || - permission.subject_type === "team" || - permission.subject_type === "group") - ) - .map((permission) => permission.subject!) - : [] - } - isSubmitting={isAllowingSelective} - onAllow={async (subjects) => { - if (!selectedUser) { - return; - } - - await allowSelectiveAccess({ - personId: selectedUser.id, - subjects - }); - }} - />
); } From 6e4d24c194fdbb45844ad86f920a9294eddc890f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 19:10:42 +0545 Subject: [PATCH 07/48] fix(mcp): use playbook object selectors and dedupe overrides --- src/api/services/permissions.ts | 2 +- src/api/types/permissions.ts | 6 +- src/pages/Settings/mcp/McpPlaybooksPage.tsx | 171 +++++++++++++++----- src/pages/Settings/mcp/McpViewsPage.tsx | 22 ++- 4 files changed, 152 insertions(+), 49 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index ab27a0443b..b42512b381 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -158,7 +158,7 @@ export function recheckPermission(id: string) { export async function fetchMcpPlaybookPermissions() { const response = await IncidentCommander.get( - "/permissions_summary?select=*&action=eq.mcp:run&playbook_id=not.is.null&deleted_at=is.null&limit=5000" + "/permissions_summary?select=*&action=eq.mcp:run&deleted_at=is.null&limit=5000" ); return response.data ?? []; diff --git a/src/api/types/permissions.ts b/src/api/types/permissions.ts index 87347ed68f..b5ac311f90 100644 --- a/src/api/types/permissions.ts +++ b/src/api/types/permissions.ts @@ -23,7 +23,11 @@ type PermissionObjectSelector = { views?: ViewSelector[]; }; -interface Selectors {} +interface Selectors { + id?: string; + name?: string; + namespace?: string; +} interface ScopeSelector { namespace?: string; diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index 20e6da67b1..227d9f491b 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -8,6 +8,7 @@ import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { PlaybookNames } from "@flanksource-ui/api/types/playbooks"; import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; @@ -27,6 +28,28 @@ type PermissionBuckets = { const EVERYONE_SUBJECT_ID = "everyone"; const EVERYONE_SUBJECT_TYPE = "group"; +function permissionMatchesPlaybook( + permission: PermissionsSummary, + playbook: PlaybookNames +) { + const playbookRefs = permission.object_selector?.playbooks ?? []; + + return playbookRefs.some((playbookRef) => { + if (!playbookRef?.name) { + return false; + } + + if (playbookRef.namespace) { + return ( + playbookRef.namespace === playbook.namespace && + playbookRef.name === playbook.name + ); + } + + return playbookRef.name === playbook.name; + }); +} + export default function McpPlaybooksPage() { const { user } = useUser(); const [selectedPlaybookId, setSelectedPlaybookId] = useState( @@ -73,25 +96,27 @@ export default function McpPlaybooksPage() { const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = useMutation({ mutationFn: async ({ - playbookId, + playbook, override }: { - playbookId: string; + playbook: PlaybookNames; override: "allow" | "none" | "deny"; }) => { - const existingOverrides = playbookPermissions.filter( + const latestPermissions = await fetchMcpPlaybookPermissions(); + + const matchingOverrides = latestPermissions.filter( (permission) => - permission.playbook_id === playbookId && permission.action === "mcp:run" && permission.subject_type === EVERYONE_SUBJECT_TYPE && permission.subject === EVERYONE_SUBJECT_ID && permission.id && - permission.source === "mcp_settings" + permission.source === "mcp_settings" && + permissionMatchesPlaybook(permission, playbook) ); if (override === "none") { await Promise.all( - existingOverrides.map((permission) => + matchingOverrides.map((permission) => deletePermission(permission.id) ) ); @@ -99,12 +124,20 @@ export default function McpPlaybooksPage() { } const targetDeny = override === "deny"; - const existingOverride = existingOverrides[0]; + const [canonicalOverride, ...duplicateOverrides] = matchingOverrides; + + if (duplicateOverrides.length > 0) { + await Promise.all( + duplicateOverrides.map((permission) => + deletePermission(permission.id) + ) + ); + } - if (existingOverride) { - if (existingOverride.deny !== targetDeny) { + if (canonicalOverride) { + if (canonicalOverride.deny !== targetDeny) { await updatePermission({ - id: existingOverride.id, + id: canonicalOverride.id, deny: targetDeny } as any); } @@ -112,7 +145,9 @@ export default function McpPlaybooksPage() { } await addPermission({ - playbook_id: playbookId, + object_selector: { + playbooks: [{ name: playbook.name, namespace: playbook.namespace }] + }, action: "mcp:run", subject: EVERYONE_SUBJECT_ID, subject_type: EVERYONE_SUBJECT_TYPE, @@ -132,10 +167,10 @@ export default function McpPlaybooksPage() { const { mutateAsync: allowSelectiveAccess, isLoading: isAllowingSelective } = useMutation({ mutationFn: async ({ - playbookId, + playbook, subjects }: { - playbookId: string; + playbook: PlaybookNames; subjects: PermissionSubject[]; }) => { const normalizeSubject = (subject: PermissionSubject) => { @@ -161,7 +196,6 @@ export default function McpPlaybooksPage() { const existingPermissions = playbookPermissions.filter( (permission) => - permission.playbook_id === playbookId && permission.action === "mcp:run" && permission.deny !== true && permission.subject && @@ -169,7 +203,9 @@ export default function McpPlaybooksPage() { (permission.subject_type === "person" || permission.subject_type === "team" || permission.subject_type === "group") && - (permission.source === "mcp_settings" || permission.source === "UI") + (permission.source === "mcp_settings" || + permission.source === "UI") && + permissionMatchesPlaybook(permission, playbook) ); const existingKeys = new Set( @@ -178,6 +214,10 @@ export default function McpPlaybooksPage() { ) ); + const playbookSelector = [ + { name: playbook.name, namespace: playbook.namespace } + ]; + const payloads = subjects .map((subject) => { const subjectType = @@ -199,7 +239,7 @@ export default function McpPlaybooksPage() { } return { - playbook_id: playbookId, + object_selector: { playbooks: playbookSelector }, action: "mcp:run", subject: subject.id, subject_type: subjectType, @@ -247,8 +287,22 @@ export default function McpPlaybooksPage() { const permissionsByPlaybook = useMemo(() => { const map = new Map(); + const playbooksByNamespacedRef = new Map(); + const playbooksByName = new Map(); + + for (const playbook of playbooks) { + playbooksByNamespacedRef.set( + `${playbook.namespace || ""}/${playbook.name}`, + playbook + ); + + const existingPlaybooks = playbooksByName.get(playbook.name) ?? []; + existingPlaybooks.push(playbook); + playbooksByName.set(playbook.name, existingPlaybooks); + } + for (const permission of playbookPermissions) { - if (!permission.playbook_id || permission.deny === true) { + if (permission.deny === true) { continue; } @@ -259,32 +313,52 @@ export default function McpPlaybooksPage() { continue; } - const current = map.get(permission.playbook_id) ?? { - users: [], - groups: [] - }; - - if (permission.subject_type === "person") { - current.users.push(permission); - } else if ( - permission.subject_type === "team" || - permission.subject_type === "group" - ) { - current.groups.push(permission); + const playbookRefs = permission.object_selector?.playbooks ?? []; + if (playbookRefs.length === 0) { + continue; } - map.set(permission.playbook_id, current); + for (const playbookRef of playbookRefs) { + if (!playbookRef.name) { + continue; + } + + const matchedPlaybook = playbookRef.namespace + ? playbooksByNamespacedRef.get( + `${playbookRef.namespace}/${playbookRef.name}` + ) + : playbooksByName.get(playbookRef.name)?.[0]; + + if (!matchedPlaybook) { + continue; + } + + const current = map.get(matchedPlaybook.id) ?? { + users: [], + groups: [] + }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } + + map.set(matchedPlaybook.id, current); + } } return map; - }, [playbookPermissions]); + }, [playbookPermissions, playbooks]); const globalOverrideByPlaybook = useMemo(() => { const map = new Map(); for (const permission of playbookPermissions) { if ( - !permission.playbook_id || permission.action !== "mcp:run" || permission.subject_type !== EVERYONE_SUBJECT_TYPE || permission.subject !== EVERYONE_SUBJECT_ID @@ -292,14 +366,29 @@ export default function McpPlaybooksPage() { continue; } - map.set( - permission.playbook_id, - permission.deny === true ? "deny" : "allow" - ); + const playbookRefs = permission.object_selector?.playbooks ?? []; + + for (const playbookRef of playbookRefs) { + if (!playbookRef.name) { + continue; + } + + const playbook = playbooks.find( + (p) => + p.name === playbookRef.name && + (playbookRef.namespace + ? p.namespace === playbookRef.namespace + : true) + ); + + if (playbook) { + map.set(playbook.id, permission.deny === true ? "deny" : "allow"); + } + } } return map; - }, [playbookPermissions]); + }, [playbookPermissions, playbooks]); const selectedPlaybook = useMemo(() => { return playbooks.find((playbook) => playbook.id === selectedPlaybookId); @@ -358,7 +447,7 @@ export default function McpPlaybooksPage() { onGlobalOverrideChange={(override) => { setMutatingPlaybookId(playbook.id); setGlobalOverride( - { playbookId: playbook.id, override }, + { playbook, override }, { onSettled: () => { setMutatingPlaybookId((current) => @@ -389,12 +478,12 @@ export default function McpPlaybooksPage() { ? playbookPermissions .filter( (permission) => - permission.playbook_id === selectedPlaybook.id && permission.deny !== true && permission.subject && (permission.subject_type === "person" || permission.subject_type === "team" || - permission.subject_type === "group") + permission.subject_type === "group") && + permissionMatchesPlaybook(permission, selectedPlaybook) ) .map((permission) => permission.subject!) : [] @@ -406,7 +495,7 @@ export default function McpPlaybooksPage() { } await allowSelectiveAccess({ - playbookId: selectedPlaybook.id, + playbook: selectedPlaybook, subjects }); }} diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 752c3daf64..e34bfda690 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -93,7 +93,9 @@ export default function McpViewsPage() { view: View; override: "allow" | "none" | "deny"; }) => { - const existingOverrides = viewPermissions.filter( + const latestPermissions = await fetchMcpViewPermissions(); + + const matchingOverrides = latestPermissions.filter( (permission) => permission.action === "mcp:run" && permission.subject_type === EVERYONE_SUBJECT_TYPE && @@ -105,7 +107,7 @@ export default function McpViewsPage() { if (override === "none") { await Promise.all( - existingOverrides.map((permission) => + matchingOverrides.map((permission) => deletePermission(permission.id) ) ); @@ -113,12 +115,20 @@ export default function McpViewsPage() { } const targetDeny = override === "deny"; - const existingOverride = existingOverrides[0]; + const [canonicalOverride, ...duplicateOverrides] = matchingOverrides; + + if (duplicateOverrides.length > 0) { + await Promise.all( + duplicateOverrides.map((permission) => + deletePermission(permission.id) + ) + ); + } - if (existingOverride) { - if (existingOverride.deny !== targetDeny) { + if (canonicalOverride) { + if (canonicalOverride.deny !== targetDeny) { await updatePermission({ - id: existingOverride.id, + id: canonicalOverride.id, deny: targetDeny } as any); } From c02acb576d1375ff08b23e8c3320eb91eaac1fe4 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 21:13:44 +0545 Subject: [PATCH 08/48] fix(mcp): centralize and enforce mcp settings source --- src/api/services/permissions.ts | 3 +++ src/pages/Settings/mcp/McpOverviewPage.tsx | 19 +++++++++++-------- src/pages/Settings/mcp/McpPlaybooksPage.tsx | 13 +++++++------ src/pages/Settings/mcp/McpViewsPage.tsx | 13 +++++++------ 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index b42512b381..2656a9f378 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -156,6 +156,9 @@ export function recheckPermission(id: string) { }); } +// Source marker used by the MCP Settings UI for permissions it creates/manages. +export const MCP_SETTINGS_PERMISSION_SOURCE = "mcp_settings" as const; + export async function fetchMcpPlaybookPermissions() { const response = await IncidentCommander.get( "/permissions_summary?select=*&action=eq.mcp:run&deleted_at=is.null&limit=5000" diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx index cbf92cf1f3..238af58f4b 100644 --- a/src/pages/Settings/mcp/McpOverviewPage.tsx +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -3,6 +3,7 @@ import { deletePermission, fetchAllPermissionSubjects, fetchMcpUserPermissions, + MCP_SETTINGS_PERMISSION_SOURCE, PermissionSubject, updatePermission } from "@flanksource-ui/api/services/permissions"; @@ -23,12 +24,12 @@ function isMcpUserAccessPermission(permission: PermissionsSummary) { permission.object === MCP_OBJECT && !!permission.subject && !!permission.id && - (permission.source === "mcp_settings" || permission.source === "UI") + permission.source === MCP_SETTINGS_PERMISSION_SOURCE ); } function mapPermissionSubjectType(type: PermissionSubject["type"]) { - if (type === "permission_subject_group") { + if (type === "permission_subject_group" || type === "role") { return "group" as const; } @@ -91,7 +92,9 @@ export default function McpOverviewPage() { }) => { const existingPermissions = ( permissionsByUser.get(subjectId) ?? [] - ).filter((permission) => permission.source === "mcp_settings"); + ).filter( + (permission) => permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); if (access === "default") { await Promise.all( @@ -115,7 +118,7 @@ export default function McpOverviewPage() { subject: subjectId, subject_type: mapPermissionSubjectType(subjectType), deny: targetDeny, - source: "mcp_settings", + source: MCP_SETTINGS_PERMISSION_SOURCE, created_by: user?.id! } as any); @@ -173,10 +176,10 @@ export default function McpOverviewPage() { {subjects.map((subject) => { const permissions = permissionsByUser.get(subject.id) ?? []; - const activePermission = - permissions.find( - (permission) => permission.source === "mcp_settings" - ) ?? permissions[0]; + const activePermission = permissions.find( + (permission) => + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); const access = !activePermission ? "default" diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index 227d9f491b..c61e7650b6 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -5,6 +5,7 @@ import { fetchMcpPlaybookPermissions, updatePermission, fetchPermissionSubjects, + MCP_SETTINGS_PERMISSION_SOURCE, PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; @@ -110,7 +111,7 @@ export default function McpPlaybooksPage() { permission.subject_type === EVERYONE_SUBJECT_TYPE && permission.subject === EVERYONE_SUBJECT_ID && permission.id && - permission.source === "mcp_settings" && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && permissionMatchesPlaybook(permission, playbook) ); @@ -152,7 +153,7 @@ export default function McpPlaybooksPage() { subject: EVERYONE_SUBJECT_ID, subject_type: EVERYONE_SUBJECT_TYPE, deny: targetDeny, - source: "mcp_settings", + source: MCP_SETTINGS_PERMISSION_SOURCE, created_by: user?.id! } as any); }, @@ -203,8 +204,7 @@ export default function McpPlaybooksPage() { (permission.subject_type === "person" || permission.subject_type === "team" || permission.subject_type === "group") && - (permission.source === "mcp_settings" || - permission.source === "UI") && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && permissionMatchesPlaybook(permission, playbook) ); @@ -244,7 +244,7 @@ export default function McpPlaybooksPage() { subject: subject.id, subject_type: subjectType, deny: false, - source: "mcp_settings" as const, + source: MCP_SETTINGS_PERMISSION_SOURCE, created_by: user?.id }; }) @@ -361,7 +361,8 @@ export default function McpPlaybooksPage() { if ( permission.action !== "mcp:run" || permission.subject_type !== EVERYONE_SUBJECT_TYPE || - permission.subject !== EVERYONE_SUBJECT_ID + permission.subject !== EVERYONE_SUBJECT_ID || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE ) { continue; } diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index e34bfda690..4a1d0131c6 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -4,6 +4,7 @@ import { fetchMcpViewPermissions, updatePermission, fetchPermissionSubjects, + MCP_SETTINGS_PERMISSION_SOURCE, PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { getAllViews, View } from "@flanksource-ui/api/services/views"; @@ -101,7 +102,7 @@ export default function McpViewsPage() { permission.subject_type === EVERYONE_SUBJECT_TYPE && permission.subject === EVERYONE_SUBJECT_ID && permission.id && - permission.source === "mcp_settings" && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && permissionMatchesView(permission, view) ); @@ -143,7 +144,7 @@ export default function McpViewsPage() { subject: EVERYONE_SUBJECT_ID, subject_type: EVERYONE_SUBJECT_TYPE, deny: targetDeny, - source: "mcp_settings", + source: MCP_SETTINGS_PERMISSION_SOURCE, created_by: user?.id! } as any); }, @@ -194,8 +195,7 @@ export default function McpViewsPage() { (permission.subject_type === "person" || permission.subject_type === "team" || permission.subject_type === "group") && - (permission.source === "mcp_settings" || - permission.source === "UI") && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && permissionMatchesView(permission, view) ); @@ -233,7 +233,7 @@ export default function McpViewsPage() { subject: subject.id, subject_type: subjectType, deny: false, - source: "mcp_settings" as const, + source: MCP_SETTINGS_PERMISSION_SOURCE, created_by: user?.id }; }) @@ -338,7 +338,8 @@ export default function McpViewsPage() { if ( permission.action !== "mcp:run" || permission.subject_type !== EVERYONE_SUBJECT_TYPE || - permission.subject !== EVERYONE_SUBJECT_ID + permission.subject !== EVERYONE_SUBJECT_ID || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE ) { continue; } From 52b637a751dd3406140c238f54bc12943bb4617a Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 21:41:38 +0545 Subject: [PATCH 09/48] fix(mcp): resolve ambiguous view-name permission matching --- src/pages/Settings/mcp/McpViewsPage.tsx | 87 +++++++++++++++++++------ 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 4a1d0131c6..1f6cbb52a0 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -44,6 +44,25 @@ function permissionMatchesView(permission: PermissionsSummary, view: View) { }); } +function resolveViewsForRef( + viewRef: { name?: string; namespace?: string }, + viewsByNamespacedRef: Map, + viewsByName: Map +) { + if (!viewRef?.name) { + return []; + } + + if (viewRef.namespace) { + const matchedView = viewsByNamespacedRef.get( + `${viewRef.namespace}/${viewRef.name}` + ); + return matchedView ? [matchedView] : []; + } + + return viewsByName.get(viewRef.name) ?? []; +} + export default function McpViewsPage() { const { user } = useUser(); const [selectedViewId, setSelectedViewId] = useState(null); @@ -304,27 +323,35 @@ export default function McpViewsPage() { continue; } + const assignedViewIds = new Set(); + for (const viewRef of viewRefs) { - const matchedView = viewRef.namespace - ? viewsByNamespacedRef.get(`${viewRef.namespace}/${viewRef.name}`) - : viewsByName.get(viewRef.name)?.[0]; + const matchedViews = resolveViewsForRef( + viewRef, + viewsByNamespacedRef, + viewsByName + ); - if (!matchedView) { - continue; - } + for (const matchedView of matchedViews) { + if (assignedViewIds.has(matchedView.id)) { + continue; + } - const current = map.get(matchedView.id) ?? { users: [], groups: [] }; + assignedViewIds.add(matchedView.id); - if (permission.subject_type === "person") { - current.users.push(permission); - } else if ( - permission.subject_type === "team" || - permission.subject_type === "group" - ) { - current.groups.push(permission); - } + const current = map.get(matchedView.id) ?? { users: [], groups: [] }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } - map.set(matchedView.id, current); + map.set(matchedView.id, current); + } } } @@ -334,6 +361,17 @@ export default function McpViewsPage() { const globalOverrideByView = useMemo(() => { const map = new Map(); + const viewsByNamespacedRef = new Map(); + const viewsByName = new Map(); + + for (const view of views) { + viewsByNamespacedRef.set(`${view.namespace || ""}/${view.name}`, view); + + const existingViews = viewsByName.get(view.name) ?? []; + existingViews.push(view); + viewsByName.set(view.name, existingViews); + } + for (const permission of viewPermissions) { if ( permission.action !== "mcp:run" || @@ -345,14 +383,21 @@ export default function McpViewsPage() { } const viewRefs = permission.object_selector?.views ?? []; + const assignedViewIds = new Set(); + for (const viewRef of viewRefs) { - const view = views.find( - (v) => - v.name === viewRef.name && - (viewRef.namespace ? v.namespace === viewRef.namespace : true) + const matchedViews = resolveViewsForRef( + viewRef, + viewsByNamespacedRef, + viewsByName ); - if (view) { + for (const view of matchedViews) { + if (assignedViewIds.has(view.id)) { + continue; + } + + assignedViewIds.add(view.id); map.set(view.id, permission.deny === true ? "deny" : "allow"); } } From 3a3d0d7ee32602aa8ce41b41ce509f3f203e375b Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 6 Apr 2026 22:04:09 +0545 Subject: [PATCH 10/48] chore: refactor access card -> permission matching --- .../permissions/mcpPermissionCardMappings.ts | 183 +++++++++++++++++ src/pages/Settings/mcp/McpPlaybooksPage.tsx | 165 +++------------ src/pages/Settings/mcp/McpViewsPage.tsx | 188 +++--------------- 3 files changed, 237 insertions(+), 299 deletions(-) create mode 100644 src/lib/permissions/mcpPermissionCardMappings.ts diff --git a/src/lib/permissions/mcpPermissionCardMappings.ts b/src/lib/permissions/mcpPermissionCardMappings.ts new file mode 100644 index 0000000000..984805becb --- /dev/null +++ b/src/lib/permissions/mcpPermissionCardMappings.ts @@ -0,0 +1,183 @@ +import { + PermissionSubject, + MCP_SETTINGS_PERMISSION_SOURCE +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; + +export type NamespacedResource = { + id: string; + name: string; + namespace?: string; +}; + +export type NamespacedRef = { + name?: string; + namespace?: string; +}; + +export type PermissionBuckets = { + users: PermissionsSummary[]; + groups: PermissionsSummary[]; +}; + +export function buildSubjectLookup(subjects: PermissionSubject[]) { + return Object.fromEntries( + subjects.map((subject) => [ + subject.id, + { name: subject.name, type: subject.type } + ]) + ); +} + +export function permissionMatchesResource( + permission: PermissionsSummary, + resource: TResource, + getRefs: (permission: PermissionsSummary) => NamespacedRef[] +) { + const refs = getRefs(permission); + + return refs.some((ref) => { + if (!ref?.name) { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +function createResourceIndexes( + resources: TResource[] +) { + const byNamespacedRef = new Map(); + const byName = new Map(); + + for (const resource of resources) { + byNamespacedRef.set( + `${resource.namespace || ""}/${resource.name}`, + resource + ); + + const existing = byName.get(resource.name) ?? []; + existing.push(resource); + byName.set(resource.name, existing); + } + + return { byNamespacedRef, byName }; +} + +function resolveResourcesForRef( + ref: NamespacedRef, + indexes: ReturnType> +) { + if (!ref?.name) { + return []; + } + + if (ref.namespace) { + const matched = indexes.byNamespacedRef.get(`${ref.namespace}/${ref.name}`); + return matched ? [matched] : []; + } + + return indexes.byName.get(ref.name) ?? []; +} + +export function buildPermissionAccessCardMaps< + TResource extends NamespacedResource +>({ + resources, + permissions, + getRefs, + action = "mcp:run", + source = MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId = "everyone", + everyoneSubjectType = "group" +}: { + resources: TResource[]; + permissions: PermissionsSummary[]; + getRefs: (permission: PermissionsSummary) => NamespacedRef[]; + action?: string; + source?: string; + everyoneSubjectId?: string; + everyoneSubjectType?: string; +}) { + const permissionsByResource = new Map(); + const globalOverrideByResource = new Map(); + + const indexes = createResourceIndexes(resources); + + for (const permission of permissions) { + const refs = getRefs(permission); + if (refs.length === 0) { + continue; + } + + const matchedResources: TResource[] = []; + const matchedIds = new Set(); + + for (const ref of refs) { + const resourcesForRef = resolveResourcesForRef(ref, indexes); + for (const resource of resourcesForRef) { + if (matchedIds.has(resource.id)) { + continue; + } + matchedIds.add(resource.id); + matchedResources.push(resource); + } + } + + if (matchedResources.length === 0) { + continue; + } + + const isGlobalOverride = + permission.action === action && + permission.subject_type === everyoneSubjectType && + permission.subject === everyoneSubjectId && + permission.source === source; + + if (isGlobalOverride) { + for (const resource of matchedResources) { + globalOverrideByResource.set( + resource.id, + permission.deny === true ? "deny" : "allow" + ); + } + continue; + } + + if ( + permission.deny === true || + (permission.subject_type === everyoneSubjectType && + permission.subject === everyoneSubjectId) + ) { + continue; + } + + for (const resource of matchedResources) { + const current = permissionsByResource.get(resource.id) ?? { + users: [], + groups: [] + }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" + ) { + current.groups.push(permission); + } + + permissionsByResource.set(resource.id, current); + } + } + + return { + permissionsByResource, + globalOverrideByResource + }; +} diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index c61e7650b6..24e639ccf5 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -10,6 +10,11 @@ import { } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; import { PlaybookNames } from "@flanksource-ui/api/types/playbooks"; +import { + buildPermissionAccessCardMaps, + buildSubjectLookup, + permissionMatchesResource +} from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; @@ -21,34 +26,17 @@ import { useUser } from "@flanksource-ui/context"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; -type PermissionBuckets = { - users: PermissionsSummary[]; - groups: PermissionsSummary[]; -}; - const EVERYONE_SUBJECT_ID = "everyone"; const EVERYONE_SUBJECT_TYPE = "group"; +const getPlaybookRefs = (permission: PermissionsSummary) => + permission.object_selector?.playbooks ?? []; + function permissionMatchesPlaybook( permission: PermissionsSummary, playbook: PlaybookNames ) { - const playbookRefs = permission.object_selector?.playbooks ?? []; - - return playbookRefs.some((playbookRef) => { - if (!playbookRef?.name) { - return false; - } - - if (playbookRef.namespace) { - return ( - playbookRef.namespace === playbook.namespace && - playbookRef.name === playbook.name - ); - } - - return playbookRef.name === playbook.name; - }); + return permissionMatchesResource(permission, playbook, getPlaybookRefs); } export default function McpPlaybooksPage() { @@ -85,14 +73,10 @@ export default function McpPlaybooksPage() { queryFn: fetchPermissionSubjects }); - const subjectLookup = useMemo(() => { - return Object.fromEntries( - permissionSubjects.map((subject) => [ - subject.id, - { name: subject.name, type: subject.type } - ]) - ); - }, [permissionSubjects]); + const subjectLookup = useMemo( + () => buildSubjectLookup(permissionSubjects), + [permissionSubjects] + ); const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = useMutation({ @@ -284,113 +268,18 @@ export default function McpPlaybooksPage() { } }); - const permissionsByPlaybook = useMemo(() => { - const map = new Map(); - - const playbooksByNamespacedRef = new Map(); - const playbooksByName = new Map(); - - for (const playbook of playbooks) { - playbooksByNamespacedRef.set( - `${playbook.namespace || ""}/${playbook.name}`, - playbook - ); - - const existingPlaybooks = playbooksByName.get(playbook.name) ?? []; - existingPlaybooks.push(playbook); - playbooksByName.set(playbook.name, existingPlaybooks); - } - - for (const permission of playbookPermissions) { - if (permission.deny === true) { - continue; - } - - if ( - permission.subject_type === EVERYONE_SUBJECT_TYPE && - permission.subject === EVERYONE_SUBJECT_ID - ) { - continue; - } - - const playbookRefs = permission.object_selector?.playbooks ?? []; - if (playbookRefs.length === 0) { - continue; - } - - for (const playbookRef of playbookRefs) { - if (!playbookRef.name) { - continue; - } - - const matchedPlaybook = playbookRef.namespace - ? playbooksByNamespacedRef.get( - `${playbookRef.namespace}/${playbookRef.name}` - ) - : playbooksByName.get(playbookRef.name)?.[0]; - - if (!matchedPlaybook) { - continue; - } - - const current = map.get(matchedPlaybook.id) ?? { - users: [], - groups: [] - }; - - if (permission.subject_type === "person") { - current.users.push(permission); - } else if ( - permission.subject_type === "team" || - permission.subject_type === "group" - ) { - current.groups.push(permission); - } - - map.set(matchedPlaybook.id, current); - } - } - - return map; - }, [playbookPermissions, playbooks]); - - const globalOverrideByPlaybook = useMemo(() => { - const map = new Map(); - - for (const permission of playbookPermissions) { - if ( - permission.action !== "mcp:run" || - permission.subject_type !== EVERYONE_SUBJECT_TYPE || - permission.subject !== EVERYONE_SUBJECT_ID || - permission.source !== MCP_SETTINGS_PERMISSION_SOURCE - ) { - continue; - } - - const playbookRefs = permission.object_selector?.playbooks ?? []; - - for (const playbookRef of playbookRefs) { - if (!playbookRef.name) { - continue; - } - - const playbook = playbooks.find( - (p) => - p.name === playbookRef.name && - (playbookRef.namespace - ? p.namespace === playbookRef.namespace - : true) - ); - - if (playbook) { - map.set(playbook.id, permission.deny === true ? "deny" : "allow"); - } - } - } - - return map; - }, [playbookPermissions, playbooks]); - + const { permissionsByResource, globalOverrideByResource } = useMemo( + () => + buildPermissionAccessCardMaps({ + resources: playbooks, + permissions: playbookPermissions, + getRefs: getPlaybookRefs, + source: MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId: EVERYONE_SUBJECT_ID, + everyoneSubjectType: EVERYONE_SUBJECT_TYPE + }), + [playbookPermissions, playbooks] + ); const selectedPlaybook = useMemo(() => { return playbooks.find((playbook) => playbook.id === selectedPlaybookId); }, [playbooks, selectedPlaybookId]); @@ -424,7 +313,7 @@ export default function McpPlaybooksPage() {
{playbooks.map((playbook) => { - const permissions = permissionsByPlaybook.get(playbook.id) ?? { + const permissions = permissionsByResource.get(playbook.id) ?? { users: [], groups: [] }; @@ -442,7 +331,7 @@ export default function McpPlaybooksPage() { groups={permissions.groups} subjectLookup={subjectLookup} globalOverride={ - globalOverrideByPlaybook.get(playbook.id) ?? "none" + globalOverrideByResource.get(playbook.id) ?? "none" } isMutating={mutatingPlaybookId === playbook.id} onGlobalOverrideChange={(override) => { diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 1f6cbb52a0..720ee3f775 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -9,6 +9,11 @@ import { } from "@flanksource-ui/api/services/permissions"; import { getAllViews, View } from "@flanksource-ui/api/services/views"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { + buildPermissionAccessCardMaps, + buildSubjectLookup, + permissionMatchesResource +} from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; import PermissionAccessCard from "@flanksource-ui/components/Permissions/PermissionAccessCard"; import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; import SubjectSelectorModal from "@flanksource-ui/components/Permissions/SubjectSelectorModal"; @@ -20,47 +25,14 @@ import { useUser } from "@flanksource-ui/context"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; -type PermissionBuckets = { - users: PermissionsSummary[]; - groups: PermissionsSummary[]; -}; - const EVERYONE_SUBJECT_ID = "everyone"; const EVERYONE_SUBJECT_TYPE = "group"; -function permissionMatchesView(permission: PermissionsSummary, view: View) { - const viewRefs = permission.object_selector?.views ?? []; - - return viewRefs.some((viewRef) => { - if (!viewRef.name) { - return false; - } +const getViewRefs = (permission: PermissionsSummary) => + permission.object_selector?.views ?? []; - if (viewRef.namespace) { - return viewRef.namespace === view.namespace && viewRef.name === view.name; - } - - return viewRef.name === view.name; - }); -} - -function resolveViewsForRef( - viewRef: { name?: string; namespace?: string }, - viewsByNamespacedRef: Map, - viewsByName: Map -) { - if (!viewRef?.name) { - return []; - } - - if (viewRef.namespace) { - const matchedView = viewsByNamespacedRef.get( - `${viewRef.namespace}/${viewRef.name}` - ); - return matchedView ? [matchedView] : []; - } - - return viewsByName.get(viewRef.name) ?? []; +function permissionMatchesView(permission: PermissionsSummary, view: View) { + return permissionMatchesResource(permission, view, getViewRefs); } export default function McpViewsPage() { @@ -95,14 +67,10 @@ export default function McpViewsPage() { queryFn: fetchPermissionSubjects }); - const subjectLookup = useMemo(() => { - return Object.fromEntries( - permissionSubjects.map((subject) => [ - subject.id, - { name: subject.name, type: subject.type } - ]) - ); - }, [permissionSubjects]); + const subjectLookup = useMemo( + () => buildSubjectLookup(permissionSubjects), + [permissionSubjects] + ); const { mutate: setGlobalOverride, isLoading: isUpdatingGlobalOverride } = useMutation({ @@ -292,120 +260,18 @@ export default function McpViewsPage() { } }); - const permissionsByView = useMemo(() => { - const map = new Map(); - - const viewsByNamespacedRef = new Map(); - const viewsByName = new Map(); - - for (const view of views) { - viewsByNamespacedRef.set(`${view.namespace || ""}/${view.name}`, view); - - const existingViews = viewsByName.get(view.name) ?? []; - existingViews.push(view); - viewsByName.set(view.name, existingViews); - } - - for (const permission of viewPermissions) { - if (permission.deny === true) { - continue; - } - - if ( - permission.subject_type === EVERYONE_SUBJECT_TYPE && - permission.subject === EVERYONE_SUBJECT_ID - ) { - continue; - } - - const viewRefs = permission.object_selector?.views ?? []; - if (viewRefs.length === 0) { - continue; - } - - const assignedViewIds = new Set(); - - for (const viewRef of viewRefs) { - const matchedViews = resolveViewsForRef( - viewRef, - viewsByNamespacedRef, - viewsByName - ); - - for (const matchedView of matchedViews) { - if (assignedViewIds.has(matchedView.id)) { - continue; - } - - assignedViewIds.add(matchedView.id); - - const current = map.get(matchedView.id) ?? { users: [], groups: [] }; - - if (permission.subject_type === "person") { - current.users.push(permission); - } else if ( - permission.subject_type === "team" || - permission.subject_type === "group" - ) { - current.groups.push(permission); - } - - map.set(matchedView.id, current); - } - } - } - - return map; - }, [viewPermissions, views]); - - const globalOverrideByView = useMemo(() => { - const map = new Map(); - - const viewsByNamespacedRef = new Map(); - const viewsByName = new Map(); - - for (const view of views) { - viewsByNamespacedRef.set(`${view.namespace || ""}/${view.name}`, view); - - const existingViews = viewsByName.get(view.name) ?? []; - existingViews.push(view); - viewsByName.set(view.name, existingViews); - } - - for (const permission of viewPermissions) { - if ( - permission.action !== "mcp:run" || - permission.subject_type !== EVERYONE_SUBJECT_TYPE || - permission.subject !== EVERYONE_SUBJECT_ID || - permission.source !== MCP_SETTINGS_PERMISSION_SOURCE - ) { - continue; - } - - const viewRefs = permission.object_selector?.views ?? []; - const assignedViewIds = new Set(); - - for (const viewRef of viewRefs) { - const matchedViews = resolveViewsForRef( - viewRef, - viewsByNamespacedRef, - viewsByName - ); - - for (const view of matchedViews) { - if (assignedViewIds.has(view.id)) { - continue; - } - - assignedViewIds.add(view.id); - map.set(view.id, permission.deny === true ? "deny" : "allow"); - } - } - } - - return map; - }, [viewPermissions, views]); - + const { permissionsByResource, globalOverrideByResource } = useMemo( + () => + buildPermissionAccessCardMaps({ + resources: views, + permissions: viewPermissions, + getRefs: getViewRefs, + source: MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId: EVERYONE_SUBJECT_ID, + everyoneSubjectType: EVERYONE_SUBJECT_TYPE + }), + [viewPermissions, views] + ); const selectedView = useMemo(() => { return views.find((view) => view.id === selectedViewId); }, [selectedViewId, views]); @@ -439,7 +305,7 @@ export default function McpViewsPage() {
{views.map((view) => { - const permissions = permissionsByView.get(view.id) ?? { + const permissions = permissionsByResource.get(view.id) ?? { users: [], groups: [] }; @@ -456,7 +322,7 @@ export default function McpViewsPage() { users={permissions.users} groups={permissions.groups} subjectLookup={subjectLookup} - globalOverride={globalOverrideByView.get(view.id) ?? "none"} + globalOverride={globalOverrideByResource.get(view.id) ?? "none"} isMutating={mutatingViewId === view.id} onGlobalOverrideChange={(override) => { setMutatingViewId(view.id); From 7e321fa6c02f1e18abeb13f9be63d8770698b94f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 7 Apr 2026 08:57:43 +0545 Subject: [PATCH 11/48] fix(mcp): centralize tab-body loading and avoid blank switch --- src/App.tsx | 15 +++------------ src/components/MCP/McpTabsLinks.tsx | 15 +++++++++++++-- src/pages/Settings/mcp/McpOverviewPage.tsx | 5 +++++ src/pages/Settings/mcp/McpPlaybooksPage.tsx | 5 +++++ src/pages/Settings/mcp/McpViewsPage.tsx | 5 +++++ 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b7b5efa31c..54fa2c1a96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,9 @@ import { UserAccessStateContextProvider } from "./context/UserAccessContext/User import { tables } from "./context/UserAccessContext/permissions"; import { PermissionsPage } from "./pages/Settings/PermissionsPage"; +import McpOverviewPage from "./pages/Settings/mcp/McpOverviewPage"; +import McpPlaybooksPage from "./pages/Settings/mcp/McpPlaybooksPage"; +import McpViewsPage from "./pages/Settings/mcp/McpViewsPage"; import ScopesPage from "./pages/Settings/ScopesPage"; import { features } from "./services/permissions/features"; import { getViewsForSidebar, ViewSummary } from "./api/services/views"; @@ -210,18 +213,6 @@ const NotificationsSilencedPage = dynamic( ) ); -const McpOverviewPage = dynamic( - () => import("@flanksource-ui/pages/Settings/mcp/McpOverviewPage") -); - -const McpPlaybooksPage = dynamic( - () => import("@flanksource-ui/pages/Settings/mcp/McpPlaybooksPage") -); - -const McpViewsPage = dynamic( - () => import("@flanksource-ui/pages/Settings/mcp/McpViewsPage") -); - const UsersPage = dynamic(() => import("@flanksource-ui/pages/UsersPage").then((m) => m.UsersPage) ); diff --git a/src/components/MCP/McpTabsLinks.tsx b/src/components/MCP/McpTabsLinks.tsx index 6c9b6e3762..85a4a06395 100644 --- a/src/components/MCP/McpTabsLinks.tsx +++ b/src/components/MCP/McpTabsLinks.tsx @@ -5,6 +5,7 @@ import { } from "@flanksource-ui/ui/BreadcrumbNav"; import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import { Loading } from "@flanksource-ui/ui/Loading"; import TabbedLinks from "@flanksource-ui/ui/Tabs/TabbedLinks"; import clsx from "clsx"; import { useMemo } from "react"; @@ -18,6 +19,8 @@ type McpTabsLinksProps = { className?: string; onRefresh?: () => void; loading?: boolean; + isInitialLoading?: boolean; + loadingText?: string; }; export default function McpTabsLinks({ @@ -25,7 +28,9 @@ export default function McpTabsLinks({ children, className, onRefresh = () => {}, - loading = false + loading = false, + isInitialLoading = false, + loadingText = "Loading..." }: McpTabsLinksProps) { const [searchParams] = useSearchParams(); @@ -83,7 +88,13 @@ export default function McpTabsLinks({ className )} > - {children} + + {isInitialLoading ? ( + + ) : ( + children + )} +
diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx index 238af58f4b..1c8255493c 100644 --- a/src/pages/Settings/mcp/McpOverviewPage.tsx +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -152,10 +152,15 @@ export default function McpOverviewPage() { isPermissionsRefetching || isUpdatingUserAccess; + const isInitialLoading = + (isUsersLoading || isPermissionsLoading) && subjects.length === 0; + return ( { refetchUsers(); refetchPermissions(); diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx index 24e639ccf5..0db14fb4fa 100644 --- a/src/pages/Settings/mcp/McpPlaybooksPage.tsx +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -292,10 +292,15 @@ export default function McpPlaybooksPage() { isUpdatingGlobalOverride || isAllowingSelective; + const isInitialLoading = + (isPlaybooksLoading || isPermissionsLoading) && playbooks.length === 0; + return ( { refetchPlaybooks(); refetchPermissions(); diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx index 720ee3f775..fb70423191 100644 --- a/src/pages/Settings/mcp/McpViewsPage.tsx +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -284,10 +284,15 @@ export default function McpViewsPage() { isUpdatingGlobalOverride || isAllowingSelective; + const isInitialLoading = + (isViewsLoading || isPermissionsLoading) && views.length === 0; + return ( { refetchViews(); refetchPermissions(); From 47c229278772ed4d4e6af498fde7569970cd6d1f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 7 Apr 2026 09:42:18 +0545 Subject: [PATCH 12/48] fix(mcp): cap card subjects and scope queries to mcp source --- src/api/services/permissions.ts | 6 +- .../Permissions/PermissionAccessCard.tsx | 36 +++- .../Permissions/SubjectSelectorModal.tsx | 189 +++++++++++------- src/pages/Settings/mcp/McpPlaybooksPage.tsx | 34 ++++ src/pages/Settings/mcp/McpViewsPage.tsx | 34 +++- 5 files changed, 219 insertions(+), 80 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 2656a9f378..ed99ecb1e6 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -161,7 +161,7 @@ export const MCP_SETTINGS_PERMISSION_SOURCE = "mcp_settings" as const; export async function fetchMcpPlaybookPermissions() { const response = await IncidentCommander.get( - "/permissions_summary?select=*&action=eq.mcp:run&deleted_at=is.null&limit=5000" + `/permissions_summary?select=*&action=eq.mcp:run&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` ); return response.data ?? []; @@ -169,7 +169,7 @@ export async function fetchMcpPlaybookPermissions() { export async function fetchMcpViewPermissions() { const response = await IncidentCommander.get( - "/permissions_summary?select=*&action=eq.mcp:run&deleted_at=is.null&limit=5000" + `/permissions_summary?select=*&action=eq.mcp:run&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` ); return response.data ?? []; @@ -177,7 +177,7 @@ export async function fetchMcpViewPermissions() { export async function fetchMcpUserPermissions() { const response = await IncidentCommander.get( - "/permissions_summary?select=*&action=eq.mcp:use&object=eq.mcp&deleted_at=is.null&limit=5000" + `/permissions_summary?select=*&action=eq.mcp:use&object=eq.mcp&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` ); return response.data ?? []; diff --git a/src/components/Permissions/PermissionAccessCard.tsx b/src/components/Permissions/PermissionAccessCard.tsx index a02fb77e53..8b798a024d 100644 --- a/src/components/Permissions/PermissionAccessCard.tsx +++ b/src/components/Permissions/PermissionAccessCard.tsx @@ -23,6 +23,7 @@ type PermissionAccessCardProps = { globalOverride?: "allow" | "none" | "deny"; onGlobalOverrideChange: (value: "allow" | "none" | "deny") => void; onAllowSelective: () => void; + onViewSubjects?: () => void; isMutating?: boolean; }; @@ -119,8 +120,15 @@ export default function PermissionAccessCard({ globalOverride = "none", onGlobalOverrideChange, onAllowSelective, + onViewSubjects, isMutating = false }: PermissionAccessCardProps) { + const maxVisiblePerSection = 2; + const visibleGroups = groups.slice(0, maxVisiblePerSection); + const visibleUsers = users.slice(0, maxVisiblePerSection); + const hiddenGroupsCount = Math.max(groups.length - maxVisiblePerSection, 0); + const hiddenUsersCount = Math.max(users.length - maxVisiblePerSection, 0); + return ( @@ -187,7 +195,14 @@ export default function PermissionAccessCard({
- + { + if (globalOverride === "none") { + onViewSubjects?.(); + } + }} + > {globalOverride !== "none" ? (
{globalOverride === "allow" @@ -202,13 +217,18 @@ export default function PermissionAccessCard({
{groups.length > 0 ? (
- {groups.map((groupPermission, idx) => ( + {visibleGroups.map((groupPermission, idx) => ( ))} + {hiddenGroupsCount > 0 ? ( +
+ and {hiddenGroupsCount} more +
+ ) : null}
) : (
@@ -223,13 +243,18 @@ export default function PermissionAccessCard({
{users.length > 0 ? (
- {users.map((userPermission, idx) => ( + {visibleUsers.map((userPermission, idx) => ( ))} + {hiddenUsersCount > 0 ? ( +
+ and {hiddenUsersCount} more +
+ ) : null}
) : (
@@ -243,7 +268,10 @@ export default function PermissionAccessCard({ variant="outline" size="sm" className="h-8" - onClick={onAllowSelective} + onClick={(e) => { + e.stopPropagation(); + onAllowSelective(); + }} disabled={isMutating} > + Add user or group diff --git a/src/components/Permissions/SubjectSelectorModal.tsx b/src/components/Permissions/SubjectSelectorModal.tsx index 5f8ee86f0a..3374040fcd 100644 --- a/src/components/Permissions/SubjectSelectorModal.tsx +++ b/src/components/Permissions/SubjectSelectorModal.tsx @@ -35,9 +35,10 @@ type SubjectSelectorModalProps = { onOpenChange: (open: boolean) => void; title: string; description?: string; - onAllow: (selection: PermissionSubject[]) => Promise | void; + onAllow?: (selection: PermissionSubject[]) => Promise | void; preselectedSubjectIds?: string[]; isSubmitting?: boolean; + mode?: "edit" | "readonly"; }; function SubjectIcon({ subject }: { subject: PermissionSubject }) { @@ -64,7 +65,8 @@ export default function SubjectSelectorModal({ description, onAllow, preselectedSubjectIds = [], - isSubmitting = false + isSubmitting = false, + mode = "edit" }: SubjectSelectorModalProps) { const [search, setSearch] = useState(""); const [pageIndex, setPageIndex] = useState(0); @@ -116,21 +118,24 @@ export default function SubjectSelectorModal({ // Fetch full subject objects for every preselected ID so that // `selectedSubjects` is complete even when those subjects live on a // different page of results. - useQuery({ - queryKey: ["permission-subjects-by-ids", preselectedSubjectIds], - queryFn: () => fetchPermissionSubjectsByIds(preselectedSubjectIds), - enabled: open && preselectedSubjectIds.length > 0, - staleTime: 60_000, - onSuccess: (data) => { - setSelectedSubjects((prev) => { - const next = { ...prev }; - for (const subject of data) { - next[subject.id] = subject; - } - return next; - }); - } - }); + const shouldFetchSubjectsByIds = open && preselectedSubjectIds.length > 0; + + const { data: subjectsByIds = [], isLoading: isSubjectsByIdsLoading } = + useQuery({ + queryKey: ["permission-subjects-by-ids", preselectedSubjectIds], + queryFn: () => fetchPermissionSubjectsByIds(preselectedSubjectIds), + enabled: shouldFetchSubjectsByIds, + staleTime: 60_000, + onSuccess: (data) => { + setSelectedSubjects((prev) => { + const next = { ...prev }; + for (const subject of data) { + next[subject.id] = subject; + } + return next; + }); + } + }); const debouncedSearch = useDebouncedValue(search, 300) ?? ""; @@ -152,15 +157,34 @@ export default function SubjectSelectorModal({ pageIndex, pageSize: PAGE_SIZE }), - enabled: open + enabled: open && mode === "edit" }); const subjects = useMemo( () => (data?.data ?? []) as PermissionSubject[], [data?.data] ); - const totalEntries = data?.totalEntries ?? 0; - const pageCount = Math.ceil(totalEntries / PAGE_SIZE); + const readonlySubjects = useMemo(() => { + const normalizedSearch = debouncedSearch.trim().toLowerCase(); + + if (!normalizedSearch) { + return subjectsByIds; + } + + return subjectsByIds.filter((subject) => + subject.name.toLowerCase().includes(normalizedSearch) + ); + }, [debouncedSearch, subjectsByIds]); + + const displayedSubjects = mode === "readonly" ? readonlySubjects : subjects; + const totalEntries = + mode === "readonly" ? readonlySubjects.length : (data?.totalEntries ?? 0); + const pageCount = + mode === "readonly" + ? totalEntries > 0 + ? 1 + : 0 + : Math.ceil(totalEntries / PAGE_SIZE); // Enrich selectedSubjects as new pages are loaded. useEffect(() => { @@ -226,21 +250,27 @@ export default function SubjectSelectorModal({ />
- {isLoading ? ( + {mode === "readonly" && + shouldFetchSubjectsByIds && + isSubjectsByIdsLoading ? (
Loading...
- ) : subjects.length > 0 ? ( - subjects.map((subject) => ( + ) : mode === "edit" && isLoading ? ( +
Loading...
+ ) : displayedSubjects.length > 0 ? ( + displayedSubjects.map((subject) => (