diff --git a/package-lock.json b/package-lock.json index 21ab7c45ee..35cc5d4b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@flanksource/flanksource-ui", - "version": "1.4.218", + "version": "1.4.232", "dependencies": { "@ai-sdk/anthropic": "^3.0.1", "@ai-sdk/mcp": "^1.0.1", @@ -40,6 +40,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@storybook/client-api": "^7.6.17", @@ -7655,6 +7656,106 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", diff --git a/package.json b/package.json index 38571882d0..8ae4587b81 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@storybook/client-api": "^7.6.17", diff --git a/src/App.tsx b/src/App.tsx index cb70bfd0b1..39fc0a7b5f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,12 @@ import { UserAccessStateContextProvider } from "./context/UserAccessContext/User import { tables } from "./context/UserAccessContext/permissions"; import { PermissionsPage } from "./pages/Settings/PermissionsPage"; +import { PermissionsSubjectsPage } from "./pages/Settings/PermissionsSubjectsPage"; +import McpOverviewPage from "./pages/Settings/mcp/McpOverviewPage"; +import McpPlaybooksPage from "./pages/Settings/mcp/McpPlaybooksPage"; +import McpViewsPage from "./pages/Settings/mcp/McpViewsPage"; +import McpSubjectAccessPage from "./pages/Settings/mcp/McpSubjectAccessPage"; +import McpCheckAccessPage from "./pages/Settings/mcp/McpCheckAccessPage"; import ScopesPage from "./pages/Settings/ScopesPage"; import { features } from "./services/permissions/features"; import { getViewsForSidebar, ViewSummary } from "./api/services/views"; @@ -427,6 +433,15 @@ const settingsNav: SettingsNavigationItems = { 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", @@ -735,6 +750,14 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { "read" )} /> + , + tables.permissions, + "read" + )} + /> + + } /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + 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..43a1839787 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -17,8 +17,15 @@ 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" + | "access_token_person"; }; function composeQueryParamForFetchPermissions({ @@ -149,3 +156,87 @@ export function recheckPermission(id: string) { error: null }); } + +// 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 fetchMcpRunPermissions() { + const response = await IncidentCommander.get( + `/permissions_summary?select=*&action=eq.mcp:run&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` + ); + + return response.data ?? []; +} + +export async function fetchMcpUserPermissions() { + const response = await IncidentCommander.get( + `/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 ?? []; +} + +export type PermissionSubject = { + id: string; + name: string; + type: + | "team" + | "permission_subject_group" + | "person" + | "role" + | "access_token_person"; + owner?: string | null; +}; + +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,owner&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 fetchPermissionSubjectsByIds(ids: string[]) { + if (ids.length === 0) { + return []; + } + const response = await IncidentCommander.get( + `/permission_subjects?select=id,name,type,owner&id=in.(${ids.join(",")})&limit=${ids.length}` + ); + return response.data ?? []; +} + +async function fetchPermissionSubjectsWithOrder(order: string) { + const response = await IncidentCommander.get( + `/permission_subjects?select=id,name,type,owner&order=${order}&limit=5000` + ); + + return response.data ?? []; +} + +export async function fetchPermissionSubjects() { + return fetchPermissionSubjectsWithOrder("name.asc"); +} + +export async function fetchAllPermissionSubjects() { + return fetchPermissionSubjectsWithOrder("type.asc,name.asc"); +} diff --git a/src/api/services/playbooks.ts b/src/api/services/playbooks.ts index c9a0be84ca..468d5e762f 100644 --- a/src/api/services/playbooks.ts +++ b/src/api/services/playbooks.ts @@ -27,7 +27,7 @@ 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 ?? []; } diff --git a/src/api/services/rbac.ts b/src/api/services/rbac.ts index aa441a09d2..17948e022b 100644 --- a/src/api/services/rbac.ts +++ b/src/api/services/rbac.ts @@ -54,3 +54,190 @@ export async function getPermissions(id: string): Promise { ); return response.data.payload ?? []; } + +export type SubjectAccessReviewResource = { + playbook?: string; + view?: string; + config?: string; + check?: string; + global?: string; + [key: string]: string | undefined; +}; + +export type SubjectAccessReviewAction = + | "read" + | "mcp:run" + | "mcp:use" + | "playbook:run" + | "playbook:cancel" + | "playbook:approve"; + +export type SubjectAccessReviewRequest = { + resource: SubjectAccessReviewResource; + action: SubjectAccessReviewAction; + subjects: string[]; +}; + +export type SubjectAccessReviewMatchedPolicy = { + subject: string; + object: string; + action: string; + effect: "allow" | "deny"; + condition?: string; + id?: string; +}; + +export type SubjectAccessReviewResult = { + subject: string; + allowed: boolean; + reason?: string; + trace?: { + allow_count: number; + deny_count: number; + matched_policies: SubjectAccessReviewMatchedPolicy[]; + }; + error?: string; +}; + +export type SubjectAccessReviewResponse = { + resource: SubjectAccessReviewResource; + action: SubjectAccessReviewAction; + results: SubjectAccessReviewResult[]; +}; + +export async function reviewSubjectAccess( + payload: SubjectAccessReviewRequest +): Promise { + const response = await Rback.post( + "/subject-access-reviews", + payload + ); + + return response.data; +} + +export type EffectiveSubjectResourceAccessResource = { + id: string; + type: "playbook" | "view"; +}; + +export type EffectiveSubjectResourceAccessRequest = { + subject: string; + action: SubjectAccessReviewAction; + resources: EffectiveSubjectResourceAccessResource[]; +}; + +export type EffectiveSubjectResourceAccessResult = { + resourceId: string; + resourceType: "playbook" | "view"; + allowed: boolean; +}; + +export type EffectiveSubjectResourceAccessResponse = { + subject: string; + action: SubjectAccessReviewAction; + results: EffectiveSubjectResourceAccessResult[]; +}; + +type SubjectAccessSearchRequest = { + subject: string; + action: SubjectAccessReviewAction; + resource_types?: Array<"playbook" | "view">; + search?: string; + namespace?: string; +}; + +type SubjectAccessSearchResponse = { + subject: string; + action: SubjectAccessReviewAction; + resource_types: Array<"playbook" | "view">; + total: number; + limit: number; + offset: number; + results: Array<{ + resource_type: "playbook" | "view"; + id: string; + name: string; + namespace?: string; + }>; +}; + +export async function fetchEffectiveSubjectResourceAccess( + payload: EffectiveSubjectResourceAccessRequest +): Promise { + const resourceTypes = Array.from( + new Set(payload.resources.map((resource) => resource.type)) + ); + + const allowedResourceKeys = new Set(); + + const response = await Rback.post( + "/subject-access-search", + { + subject: payload.subject, + action: payload.action, + resource_types: resourceTypes + } satisfies SubjectAccessSearchRequest + ); + + const data = response.data; + + for (const result of data.results ?? []) { + allowedResourceKeys.add(`${result.resource_type}:${result.id}`); + } + + return { + subject: payload.subject, + action: payload.action, + results: payload.resources.map((resource) => ({ + resourceId: resource.id, + resourceType: resource.type, + allowed: allowedResourceKeys.has(`${resource.type}:${resource.id}`) + })) + }; +} + +export type EffectiveResourceSubjectAccessRequest = { + resource: EffectiveSubjectResourceAccessResource; + action: SubjectAccessReviewAction; + subjects: string[]; +}; + +export type EffectiveResourceSubjectAccessResult = { + subjectId: string; + allowed: boolean; +}; + +export type EffectiveResourceSubjectAccessResponse = { + resource: EffectiveSubjectResourceAccessResource; + action: SubjectAccessReviewAction; + results: EffectiveResourceSubjectAccessResult[]; +}; + +export async function fetchEffectiveResourceSubjectAccess( + payload: EffectiveResourceSubjectAccessRequest +): Promise { + const resource: SubjectAccessReviewResource = + payload.resource.type === "playbook" + ? { playbook: payload.resource.id } + : { view: payload.resource.id }; + + const response = await reviewSubjectAccess({ + resource, + action: payload.action, + subjects: ["*"] + }); + + const allowedBySubject = new Map( + (response.results ?? []).map((result) => [result.subject, result.allowed]) + ); + + return { + resource: payload.resource, + action: payload.action, + results: payload.subjects.map((subjectId) => ({ + subjectId, + allowed: allowedBySubject.get(subjectId) === true + })) + }; +} diff --git a/src/api/types/permissions.ts b/src/api/types/permissions.ts index 73dbb6a0f3..2a219ded58 100644 --- a/src/api/types/permissions.ts +++ b/src/api/types/permissions.ts @@ -14,6 +14,12 @@ export type PermissionGlobalObject = | "topology" | "mcp"; +/** + * Selector map for `object_selector` in a permission rule. + * + * Each key narrows the permission scope for that resource type. + * If omitted, the permission applies to all objects of the selected global object. + */ type PermissionObjectSelector = { playbooks?: Selectors[]; connections?: Selectors[]; @@ -23,7 +29,11 @@ type PermissionObjectSelector = { views?: ViewSelector[]; }; -interface Selectors {} +interface Selectors { + id?: string; + name?: string; + namespace?: string; +} interface ScopeSelector { namespace?: string; @@ -47,7 +57,9 @@ export type PermissionTable = { | "team" | "person" | "notification" - | "component"; + | "component" + | "role" + | "access_token_person"; created_by: string; updated_by: string; created_at: string; diff --git a/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx b/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx index 1291a3b746..5601943f27 100644 --- a/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx +++ b/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { ConfigDetails } from "./../ConfigDetails"; import { Provider as JotaiProvider } from "jotai"; @@ -46,8 +46,7 @@ const renderWithProviders = (component: React.ReactElement) => { const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: false, - gcTime: 0 + retry: false } } }); diff --git a/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx b/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx index e6c1d5a784..7ea6305418 100644 --- a/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx +++ b/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { Connection } from "../ConnectionFormModal"; import { ConnectionList } from "../ConnectionsList"; +import { ConnectionValueType } from "../connectionTypes"; // Mock the CRDSource component jest.mock("../../Settings/CRDSource", () => { @@ -71,16 +72,14 @@ describe("ConnectionsList", () => { const mockConnectionWithCRD: Connection = { id: "conn-123", name: "Test CRD Connection", - type: "postgres", - source: "KubernetesCRD", - created_by: { id: "user-1", name: "User One" } + type: ConnectionValueType.Postgres, + source: "KubernetesCRD" }; const mockConnectionWithUser: Connection = { id: "conn-456", name: "Test User Connection", - type: "mysql", - created_by: { id: "user-2", name: "User Two" } + type: ConnectionValueType.MySQL }; it("should display CRD component when source is KubernetesCRD", () => { diff --git a/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx b/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx index 901ef2ab72..557532ad4f 100644 --- a/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx +++ b/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx @@ -28,6 +28,7 @@ type FormikConfigsDropdownProps = { className?: string; valueField?: "id" | "name"; disabled?: boolean; + renderMenuInPortal?: boolean; }; export default function FormikResourceSelectorDropdown({ @@ -43,7 +44,8 @@ export default function FormikResourceSelectorDropdown({ playbookResourceSelector, className = "flex flex-col space-y-2 py-2", valueField = "id", - disabled = false + disabled = false, + renderMenuInPortal = true }: FormikConfigsDropdownProps) { const [inputText, setInputText] = useState(""); const [searchText, setSearchText] = useState(""); @@ -332,11 +334,15 @@ export default function FormikResourceSelectorDropdown({ }} onInputChange={handleInputChange} inputValue={inputText ?? value} - menuPortalTarget={document.body} + menuPortalTarget={ + renderMenuInPortal && typeof window !== "undefined" + ? document.body + : undefined + } styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }} - menuPosition={"fixed"} + menuPosition={renderMenuInPortal ? "fixed" : "absolute"} menuShouldBlockScroll={true} onBlur={(event) => { field.onBlur(event); diff --git a/src/components/MCP/McpTabsLinks.tsx b/src/components/MCP/McpTabsLinks.tsx new file mode 100644 index 0000000000..a91b833754 --- /dev/null +++ b/src/components/MCP/McpTabsLinks.tsx @@ -0,0 +1,125 @@ +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 { Loading } from "@flanksource-ui/ui/Loading"; +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" + | "Subject access" + | "Check Access"; + children: React.ReactNode; + className?: string; + onRefresh?: () => void; + loading?: boolean; + isInitialLoading?: boolean; + loadingText?: string; + headerAction?: React.ReactNode; +}; + +export default function McpTabsLinks({ + activeTab, + children, + className, + onRefresh = () => {}, + loading = false, + isInitialLoading = false, + loadingText = "Loading...", + headerAction +}: 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: "Subjects", + path: "/settings/mcp/subject-access", + key: "Subject access", + search + }, + { + label: "Playbooks", + path: "/settings/mcp/playbooks", + key: "Playbooks", + search + }, + { + label: "Views", + path: "/settings/mcp/views", + key: "Views", + search + }, + { + label: "Check Access", + path: "/settings/mcp/check-access", + key: "Check Access", + search + } + ]; + }, [searchParams]); + + return ( + <> + + + MCP + , + {activeTab}, + ...(headerAction ? [headerAction] : []) + ]} + /> + } + onRefresh={onRefresh} + loading={loading} + contentClass="p-0 h-full overflow-y-hidden" + > + + + + + {isInitialLoading ? ( + + ) : ( + children + )} + + + + + + + > + ); +} diff --git a/src/components/MCP/UserList.tsx b/src/components/MCP/UserList.tsx new file mode 100644 index 0000000000..50ff0dfa0f --- /dev/null +++ b/src/components/MCP/UserList.tsx @@ -0,0 +1,84 @@ +import { + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import SubjectAccessCard from "@flanksource-ui/components/Permissions/SubjectAccessCard"; + +const MCP_OBJECT = "mcp"; +const MCP_ACTION = "mcp:use"; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +type GroupedSubject = { + type: PermissionSubject["type"]; + subjects: PermissionSubject[]; +}; + +type Props = { + groupedSubjects: GroupedSubject[]; + permissionsByUser: Map; + mutatingSubjectId: string | null; + onChangeAccess: ( + subject: PermissionSubject, + access: "allow" | "deny" | "default" + ) => void; +}; + +export default function UserList({ + groupedSubjects, + permissionsByUser, + mutatingSubjectId, + onChangeAccess +}: Props) { + return ( + + {groupedSubjects.map((group) => ( + + + {TYPE_LABELS[group.type] ?? group.type} + + + + {group.subjects.map((subject) => { + const permissions = permissionsByUser.get(subject.id) ?? []; + + const activePermission = permissions.find( + (permission) => + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); + + const access = !activePermission + ? "default" + : activePermission.deny === true + ? "deny" + : "allow"; + + return ( + onChangeAccess(subject, access)} + /> + ); + })} + + + ))} + + ); +} diff --git a/src/components/Permissions/EffectiveAccessBadge.tsx b/src/components/Permissions/EffectiveAccessBadge.tsx new file mode 100644 index 0000000000..763127bcf1 --- /dev/null +++ b/src/components/Permissions/EffectiveAccessBadge.tsx @@ -0,0 +1,37 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@flanksource-ui/components/ui/tooltip"; +import { Check, X } from "lucide-react"; + +type EffectiveAccessBadgeProps = { + isAllowed: boolean; +}; + +export default function EffectiveAccessBadge({ + isAllowed +}: EffectiveAccessBadgeProps) { + return ( + + + + {isAllowed ? ( + + ) : ( + + )} + + + + Effective access: {isAllowed ? "Allowed" : "Denied"} + + + ); +} diff --git a/src/components/Permissions/PermissionAccessCheckModal.tsx b/src/components/Permissions/PermissionAccessCheckModal.tsx new file mode 100644 index 0000000000..14fe9eda4e --- /dev/null +++ b/src/components/Permissions/PermissionAccessCheckModal.tsx @@ -0,0 +1,598 @@ +import { fetchPermissionSubjects } from "@flanksource-ui/api/services/permissions"; +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + reviewSubjectAccess, + SubjectAccessReviewAction, + SubjectAccessReviewResource +} from "@flanksource-ui/api/services/rbac"; +import { getErrorMessage } from "@flanksource-ui/api/types/error"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { Badge } from "@flanksource-ui/components/ui/badge"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Form, Formik } from "formik"; +import { useEffect, useMemo, useState } from "react"; + +export type PermissionAccessCheckResourceType = "playbook" | "view"; + +export type PermissionAccessCheckResource = { + type: PermissionAccessCheckResourceType; + id: string; + name?: string; +}; + +export type PermissionAccessCheckConfig = { + actions: SubjectAccessReviewAction[]; + title?: string; + description?: string; + resource?: PermissionAccessCheckResource; + lockedResource?: PermissionAccessCheckResource; + allowedResourceTypes?: PermissionAccessCheckResourceType[]; + hideResourceForActions?: SubjectAccessReviewAction[]; + resourceOverrideByAction?: Partial< + Record + >; +}; + +type PermissionAccessCheckModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + config: PermissionAccessCheckConfig; +}; + +type PermissionAccessCheckFormValues = { + resourceType: PermissionAccessCheckResourceType; + resourceId: string; + subjectId: string; + action: SubjectAccessReviewAction | ""; +}; + +type PermissionAccessCheckFormProps = { + config: PermissionAccessCheckConfig; + isActive?: boolean; + onCancel?: () => void; +}; + +type PermissionAccessResourceSelectorProps = { + resourceType: PermissionAccessCheckResourceType; + resourceId: string; + setFieldValue: (field: string, value: unknown) => void; + clearResult: () => void; + lockedResource?: PermissionAccessCheckResource; + allowedResourceTypes: PermissionAccessCheckResourceType[]; + isActive: boolean; +}; + +function PermissionAccessResourceSelector({ + resourceType, + resourceId, + setFieldValue, + clearResult, + lockedResource, + allowedResourceTypes, + isActive +}: PermissionAccessResourceSelectorProps) { + const shouldLoadPlaybooks = allowedResourceTypes.includes("playbook"); + const shouldLoadViews = allowedResourceTypes.includes("view"); + + const { data: playbooks = [] } = useQuery({ + queryKey: ["permission-access-check", "playbooks"], + queryFn: getAllPlaybookNames, + enabled: isActive && shouldLoadPlaybooks + }); + + const { data: viewsResponse } = useQuery({ + queryKey: ["permission-access-check", "views"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000), + enabled: isActive && shouldLoadViews + }); + + const sortedPlaybooks = useMemo( + () => + [...playbooks].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ), + [playbooks] + ); + + const groupedPlaybooks = useMemo(() => { + const grouped = new Map(); + + for (const playbook of sortedPlaybooks) { + const category = playbook.category?.trim() || "Other"; + const list = grouped.get(category) ?? []; + list.push(playbook); + grouped.set(category, list); + } + + return Array.from(grouped.entries()).sort(([a], [b]) => { + if (a === "Other") { + return 1; + } + if (b === "Other") { + return -1; + } + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + }, [sortedPlaybooks]); + + const sortedViews = useMemo(() => { + const views = viewsResponse?.data ?? []; + + return [...views].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + }, [viewsResponse?.data]); + + useEffect(() => { + if (lockedResource) { + return; + } + + if (resourceType === "playbook") { + if (!resourceId && sortedPlaybooks.length > 0) { + setFieldValue("resourceId", sortedPlaybooks[0].id); + clearResult(); + } + return; + } + + if (!resourceId && sortedViews.length > 0) { + setFieldValue("resourceId", sortedViews[0].id); + clearResult(); + } + }, [ + clearResult, + lockedResource, + resourceId, + resourceType, + setFieldValue, + sortedPlaybooks, + sortedViews + ]); + + const selectedPlaybook = useMemo( + () => sortedPlaybooks.find((item) => item.id === resourceId), + [resourceId, sortedPlaybooks] + ); + + const selectedView = useMemo( + () => sortedViews.find((item) => item.id === resourceId), + [resourceId, sortedViews] + ); + + const selectedName = + resourceType === "playbook" + ? (selectedPlaybook?.name ?? lockedResource?.name) + : (selectedView?.name ?? lockedResource?.name); + const selectedNamespace = + resourceType === "playbook" + ? selectedPlaybook?.namespace + : selectedView?.namespace; + + return ( + + {allowedResourceTypes.length > 1 ? ( + + Resource Type + + options={allowedResourceTypes} + value={resourceType} + onChange={(value) => { + if (lockedResource) { + return; + } + + setFieldValue("resourceType", value); + setFieldValue("resourceId", ""); + clearResult(); + }} + className="w-full" + /> + + ) : null} + + + Resource + { + if (lockedResource) { + return; + } + + setFieldValue("resourceId", value); + clearResult(); + }} + disabled={!!lockedResource} + > + + {selectedName ? ( + + {selectedName} + {selectedNamespace ? ( + + {selectedNamespace} + + ) : null} + + ) : ( + + )} + + + {resourceType === "playbook" + ? groupedPlaybooks.map(([category, categoryPlaybooks]) => ( + + {category} + {categoryPlaybooks.map((playbook) => ( + + + {playbook.name} + {playbook.namespace ? ( + + {playbook.namespace} + + ) : null} + + + ))} + + )) + : sortedViews.map((view) => ( + + + {view.name} + {view.namespace ? ( + + {view.namespace} + + ) : null} + + + ))} + + + + + ); +} + +export function PermissionAccessCheckForm({ + config, + isActive = true, + onCancel +}: PermissionAccessCheckFormProps) { + const [requestError, setRequestError] = useState(); + const [result, setResult] = useState<{ + allowed: boolean; + error?: string; + }>(); + + const lockedResource = config.lockedResource ?? config.resource; + const allowedResourceTypes = useMemo(() => { + if (lockedResource) { + return [lockedResource.type] as PermissionAccessCheckResourceType[]; + } + + if (config.allowedResourceTypes && config.allowedResourceTypes.length > 0) { + return config.allowedResourceTypes; + } + + return ["playbook", "view"] as PermissionAccessCheckResourceType[]; + }, [config.allowedResourceTypes, lockedResource]); + + const initialResourceType = + lockedResource?.type ?? allowedResourceTypes[0] ?? "playbook"; + + const { data: subjects = [], isLoading: isLoadingSubjects } = useQuery({ + queryKey: ["permission-subjects", "access-check-form"], + queryFn: fetchPermissionSubjects, + enabled: isActive + }); + + const { mutateAsync: checkAccess, isLoading: isCheckingAccess } = useMutation( + { + mutationFn: reviewSubjectAccess + } + ); + + useEffect(() => { + if (!isActive) { + setResult(undefined); + setRequestError(undefined); + } + }, [isActive]); + + const initialValues: PermissionAccessCheckFormValues = useMemo( + () => ({ + resourceType: initialResourceType, + resourceId: lockedResource?.id ?? "", + subjectId: "", + action: config.actions[0] ?? "" + }), + [config.actions, initialResourceType, lockedResource?.id] + ); + + return ( + + initialValues={initialValues} + enableReinitialize + onSubmit={async (values, helpers) => { + if (!values.subjectId) { + helpers.setFieldError("subjectId", "Subject is required"); + return; + } + + if (!values.action) { + helpers.setFieldError("action", "Action is required"); + return; + } + + const resourceOverride = values.action + ? config.resourceOverrideByAction?.[values.action] + : undefined; + const shouldHideResourceSelector = values.action + ? !!config.hideResourceForActions?.includes(values.action) + : false; + + if ( + !resourceOverride && + !shouldHideResourceSelector && + !values.resourceId + ) { + helpers.setFieldError("resourceId", "Resource is required"); + return; + } + + setRequestError(undefined); + setResult(undefined); + + try { + const response = await checkAccess({ + resource: + resourceOverride ?? + (values.resourceType === "playbook" + ? { playbook: values.resourceId } + : { view: values.resourceId }), + action: values.action, + subjects: [values.subjectId] + }); + + const firstResult = response?.results?.[0]; + setResult( + firstResult + ? { allowed: firstResult.allowed, error: firstResult.error } + : undefined + ); + } catch (error) { + setRequestError(getErrorMessage(error)); + setResult(undefined); + } finally { + helpers.setSubmitting(false); + } + }} + > + {({ values, setFieldValue, errors, isSubmitting }) => { + const selectedSubject = subjects.find( + (subject) => subject.id === values.subjectId + ); + + const shouldHideResourceSelector = values.action + ? !!config.hideResourceForActions?.includes(values.action) + : false; + + const resourceOverride = values.action + ? config.resourceOverrideByAction?.[values.action] + : undefined; + + const overrideResourceLabel = resourceOverride?.global + ? resourceOverride.global.toUpperCase() + : (resourceOverride?.playbook ?? resourceOverride?.view); + + const isCheckDisabled = + !values.subjectId || + !values.action || + (!resourceOverride && + !shouldHideResourceSelector && + !values.resourceId) || + isCheckingAccess || + isSubmitting; + + const clearResult = () => { + setResult(undefined); + setRequestError(undefined); + }; + + return ( + + + Subject + { + setFieldValue("subjectId", value); + clearResult(); + }} + > + + {selectedSubject ? ( + + + {selectedSubject.name} + + ) : ( + + )} + + + {subjects.map((subject) => ( + + + + {subject.name} + + + ))} + + + {errors.subjectId ? ( + {errors.subjectId} + ) : null} + + + + Action + { + setFieldValue("action", value); + clearResult(); + }} + > + + + + + {config.actions.map((option) => ( + + {option} + + ))} + + + {errors.action ? ( + {errors.action} + ) : null} + + + {!shouldHideResourceSelector ? ( + <> + + {errors.resourceId ? ( + + {errors.resourceId} + + ) : null} + > + ) : ( + + Resource + + {overrideResourceLabel ?? "N/A"} + + + )} + + {requestError ? ( + + {requestError} + + ) : null} + + {result ? ( + + {selectedSubject?.name ?? "Subject"} is + {result.allowed ? " allowed " : " not allowed "} + to perform {values.action} + {result.error ? ` (${result.error})` : ""} + + ) : null} + + + {onCancel ? ( + + Cancel + + ) : null} + + {isCheckingAccess ? "Checking..." : "Check Access"} + + + + ); + }} + + ); +} + +export default function PermissionAccessCheckModal({ + open, + onOpenChange, + config +}: PermissionAccessCheckModalProps) { + return ( + + + + {config.title ?? "Permission Access Check"} + + {config.description ?? + "Select a subject and action to check access for this resource."} + + + + onOpenChange(false)} + /> + + + ); +} diff --git a/src/components/Permissions/PermissionSubjectPanel.tsx b/src/components/Permissions/PermissionSubjectPanel.tsx new file mode 100644 index 0000000000..fdf21580a6 --- /dev/null +++ b/src/components/Permissions/PermissionSubjectPanel.tsx @@ -0,0 +1,72 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Input } from "@flanksource-ui/components/ui/input"; + +export type PermissionSubjectGroup = { + type: PermissionSubject["type"]; + list: PermissionSubject[]; +}; + +type PermissionSubjectPanelProps = { + subjectSearch: string; + onSubjectSearchChange: (value: string) => void; + groupedSubjects: PermissionSubjectGroup[]; + selectedSubjectId: string | null; + onSelectSubject: (subjectId: string) => void; +}; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +export default function PermissionSubjectPanel({ + subjectSearch, + onSubjectSearchChange, + groupedSubjects, + selectedSubjectId, + onSelectSubject +}: PermissionSubjectPanelProps) { + return ( + + onSubjectSearchChange(event.target.value)} + /> + + + {groupedSubjects.map((group) => ( + + + {TYPE_LABELS[group.type] ?? group.type} + + + {group.list.map((subject) => { + const isActive = subject.id === selectedSubjectId; + + return ( + onSelectSubject(subject.id)} + className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${ + isActive + ? "bg-blue-50 text-blue-800" + : "text-gray-800 hover:bg-gray-50" + }`} + > + + {subject.name} + + ); + })} + + ))} + + + ); +} diff --git a/src/components/Permissions/PermissionsTabsLinks.tsx b/src/components/Permissions/PermissionsTabsLinks.tsx new file mode 100644 index 0000000000..d74f54c387 --- /dev/null +++ b/src/components/Permissions/PermissionsTabsLinks.tsx @@ -0,0 +1,91 @@ +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 PermissionsTabsLinksProps = { + activeTab: "Permissions" | "Subjects"; + children: React.ReactNode; + className?: string; + onRefresh?: () => void; + loading?: boolean; + headerAction?: React.ReactNode; +}; + +export default function PermissionsTabsLinks({ + activeTab, + children, + className, + onRefresh = () => {}, + loading = false, + headerAction +}: PermissionsTabsLinksProps) { + const [searchParams] = useSearchParams(); + + const tabLinks = useMemo(() => { + const query = searchParams.toString(); + const search = query ? `?${query}` : ""; + + return [ + { + label: "Permissions", + path: "/settings/permissions", + key: "Permissions", + search + }, + { + label: "Subjects", + path: "/settings/permissions/subjects", + key: "Subjects", + search + } + ]; + }, [searchParams]); + + return ( + <> + + + Permissions + , + {activeTab}, + ...(headerAction ? [headerAction] : []) + ]} + /> + } + onRefresh={onRefresh} + loading={loading} + contentClass="p-0 h-full overflow-y-hidden" + > + + + + {children} + + + + + + > + ); +} diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index d552e84a7f..616ea43c59 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -15,6 +15,9 @@ import { useEffect, useState } from "react"; import { Button } from ".."; import { FormikSelectDropdownOption } from "../Forms/Formik/FormikSelectDropdown"; import PermissionForm from "./ManagePermissions/Forms/PermissionForm"; +import PermissionAccessCheckModal, { + PermissionAccessCheckConfig +} from "./PermissionAccessCheckModal"; import PermissionsTable from "./PermissionsTable"; // Source: github.com/flanksource/duty/rbac/policy/policy.go @@ -82,6 +85,7 @@ type PermissionsViewProps = { newPermissionData?: Partial; showAddPermission?: boolean; onRefetch?: (refetch: () => void) => void; + accessCheckConfig?: PermissionAccessCheckConfig; }; export default function PermissionsView({ @@ -91,13 +95,15 @@ export default function PermissionsView({ hideSubjectColumn = false, newPermissionData, showAddPermission = false, - onRefetch + onRefetch, + accessCheckConfig }: PermissionsViewProps) { const [selectedPermission, setSelectedPermission] = useState(); const { pageSize, pageIndex } = useReactTablePaginationState(); const [sortState] = useReactTableSortState(); const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false); + const [isAccessCheckModalOpen, setIsAccessCheckModalOpen] = useState(false); const mappedSortBy = sortState[0]?.id === "created" @@ -153,13 +159,25 @@ export default function PermissionsView({ {showAddPermission && ( - { - setIsPermissionModalOpen(true); - }} - > - Add Permission - + + { + setIsPermissionModalOpen(true); + }} + > + Add Permission + + {accessCheckConfig ? ( + { + setIsAccessCheckModalOpen(true); + }} + > + Check Access + + ) : null} + )} @@ -193,6 +211,13 @@ export default function PermissionsView({ }} /> )} + {accessCheckConfig ? ( + + ) : null} ); } diff --git a/src/components/Permissions/ResourceAccessCard.tsx b/src/components/Permissions/ResourceAccessCard.tsx new file mode 100644 index 0000000000..c0c8495ee9 --- /dev/null +++ b/src/components/Permissions/ResourceAccessCard.tsx @@ -0,0 +1,146 @@ +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; + +type GlobalOverride = "allow" | "none" | "deny"; + +type Entity = { + id: string; + name: string; + namespace?: string; + icon?: string; +}; + +type PermissionAccessCardProps = { + entity: Entity; + globalOverride?: GlobalOverride; + onGlobalOverrideChange?: (value: GlobalOverride) => void; + onViewSubjects?: () => void; + isMutating?: boolean; + isSelected?: boolean; + showGlobalSwitch?: boolean; +}; + +const SWITCH_OPTIONS = ["Deny all", "Custom", "Allow all"]; +type SwitchOption = "Deny all" | "Custom" | "Allow all"; + +function toSwitchOption(value: GlobalOverride): SwitchOption { + switch (value) { + case "deny": + return "Deny all"; + case "allow": + return "Allow all"; + default: + return "Custom"; + } +} + +function toGlobalOverride(value: string): GlobalOverride { + switch (value) { + case "Deny all": + return "deny"; + case "Allow all": + return "allow"; + default: + return "none"; + } +} + +export default function ResourceAccessCard({ + entity, + globalOverride = "none", + onGlobalOverrideChange, + onViewSubjects, + isMutating = false, + isSelected = false, + showGlobalSwitch = true +}: PermissionAccessCardProps) { + const { icon, name, namespace } = entity; + const canOpenSubjects = + (showGlobalSwitch ? globalOverride === "none" : true) && + Boolean(onViewSubjects); + const isGlobalSwitchDisabled = isMutating || !onGlobalOverrideChange; + + const handleCardClick = () => { + if (!canOpenSubjects) { + return; + } + + onViewSubjects?.(); + }; + + const handleGlobalOverrideSwitchChange = (value: string) => { + if (!onGlobalOverrideChange) { + return; + } + + onGlobalOverrideChange(toGlobalOverride(value)); + }; + + return ( + + + + + + + + + + {name} + + {namespace && ( + + {namespace} + + )} + + + {showGlobalSwitch ? ( + event.stopPropagation()} + > + + { + if (option === "Allow all") { + return "bg-blue-50 text-blue-700 ring-blue-200"; + } + + if (option === "Deny all") { + return "bg-red-50 text-red-700 ring-red-200"; + } + + return undefined; + }} + /> + + + ) : null} + + + + ); +} diff --git a/src/components/Permissions/ResourceList.tsx b/src/components/Permissions/ResourceList.tsx new file mode 100644 index 0000000000..f28947b858 --- /dev/null +++ b/src/components/Permissions/ResourceList.tsx @@ -0,0 +1,217 @@ +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { Switch as SegmentedSwitch } from "@flanksource-ui/ui/FormControls/Switch"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import type { + McpSubjectResource, + ResourceAccess +} from "./ResourceSelectorPanel"; +import ResourceRow from "./ResourceRow"; + +const BULK_OPTIONS = ["Deny All", "Custom", "Allow All"] as const; +type BulkOption = (typeof BULK_OPTIONS)[number]; + +const RESOURCE_SORT_OPTIONS = ["deny", "allow", "alphabetical"] as const; +type ResourceSortOption = (typeof RESOURCE_SORT_OPTIONS)[number]; +type ResourceSortDirection = "asc" | "desc"; + +const RESOURCE_SORT_OPTION_LABELS: Record = { + deny: "Deny", + allow: "Allow", + alphabetical: "Alphabetical" +}; + +function getSortRank(access: ResourceAccess, sortOption: ResourceSortOption) { + switch (sortOption) { + case "deny": + return access === "deny" ? 0 : access === "allow" ? 1 : 2; + case "allow": + return access === "allow" ? 0 : access === "deny" ? 1 : 2; + case "alphabetical": + default: + return 0; + } +} + +type ResourceListProps = { + title: string; + emptyMessage: string; + defaultIcon: string; + resources: McpSubjectResource[]; + bulkAccess: ResourceAccess; + onBulkAccessChange: (access: ResourceAccess) => void; + accessByResourceKey: Record; + effectiveAccessByResourceKey: Record; + hasEffectiveAccessResults: boolean; + getResourceKey: (resource: McpSubjectResource) => string; + isListLocked: boolean; + isSubmitting: boolean; + mutatingResourceIds: Record; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; +}; + +function ResourceList({ + title, + emptyMessage, + defaultIcon, + resources, + bulkAccess, + onBulkAccessChange, + accessByResourceKey, + effectiveAccessByResourceKey, + hasEffectiveAccessResults, + getResourceKey, + isListLocked, + isSubmitting, + mutatingResourceIds, + onSetResourceAccess +}: ResourceListProps) { + const [sort, setSort] = useState("alphabetical"); + const [sortDirection, setSortDirection] = + useState("asc"); + + const sortedResources = useMemo(() => { + return [...resources].sort((a, b) => { + const aAccess = accessByResourceKey[getResourceKey(a)] ?? "default"; + const bAccess = accessByResourceKey[getResourceKey(b)] ?? "default"; + + const rankDiff = getSortRank(aAccess, sort) - getSortRank(bAccess, sort); + + if (rankDiff !== 0) { + return sortDirection === "asc" ? rankDiff : -rankDiff; + } + + const nameDiff = (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { sensitivity: "base" } + ); + + return sortDirection === "asc" ? nameDiff : -nameDiff; + }); + }, [accessByResourceKey, getResourceKey, resources, sort, sortDirection]); + + const bulkOptionValue: BulkOption = + bulkAccess === "allow" + ? "Allow All" + : bulkAccess === "deny" + ? "Deny All" + : "Custom"; + + return ( + + + + {title} + + + + { + const access: ResourceAccess = + value === "Allow All" + ? "allow" + : value === "Deny All" + ? "deny" + : "default"; + onBulkAccessChange(access); + }} + getActiveItemClassName={(option) => + option === "Allow All" + ? "!bg-green-600 !text-white !ring-green-600" + : option === "Deny All" + ? "!bg-red-600 !text-white !ring-red-600" + : undefined + } + /> + + + setSort(value as ResourceSortOption)} + > + + + + + {RESOURCE_SORT_OPTIONS.map((option) => ( + + {RESOURCE_SORT_OPTION_LABELS[option]} + + ))} + + + + + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")) + } + > + {sortDirection === "asc" ? ( + + ) : ( + + )} + + + + + + {sortedResources.length === 0 ? ( + {emptyMessage} + ) : ( + sortedResources.map((resource) => { + const key = getResourceKey(resource); + + return ( + + ); + }) + )} + + + ); +} + +export default memo(ResourceList); diff --git a/src/components/Permissions/ResourceRow.tsx b/src/components/Permissions/ResourceRow.tsx new file mode 100644 index 0000000000..7faa0b0837 --- /dev/null +++ b/src/components/Permissions/ResourceRow.tsx @@ -0,0 +1,84 @@ +import EffectiveAccessBadge from "@flanksource-ui/components/Permissions/EffectiveAccessBadge"; +import TriStateAccessSwitch from "@flanksource-ui/components/Permissions/TriStateAccessSwitch"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { motion } from "motion/react"; +import { memo } from "react"; +import type { + McpSubjectResource, + ResourceAccess +} from "./ResourceSelectorPanel"; + +const ROW_LAYOUT_TRANSITION = { + type: "spring", + stiffness: 620, + damping: 42, + mass: 0.7 +} as const; + +type ResourceRowProps = { + resource: McpSubjectResource; + access: ResourceAccess; + defaultIcon: string; + showEffectiveBadge: boolean; + isAllowed: boolean; + isListLocked: boolean; + isSubmitting: boolean; + isMutating: boolean; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; +}; + +function ResourceRow({ + resource, + access, + defaultIcon, + showEffectiveBadge, + isAllowed, + isListLocked, + isSubmitting, + isMutating, + onSetResourceAccess +}: ResourceRowProps) { + return ( + + + + + + {resource.displayName || resource.name} + + {resource.subtitle ? ( + + {resource.subtitle} + + ) : null} + + + + + {showEffectiveBadge ? ( + + ) : null} + {!isListLocked ? ( + onSetResourceAccess(resource, nextAccess)} + /> + ) : null} + + + ); +} + +export default memo(ResourceRow); diff --git a/src/components/Permissions/ResourceSelectorPanel.tsx b/src/components/Permissions/ResourceSelectorPanel.tsx new file mode 100644 index 0000000000..958a4635c5 --- /dev/null +++ b/src/components/Permissions/ResourceSelectorPanel.tsx @@ -0,0 +1,291 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import ResourceList from "@flanksource-ui/components/Permissions/ResourceList"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; +import { useCallback, useMemo, useState } from "react"; + +export type ResourceAccess = "deny" | "default" | "allow"; + +export type McpSubjectResource = { + id: string; + kind: "playbook" | "view"; + /** Canonical selector name used by object_selector (e.g. playbook.name / view.name). */ + name: string; + /** Optional UI label (e.g. title). Falls back to canonical `name`. */ + displayName?: string; + namespace?: string; + icon?: string; + subtitle?: string; +}; +type ResourceSelectorPanelProps = { + selectedSubject: PermissionSubject; + resources: McpSubjectResource[]; + permissions: PermissionsSummary[]; + effectiveAccessByResourceKey?: Record; + hasEffectiveAccessResults?: boolean; + isCheckingEffectiveAccess?: boolean; + isSubmitting?: boolean; + mutatingResourceIds?: Record; + onCheckEffectiveAccess?: () => void; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; + onSetManyResourceAccess: ( + resources: McpSubjectResource[], + access: ResourceAccess + ) => Promise | void; +}; + +function getRefsForPermission( + permission: PermissionsSummary, + kind: "playbook" | "view" +) { + return kind === "playbook" + ? (permission.object_selector?.playbooks ?? []) + : (permission.object_selector?.views ?? []); +} + +function permissionMatchesResource( + permission: PermissionsSummary, + resource: McpSubjectResource +) { + const refs = getRefsForPermission(permission, resource.kind); + + return refs.some((ref) => { + if (!ref?.name || ref.name === "*") { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +function getAccessState( + permissions: PermissionsSummary[], + subject: PermissionSubject, + resource: McpSubjectResource +): { + access: ResourceAccess; +} { + const subjectType = mapSubjectType(subject.type); + + const direct = permissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.source === "mcp_settings" && + permission.subject === subject.id && + permission.subject_type === subjectType && + permissionMatchesResource(permission, resource) + ); + + if (direct.length > 0) { + return { + access: direct.some((permission) => permission.deny === true) + ? "deny" + : "allow" + }; + } + + return { access: "default" }; +} + +function getResourceKey(resource: McpSubjectResource) { + return `${resource.kind}:${resource.id}`; +} + +export default function ResourceSelectorPanel({ + selectedSubject, + resources, + permissions, + effectiveAccessByResourceKey = {}, + hasEffectiveAccessResults = false, + isCheckingEffectiveAccess = false, + isSubmitting = false, + mutatingResourceIds = {}, + onCheckEffectiveAccess, + onSetResourceAccess, + onSetManyResourceAccess +}: ResourceSelectorPanelProps) { + const [resourceSearch, setResourceSearch] = useState(""); + + const normalizedSearch = resourceSearch.trim().toLowerCase(); + + const filteredResources = useMemo(() => { + return resources.filter((resource) => { + if (!normalizedSearch) { + return true; + } + + const haystack = + `${resource.displayName || resource.name} ${resource.name} ${resource.subtitle || ""} ${resource.namespace || ""}`.toLowerCase(); + return haystack.includes(normalizedSearch); + }); + }, [normalizedSearch, resources]); + + const accessByResourceKey = useMemo(() => { + const byKey: Record = {}; + + for (const resource of filteredResources) { + byKey[getResourceKey(resource)] = getAccessState( + permissions, + selectedSubject, + resource + ).access; + } + + return byKey; + }, [filteredResources, permissions, selectedSubject]); + + const resourcesByType = useMemo(() => { + const playbooks: McpSubjectResource[] = []; + const views: McpSubjectResource[] = []; + + for (const resource of filteredResources) { + if (resource.kind === "playbook") { + playbooks.push(resource); + } else { + views.push(resource); + } + } + + return { playbooks, views }; + }, [filteredResources]); + + const bulkAccessByKind = useMemo(() => { + const subjectType = mapSubjectType(selectedSubject.type); + + const getWildcardAccessByKind = ( + kind: "playbook" | "view" + ): ResourceAccess => { + const wildcardPermissions = permissions.filter((permission) => { + if ( + permission.action !== "mcp:run" || + permission.source !== "mcp_settings" || + permission.subject !== selectedSubject.id || + permission.subject_type !== subjectType + ) { + return false; + } + + return getRefsForPermission(permission, kind).some( + (ref) => ref?.name === "*" && !ref?.namespace + ); + }); + + if (wildcardPermissions.length === 0) { + return "default"; + } + + return wildcardPermissions.some((permission) => permission.deny === true) + ? "deny" + : "allow"; + }; + + return { + playbook: getWildcardAccessByKind("playbook"), + view: getWildcardAccessByKind("view") + }; + }, [permissions, selectedSubject]); + + const onSetPlaybookBulkAccess = useCallback( + (access: ResourceAccess) => { + onSetManyResourceAccess(resourcesByType.playbooks, access); + }, + [onSetManyResourceAccess, resourcesByType.playbooks] + ); + + const onSetViewBulkAccess = useCallback( + (access: ResourceAccess) => { + onSetManyResourceAccess(resourcesByType.views, access); + }, + [onSetManyResourceAccess, resourcesByType.views] + ); + + return ( + + + + + + + + {selectedSubject.name} + + + + + {onCheckEffectiveAccess ? ( + + {isCheckingEffectiveAccess + ? "Checking effective access..." + : "Check effective access"} + + ) : null} + + + + + + + setResourceSearch(event.target.value)} + /> + + + + + + + + + + + ); +} diff --git a/src/components/Permissions/SubjectAccessCard.tsx b/src/components/Permissions/SubjectAccessCard.tsx new file mode 100644 index 0000000000..6cb83b53a5 --- /dev/null +++ b/src/components/Permissions/SubjectAccessCard.tsx @@ -0,0 +1,95 @@ +import SubjectAvatar, { + PermissionSubjectType +} from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; + +type AccessLevel = "deny" | "default" | "allow"; +type SwitchOption = "Deny" | "Default" | "Allow"; + +const ACCESS_TO_OPTION: Record = { + deny: "Deny", + default: "Default", + allow: "Allow" +}; + +const OPTION_TO_ACCESS: Record = { + Deny: "deny", + Default: "default", + Allow: "allow" +}; + +const SWITCH_OPTIONS: SwitchOption[] = ["Deny", "Default", "Allow"]; + +type SubjectAccessCardProps = { + user: { + id: string; + name?: string; + email?: string; + avatar?: string; + type?: PermissionSubjectType; + }; + action: string; + object: string; + access: AccessLevel; + onChangeAccess: (access: AccessLevel) => void; + isMutating?: boolean; +}; + +export default function SubjectAccessCard({ + user, + action, + object, + access, + onChangeAccess, + isMutating = false +}: SubjectAccessCardProps) { + return ( + + + + + + + + {user.name} + + {user.email && ( + + {user.email} + + )} + + + + onChangeAccess(OPTION_TO_ACCESS[option])} + 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" + : undefined + } + /> + + + + + ); +} diff --git a/src/components/Permissions/SubjectAvatar.tsx b/src/components/Permissions/SubjectAvatar.tsx new file mode 100644 index 0000000000..4748aeaa8f --- /dev/null +++ b/src/components/Permissions/SubjectAvatar.tsx @@ -0,0 +1,91 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; +import clsx from "clsx"; +import { IconType } from "react-icons"; +import { HiBadgeCheck, HiKey, HiUserGroup, HiUsers } from "react-icons/hi"; + +export type PermissionSubjectType = PermissionSubject["type"]; + +type SubjectAvatarSize = "xs" | "md"; + +type SubjectAvatarProps = { + subject: Pick; + size?: SubjectAvatarSize; + className?: string; +}; + +const SUBJECT_TYPE_ICON_CONFIG: Record< + Exclude, + { + Icon: IconType; + colors: string; + } +> = { + team: { + Icon: HiUserGroup, + colors: "bg-blue-100 text-blue-700" + }, + permission_subject_group: { + Icon: HiUsers, + colors: "bg-violet-100 text-violet-700" + }, + role: { + Icon: HiBadgeCheck, + colors: "bg-indigo-50 text-indigo-700" + }, + access_token_person: { + Icon: HiKey, + colors: "bg-indigo-50 text-indigo-700" + } +}; + +const SIZE_CLASSNAMES: Record< + SubjectAvatarSize, + { wrapper: string; icon: string } +> = { + xs: { + wrapper: "h-5 w-5 rounded-full", + icon: "h-3 w-3" + }, + md: { + wrapper: "h-8 w-8 rounded-md", + icon: "h-4 w-4" + } +}; + +export default function SubjectAvatar({ + subject, + size = "xs", + className +}: SubjectAvatarProps) { + if (subject.type === "person") { + return ( + span]:!text-[10px]" : "[&>span]:!text-[10px]", + className + ) + }} + /> + ); + } + + const { Icon, colors } = SUBJECT_TYPE_ICON_CONFIG[subject.type]; + const sizeClassName = SIZE_CLASSNAMES[size]; + + return ( + + + + ); +} diff --git a/src/components/Permissions/SubjectSelectorPanel.tsx b/src/components/Permissions/SubjectSelectorPanel.tsx new file mode 100644 index 0000000000..5feb281a5e --- /dev/null +++ b/src/components/Permissions/SubjectSelectorPanel.tsx @@ -0,0 +1,642 @@ +import { + fetchAllPermissionSubjects, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { + fetchEffectiveResourceSubjectAccess, + SubjectAccessReviewAction +} from "@flanksource-ui/api/services/rbac"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import TriStateAccessSwitch from "@flanksource-ui/components/Permissions/TriStateAccessSwitch"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@flanksource-ui/components/ui/tooltip"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, Check, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +export type SubjectAccess = "deny" | "default" | "allow"; + +const BULK_OPTIONS = ["Deny All", "Custom", "Allow all"] as const; +type BulkOption = (typeof BULK_OPTIONS)[number]; + +const SUBJECT_SORT_OPTIONS = [ + "deny", + "allow", + "custom", + "alphabetical" +] as const; +type SubjectSortOption = (typeof SUBJECT_SORT_OPTIONS)[number]; +type SubjectSortDirection = "asc" | "desc"; + +const SUBJECT_SORT_OPTION_LABELS: Record = { + deny: "Deny", + allow: "Allow", + custom: "Custom", + alphabetical: "Alphabetical" +}; + +const ROW_LAYOUT_TRANSITION = { + type: "spring", + stiffness: 620, + damping: 42, + mass: 0.7 +} as const; + +type SubjectSelectorPanelProps = { + title?: string; + description?: string; + headerEntity?: { + name: string; + icon?: string; + }; + effectiveAccessResource?: { + id: string; + type: "playbook" | "view"; + action?: SubjectAccessReviewAction; + }; + preselectedSubjectAccess?: Record; + isSubmitting?: boolean; + isBulkSubmitting?: boolean; + mutatingSubjectId?: string | null; + bulkAccess?: SubjectAccess; + onSetBulkAccess?: (access: SubjectAccess) => Promise | void; + onSetSubjectAccess: ( + subject: PermissionSubject, + access: SubjectAccess + ) => Promise | void; + onSetManySubjectAccess?: ( + selections: Array<{ subject: PermissionSubject; access: "allow" | "deny" }> + ) => Promise | void; +}; + +function getSubjectSortRank( + access: SubjectAccess, + sortOption: SubjectSortOption +) { + switch (sortOption) { + case "deny": + return access === "deny" ? 0 : access === "allow" ? 1 : 2; + case "allow": + return access === "allow" ? 0 : access === "deny" ? 1 : 2; + case "custom": + return access === "default" ? 1 : 0; + case "alphabetical": + default: + return 0; + } +} + +export default function SubjectSelectorPanel({ + title, + description, + effectiveAccessResource, + preselectedSubjectAccess = {}, + isSubmitting = false, + isBulkSubmitting = false, + mutatingSubjectId, + headerEntity, + bulkAccess, + onSetBulkAccess, + onSetSubjectAccess, + onSetManySubjectAccess +}: SubjectSelectorPanelProps) { + const [search, setSearch] = useState(""); + const [subjectSort, setSubjectSort] = + useState("alphabetical"); + const [subjectSortDirection, setSubjectSortDirection] = + useState("asc"); + const [selectedAccessById, setSelectedAccessById] = useState< + Record + >({}); + const [ + hasTriggeredEffectiveAccessCheck, + setHasTriggeredEffectiveAccessCheck + ] = useState(false); + + const debouncedSearch = + useDebouncedValue(search, 250)?.trim().toLowerCase() ?? ""; + + const { + data: subjects = [], + isLoading, + isFetching + } = useQuery({ + queryKey: ["mcp", "subject-selector", "all-subjects"], + queryFn: fetchAllPermissionSubjects, + staleTime: 60_000 + }); + + const { + data: effectiveSubjectAccessResponse, + isFetching: isCheckingEffectiveAccess, + refetch: refetchEffectiveSubjectAccess + } = useQuery({ + queryKey: [ + "mcp", + "subject-selector", + "effective-access", + effectiveAccessResource?.type ?? "none", + effectiveAccessResource?.id ?? "none", + subjects.length + ], + enabled: false, + queryFn: async () => { + if (!effectiveAccessResource) { + return { + resource: { id: "", type: "playbook" as const }, + action: "mcp:run" as const, + results: [] as Array<{ subjectId: string; allowed: boolean }> + }; + } + + return fetchEffectiveResourceSubjectAccess({ + resource: { + id: effectiveAccessResource.id, + type: effectiveAccessResource.type + }, + action: effectiveAccessResource.action ?? "mcp:run", + subjects: subjects.map((subject) => subject.id) + }); + } + }); + + const normalizedPreselectedAccess = useMemo( + () => + Object.fromEntries( + Object.entries(preselectedSubjectAccess) + .filter(([, access]) => access === "allow" || access === "deny") + .sort(([a], [b]) => a.localeCompare(b)) + ) as Record, + [preselectedSubjectAccess] + ); + + const preselectedAccessSignature = useMemo( + () => JSON.stringify(normalizedPreselectedAccess), + [normalizedPreselectedAccess] + ); + + useEffect(() => { + const parsed = preselectedAccessSignature + ? (JSON.parse(preselectedAccessSignature) as Record< + string, + "allow" | "deny" + >) + : {}; + + const next: Record = {}; + for (const [id, access] of Object.entries(parsed)) { + next[id] = access; + } + + setSelectedAccessById(next); + }, [preselectedAccessSignature]); + + useEffect(() => { + setHasTriggeredEffectiveAccessCheck(false); + }, [effectiveAccessResource?.type, effectiveAccessResource?.id]); + + const effectiveAccessBySubjectId = useMemo(() => { + const map: Record = {}; + + for (const result of effectiveSubjectAccessResponse?.results ?? []) { + map[result.subjectId] = result.allowed; + } + + return map; + }, [effectiveSubjectAccessResponse?.results]); + + const sortedSubjects = useMemo(() => { + const query = debouncedSearch; + + return subjects + .filter((subject) => { + if (!query) { + return true; + } + return subject.name.toLowerCase().includes(query); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + const aAccess = selectedAccessById[a.id] ?? "default"; + const bAccess = selectedAccessById[b.id] ?? "default"; + const rankDiff = + getSubjectSortRank(aAccess, subjectSort) - + getSubjectSortRank(bAccess, subjectSort); + + if (rankDiff !== 0) { + return subjectSortDirection === "asc" ? rankDiff : -rankDiff; + } + + const nameDiff = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + + return subjectSortDirection === "asc" ? nameDiff : -nameDiff; + }); + }, [ + debouncedSearch, + selectedAccessById, + subjectSort, + subjectSortDirection, + subjects + ]); + + const displayedSubjects = sortedSubjects; + + const groupedDisplayedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of displayedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()) + .sort((a, b) => SUBJECT_TYPE_ORDER[a[0]] - SUBJECT_TYPE_ORDER[b[0]]) + .map(([type, groupSubjects]) => ({ + type, + subjects: groupSubjects + })); + }, [displayedSubjects]); + + const bulkAccessFromSelection = useMemo(() => { + if (displayedSubjects.length === 0) { + return "default"; + } + + const accessValues = displayedSubjects.map( + (subject) => selectedAccessById[subject.id] ?? "default" + ); + + if (accessValues.every((value) => value === "allow")) { + return "allow"; + } + + if (accessValues.every((value) => value === "deny")) { + return "deny"; + } + + if (accessValues.every((value) => value === "default")) { + return "default"; + } + + return "default"; + }, [displayedSubjects, selectedAccessById]); + + const resolvedBulkAccess = bulkAccess ?? bulkAccessFromSelection; + + const bulkOptionValue: BulkOption = + resolvedBulkAccess === "allow" + ? "Allow all" + : resolvedBulkAccess === "deny" + ? "Deny All" + : "Custom"; + + const setBulkSubjectAccess = async (access: SubjectAccess) => { + if (onSetBulkAccess) { + await onSetBulkAccess(access); + return; + } + + if (displayedSubjects.length === 0) { + return; + } + + const nextSelections = { ...selectedAccessById }; + + for (const subject of displayedSubjects) { + if (access === "default") { + delete nextSelections[subject.id]; + } else { + nextSelections[subject.id] = access; + } + } + + setSelectedAccessById(nextSelections); + + if (onSetManySubjectAccess) { + const subjectsById = new Map( + subjects.map((subject) => [subject.id, subject] as const) + ); + + const selections = Object.entries(nextSelections) + .filter(([, value]) => value === "allow" || value === "deny") + .map(([subjectId, value]) => { + const subject = subjectsById.get(subjectId); + if (!subject) { + return null; + } + + return { + subject, + access: value as "allow" | "deny" + }; + }) + .filter( + ( + entry + ): entry is { + subject: PermissionSubject; + access: "allow" | "deny"; + } => !!entry + ); + + await onSetManySubjectAccess(selections); + return; + } + + await Promise.all( + displayedSubjects.map((subject) => onSetSubjectAccess(subject, access)) + ); + }; + + const setSubjectAccess = ( + subject: PermissionSubject, + access: SubjectAccess + ) => { + setSelectedAccessById((prev) => { + const next = { ...prev }; + + if (access === "default") { + delete next[subject.id]; + } else { + next[subject.id] = access; + } + + return next; + }); + + void onSetSubjectAccess(subject, access); + }; + + const isListLocked = + Boolean(onSetBulkAccess) && + (resolvedBulkAccess === "allow" || resolvedBulkAccess === "deny"); + + const renderEffectiveAccessIcon = (subject: PermissionSubject) => { + if (!hasTriggeredEffectiveAccessCheck) { + return null; + } + + const allowed = effectiveAccessBySubjectId[subject.id] === true; + + return ( + + + + {allowed ? ( + + ) : ( + + )} + + + + Effective access: {allowed ? "Allowed" : "Denied"} + + + ); + }; + + return ( + + + + + {headerEntity ? ( + + + + ) : null} + + + {headerEntity?.name || title} + + {!headerEntity && description ? ( + + {description} + + ) : null} + + + + {effectiveAccessResource ? ( + { + setHasTriggeredEffectiveAccessCheck(true); + refetchEffectiveSubjectAccess(); + }} + > + {isCheckingEffectiveAccess + ? "Checking effective access..." + : "Check effective access"} + + ) : null} + + + + Global permission + + + { + const access: SubjectAccess = + value === "Allow all" + ? "allow" + : value === "Deny All" + ? "deny" + : "default"; + void setBulkSubjectAccess(access); + }} + getActiveItemClassName={(option) => + option === "Allow all" + ? "!bg-green-600 !text-white !ring-green-600" + : option === "Deny All" + ? "!bg-red-600 !text-white !ring-red-600" + : undefined + } + /> + + + + + + + + setSearch(event.target.value)} + /> + + + setSubjectSort(value as SubjectSortOption) + } + > + + + + + {SUBJECT_SORT_OPTIONS.map((option) => ( + + {SUBJECT_SORT_OPTION_LABELS[option]} + + ))} + + + + + setSubjectSortDirection((prev) => + prev === "asc" ? "desc" : "asc" + ) + } + > + {subjectSortDirection === "asc" ? ( + + ) : ( + + )} + + + + + {isLoading || isFetching ? ( + + + Loading subjects... + + ) : displayedSubjects.length > 0 ? ( + groupedDisplayedSubjects.map((group) => ( + + + {TYPE_LABELS[group.type] ?? group.type} + + + + {group.subjects.map((subject) => ( + + + + + {subject.name} + + + + + {renderEffectiveAccessIcon(subject)} + {!isListLocked ? ( + + setSubjectAccess(subject, next) + } + /> + ) : null} + + + ))} + + + )) + ) : ( + No subjects found + )} + + + + + ); +} diff --git a/src/components/Permissions/TriStateAccessSwitch.tsx b/src/components/Permissions/TriStateAccessSwitch.tsx new file mode 100644 index 0000000000..52a00012ce --- /dev/null +++ b/src/components/Permissions/TriStateAccessSwitch.tsx @@ -0,0 +1,78 @@ +type ResourceAccess = "deny" | "default" | "allow"; + +type TriStateAccessSwitchProps = { + value: ResourceAccess; + onChange: (value: ResourceAccess) => void; + disabled?: boolean; +}; + +const ACCESS_LABEL: Record = { + deny: "Deny", + default: "Default", + allow: "Allow" +}; + +const ACCESS_LABEL_CLASSNAME: Record = { + deny: "text-red-600", + default: "text-gray-500", + allow: "text-green-600" +}; + +const TRACK_CLASSNAME: Record = { + deny: "bg-red-500", + default: "bg-gray-400", + allow: "bg-green-600" +}; + +const POSITION_BY_ACCESS: Record = { + deny: 0, + default: 1, + allow: 2 +}; + +const ACCESS_ORDER: ResourceAccess[] = ["deny", "default", "allow"]; + +export default function TriStateAccessSwitch({ + value, + onChange, + disabled = false +}: TriStateAccessSwitchProps) { + const position = POSITION_BY_ACCESS[value] * 14; + + return ( + + + + + {ACCESS_ORDER.map((option) => ( + { + event.preventDefault(); + event.stopPropagation(); + if (!disabled) { + onChange(option); + } + }} + disabled={disabled} + aria-pressed={value === option} + aria-label={`Set access to ${ACCESS_LABEL[option]}`} + /> + ))} + + + + {ACCESS_LABEL[value]} + + + ); +} diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx index 14af3d5d24..fee2589995 100644 --- a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -1,3 +1,4 @@ +import { SubjectAccessReviewAction } from "@flanksource-ui/api/services/rbac"; import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; import { Modal } from "@flanksource-ui/ui/Modal"; @@ -12,6 +13,13 @@ type PlaybookPermissionsModalProps = { onClose: () => void; }; +const playbookAccessReviewActions: SubjectAccessReviewAction[] = [ + "read", + "playbook:run", + "playbook:cancel", + "playbook:approve" +]; + export default function PlaybookPermissionsModal({ playbook, isOpen, @@ -99,6 +107,17 @@ export default function PlaybookPermissionsModal({ hideResourceColumn permissionRequest={inboundPermissionRequest} showAddPermission + accessCheckConfig={{ + resource: { + type: "playbook", + id: playbook.id, + name: `${playbook.namespace}/${playbook.name}` + }, + actions: playbookAccessReviewActions, + title: "Playbook Access Check", + description: + "Select a subject and action to check access for this playbook." + }} newPermissionData={{ playbook_id: playbook.id }} diff --git a/src/components/Tokens/Add/CreateTokenForm.tsx b/src/components/Tokens/Add/CreateTokenForm.tsx index 60cc4c92d5..99cf9ec875 100644 --- a/src/components/Tokens/Add/CreateTokenForm.tsx +++ b/src/components/Tokens/Add/CreateTokenForm.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { Form, Formik } from "formik"; +import { useState } from "react"; import { FaSpinner } from "react-icons/fa"; import { createToken, @@ -12,7 +13,7 @@ import { Button } from "../../../ui/Buttons/Button"; import { Modal } from "../../../ui/Modal"; import FormikTextInput from "../../Forms/Formik/FormikTextInput"; import FormikSelectDropdown from "../../Forms/Formik/FormikSelectDropdown"; -import { toastError, toastSuccess } from "../../Toast/toast"; +import { toastSuccess } from "../../Toast/toast"; import { OBJECTS, getActionsForObject, @@ -20,6 +21,7 @@ import { } from "../tokenUtils"; import TokenScopeFieldsGroup from "./TokenScopeFieldsGroup"; import FormikCheckbox from "@flanksource-ui/components/Forms/Formik/FormikCheckbox"; +import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; export type TokenFormValues = CreateTokenRequest & { objectActions: Record; @@ -59,13 +61,16 @@ export function CreateTokenFormContent({ showFooter = true, formId }: CreateTokenFormContentProps) { + const [submitError, setSubmitError] = useState(null); + const { mutate: createTokenMutation, isLoading } = useMutation({ mutationFn: createToken, - onSuccess: (data) => { + onSuccess: () => { + setSubmitError(null); toastSuccess("Token created successfully"); }, - onError: (error: any) => { - toastError(error.message || "Failed to create token"); + onError: (error: unknown) => { + setSubmitError(error); } }); @@ -121,6 +126,8 @@ export function CreateTokenFormContent({ scope: selectedScopes }; + setSubmitError(null); + createTokenMutation(tokenRequest, { onSuccess: (data) => { onSuccess(data, values); @@ -149,12 +156,12 @@ export function CreateTokenFormContent({ {({ handleSubmit, isValid }) => ( - - - + + + + {submitError != null && ( + + )} {showFooter && ( {selectedScope === "Custom" && ( - + {OBJECTS.map((object) => ( void; + showHideAgentsToggle?: boolean; + defaultHideAgents?: boolean; + tokenIdSearchParamKey?: string; }; export default function TokensTable({ tokens, isLoading, isRefetching, - refresh + refresh, + showHideAgentsToggle = true, + defaultHideAgents = true, + tokenIdSearchParamKey = "id" }: TokensTableProps) { const [searchParams, setSearchParams] = useSearchParams(); const columns = useMemo(() => tokensTableColumns, []); - const tokenId = searchParams.get("id") ?? undefined; + const tokenId = searchParams.get(tokenIdSearchParamKey) ?? undefined; const selectedToken = useMemo(() => { return tokens.find((token) => token.id === tokenId); }, [tokens, tokenId]); - const [hideAgents, setHideAgents] = useState(true); + const [hideAgents, setHideAgents] = useState(defaultHideAgents); const filteredTokens = useMemo(() => { if (hideAgents) { return tokens.filter((token) => !token.name.startsWith("agent-")); @@ -38,14 +44,16 @@ export default function TokensTable({ return ( - { - setHideAgents(val); - }} - /> + {showHideAgentsToggle && ( + { + setHideAgents(val); + }} + /> + )} { - searchParams.set("id", token.id); + searchParams.set(tokenIdSearchParamKey, token.id); setSearchParams(searchParams); }} /> @@ -67,7 +75,7 @@ export default function TokensTable({ if (refresh) { refresh(); } - searchParams.delete("id"); + searchParams.delete(tokenIdSearchParamKey); setSearchParams(searchParams); }} /> diff --git a/src/components/Users/SetupMcpModal.tsx b/src/components/Users/SetupMcpModal.tsx index 9cfbd1b661..496bded592 100644 --- a/src/components/Users/SetupMcpModal.tsx +++ b/src/components/Users/SetupMcpModal.tsx @@ -77,9 +77,10 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) { title="Setup MCP" onClose={handleClose} open={isOpen} - bodyClass="flex h-[70vh] min-h-[620px] max-h-[70vh] w-full flex-1 flex-col overflow-hidden" + allowBodyScroll + bodyClass="flex w-full flex-col" > - + Choose how MCP should authenticate @@ -90,16 +91,16 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) { - + setMode(tab as SetupMode)} - contentClassName="flex min-h-0 flex-1 flex-col overflow-y-auto border border-t-0 border-gray-300 bg-white" + contentClassName="flex flex-col border border-t-0 border-gray-300 bg-white" > ( - + {mcpUsageInstructionsByClient[activeClient] && ( - + {mcpUsageInstructionsByClient[activeClient]} )} @@ -131,7 +132,7 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) { {mcpTokenResponse ? ( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/src/lib/permissions/mcpPermissionCardMappings.ts b/src/lib/permissions/mcpPermissionCardMappings.ts new file mode 100644 index 0000000000..9421d268fb --- /dev/null +++ b/src/lib/permissions/mcpPermissionCardMappings.ts @@ -0,0 +1,208 @@ +import { + PermissionSubject, + MCP_SETTINGS_PERMISSION_SOURCE +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; + +export const EVERYONE_SUBJECT_ID = "everyone"; +export const EVERYONE_SUBJECT_TYPE = "group"; + +/** + * Map a PermissionSubject type to the subject_type used in permission records. + */ +export function mapSubjectType(type: PermissionSubject["type"]) { + if (type === "permission_subject_group") { + return "group" as const; + } + + if (type === "role") { + return "role" as const; + } + + if (type === "access_token_person") { + return "person" as const; + } + + return type; +} + +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) { + const next = permission.deny === true ? "deny" : "allow"; + const current = globalOverrideByResource.get(resource.id); + globalOverrideByResource.set( + resource.id, + current === "deny" || next === "deny" ? "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" || + permission.subject_type === "role" + ) { + current.groups.push(permission); + } + + permissionsByResource.set(resource.id, current); + } + } + + return { + permissionsByResource, + globalOverrideByResource + }; +} diff --git a/src/lib/permissions/useMcpResourcePermissions.ts b/src/lib/permissions/useMcpResourcePermissions.ts new file mode 100644 index 0000000000..f4212dd093 --- /dev/null +++ b/src/lib/permissions/useMcpResourcePermissions.ts @@ -0,0 +1,528 @@ +import { + addPermission, + deletePermission, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +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"; +import { + buildPermissionAccessCardMaps, + EVERYONE_SUBJECT_ID, + EVERYONE_SUBJECT_TYPE, + mapSubjectType, + NamespacedRef, + NamespacedResource, + permissionMatchesResource +} from "./mcpPermissionCardMappings"; + +export type McpResourcePermissionsConfig = + { + resources: TResource[]; + isResourcesLoading: boolean; + isResourcesRefetching: boolean; + refetchResources: () => void; + permissionsQueryKey: string[]; + fetchPermissions: () => Promise; + getRefs: (permission: PermissionsSummary) => NamespacedRef[]; + /** Key used in `object_selector` payloads, e.g. `"playbooks"` or `"views"`. */ + objectSelectorKey: string; + }; + +export type SubjectAccessSelection = { + subject: PermissionSubject; + access: "allow" | "deny"; +}; + +export function useMcpResourcePermissions< + TResource extends NamespacedResource +>({ + resources, + isResourcesLoading, + isResourcesRefetching, + refetchResources, + permissionsQueryKey, + fetchPermissions, + getRefs, + objectSelectorKey +}: McpResourcePermissionsConfig) { + const { user } = useUser(); + const [selectedResourceId, setSelectedResourceId] = useState( + null + ); + const [mutatingResourceId, setMutatingResourceId] = useState( + null + ); + const [mutatingSubjectId, setMutatingSubjectId] = useState( + null + ); + + // ── Queries ────────────────────────────────────────────────────────── + + const { + data: permissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: permissionsQueryKey, + queryFn: fetchPermissions + }); + + // ── Derived data ──────────────────────────────────────────────────── + + const { permissionsByResource, globalOverrideByResource } = useMemo( + () => + buildPermissionAccessCardMaps({ + resources, + permissions, + getRefs, + source: MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId: EVERYONE_SUBJECT_ID, + everyoneSubjectType: EVERYONE_SUBJECT_TYPE + }), + [permissions, resources, getRefs] + ); + + const selectedResource = useMemo( + () => resources.find((r) => r.id === selectedResourceId), + [resources, selectedResourceId] + ); + + const preselectedSubjectAccess = useMemo(() => { + const accessBySubjectId: Record = {}; + + if (!selectedResource) { + return accessBySubjectId; + } + + for (const permission of permissions) { + if ( + !permission.subject || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + (permission.subject_type !== "person" && + permission.subject_type !== "team" && + permission.subject_type !== "group" && + permission.subject_type !== "role") || + !permissionMatchesResource(permission, selectedResource, getRefs) + ) { + continue; + } + + const subjectId = permission.subject; + const nextAccess = permission.deny === true ? "deny" : "allow"; + const currentAccess = accessBySubjectId[subjectId]; + + accessBySubjectId[subjectId] = + currentAccess === "deny" || nextAccess === "deny" ? "deny" : "allow"; + } + + return accessBySubjectId; + }, [permissions, selectedResource, getRefs]); + + // ── Mutations ─────────────────────────────────────────────────────── + + const { + mutate: setGlobalOverrideMutation, + isLoading: isUpdatingGlobalOverride + } = useMutation({ + mutationFn: async ({ + resource, + override + }: { + resource: TResource; + override: "allow" | "none" | "deny"; + }) => { + const latestPermissions = await fetchPermissions(); + + const matchingOverrides = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject_type === EVERYONE_SUBJECT_TYPE && + p.subject === EVERYONE_SUBJECT_ID && + p.id && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + if (override === "none") { + await Promise.all(matchingOverrides.map((p) => deletePermission(p.id))); + return; + } + + const targetDeny = override === "deny"; + const [canonicalOverride, ...duplicateOverrides] = matchingOverrides; + + if (duplicateOverrides.length > 0) { + await Promise.all( + duplicateOverrides.map((p) => deletePermission(p.id)) + ); + } + + if (canonicalOverride) { + if (canonicalOverride.deny !== targetDeny) { + await updatePermission({ + id: canonicalOverride.id, + deny: targetDeny + } as any); + } + return; + } + + await addPermission({ + object_selector: { + [objectSelectorKey]: [ + { name: resource.name, namespace: resource.namespace } + ] + }, + action: "mcp:run", + subject: EVERYONE_SUBJECT_ID, + subject_type: EVERYONE_SUBJECT_TYPE, + deny: targetDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + }, + onSettled: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { + mutateAsync: allowSelectiveAccessMutation, + isLoading: isAllowingSelective + } = useMutation({ + mutationFn: async ({ + resource, + subjects + }: { + resource: TResource; + subjects: PermissionSubject[] | SubjectAccessSelection[]; + }) => { + // Re-fetch to avoid acting on stale data from the render closure + const latestPermissions = await fetchPermissions(); + + const normalizedSelections: SubjectAccessSelection[] = ( + subjects as Array + ).map((entry) => { + if ("subject" in entry) { + return entry; + } + + return { + subject: entry, + access: "allow" + }; + }); + + const desiredAccessByKey = new Map( + normalizedSelections.map((selection) => [ + `${mapSubjectType(selection.subject.type)}:${selection.subject.id}`, + selection.access + ]) + ); + + const existingPermissions = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject && + p.id && + (p.subject_type === "person" || + p.subject_type === "team" || + p.subject_type === "group" || + p.subject_type === "role") && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + const existingByKey = new Map(); + + for (const permission of existingPermissions) { + const key = `${permission.subject_type}:${permission.subject}`; + const list = existingByKey.get(key) ?? []; + list.push(permission); + existingByKey.set(key, list); + } + + const resourceSelector = [ + { name: resource.name, namespace: resource.namespace } + ]; + + const payloadsToAdd = normalizedSelections + .map((selection) => { + const subjectType = mapSubjectType(selection.subject.type); + const key = `${subjectType}:${selection.subject.id}`; + if (existingByKey.has(key)) { + return null; + } + return { + object_selector: { [objectSelectorKey]: resourceSelector }, + action: "mcp:run", + subject: selection.subject.id, + subject_type: subjectType, + deny: selection.access === "deny", + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + }; + }) + .filter((payload): payload is NonNullable => !!payload) + .sort((a, b) => + `${a.subject_type}:${a.subject}`.localeCompare( + `${b.subject_type}:${b.subject}` + ) + ); + + const updatePayloads = Array.from(existingByKey.entries()) + .map(([key, permissionsForSubject]) => { + const desiredAccess = desiredAccessByKey.get(key); + + if (!desiredAccess) { + return null; + } + + const [primary, ...duplicates] = permissionsForSubject; + if (!primary?.id) { + return null; + } + + const shouldDeny = desiredAccess === "deny"; + const requiresUpdate = + primary.deny === true ? !shouldDeny : shouldDeny; + + return { + id: primary.id, + deny: shouldDeny, + requiresUpdate, + duplicateIds: duplicates + .map((permission) => permission.id) + .filter((id): id is string => !!id) + }; + }) + .filter((entry): entry is NonNullable => !!entry) + .sort((a, b) => a.id.localeCompare(b.id)); + + const deleteIds = [ + ...existingPermissions + .filter((permission) => { + const key = `${permission.subject_type}:${permission.subject}`; + return !desiredAccessByKey.has(key); + }) + .map((permission) => permission.id!), + ...updatePayloads.flatMap((entry) => entry.duplicateIds) + ].sort((a, b) => a.localeCompare(b)); + + const addedPermissionIds: string[] = []; + + try { + for (const payload of payloadsToAdd) { + const created = await addPermission(payload as any); + if (created?.data?.id) { + addedPermissionIds.push(created.data.id); + } + } + + for (const payload of updatePayloads) { + if (!payload.requiresUpdate) { + continue; + } + + await updatePermission({ + id: payload.id, + deny: payload.deny + } as any); + } + + for (const id of deleteIds) { + await deletePermission(id); + } + } catch (error) { + if (addedPermissionIds.length > 0) { + await Promise.allSettled( + addedPermissionIds.map((id) => deletePermission(id)) + ); + } + throw error; + } + + return { + added: payloadsToAdd.length, + updated: updatePayloads.filter((entry) => entry.requiresUpdate).length, + removed: deleteIds.length + }; + }, + onSuccess: ({ added, updated, removed }) => { + if (added === 0 && updated === 0 && removed === 0) { + toastSuccess("No permission changes"); + } else { + toastSuccess( + `Updated permissions: +${added} / ~${updated} / -${removed}` + ); + } + setSelectedResourceId(null); + }, + onError: (error) => { + toastError(error as any); + }, + onSettled: () => { + refetchPermissions(); + } + }); + + // ── Wrapped handlers (manage mutating-id state internally) ────────── + + const setGlobalOverride = ( + resource: TResource, + override: "allow" | "none" | "deny" + ) => { + setMutatingResourceId(resource.id); + setGlobalOverrideMutation( + { resource, override }, + { + onSettled: () => { + setMutatingResourceId((cur) => (cur === resource.id ? null : cur)); + } + } + ); + }; + + const allowSelectiveAccess = async ( + resource: TResource, + subjects: PermissionSubject[] | SubjectAccessSelection[] + ) => { + await allowSelectiveAccessMutation({ resource, subjects }); + }; + + const { + mutateAsync: setSelectiveSubjectAccessMutation, + isLoading: isSettingSelectiveSubjectAccess + } = useMutation({ + mutationFn: async ({ + resource, + subject, + access + }: { + resource: TResource; + subject: PermissionSubject; + access: "allow" | "deny" | "default"; + }) => { + const latestPermissions = await fetchPermissions(); + const subjectType = mapSubjectType(subject.type); + + const existingPermissions = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject === subject.id && + p.subject_type === subjectType && + p.id && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + if (access === "default") { + await Promise.all( + existingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const shouldDeny = access === "deny"; + const [primary, ...duplicates] = existingPermissions; + + if (!primary) { + await addPermission({ + object_selector: { + [objectSelectorKey]: [ + { name: resource.name, namespace: resource.namespace } + ] + }, + action: "mcp:run", + subject: subject.id, + subject_type: subjectType, + deny: shouldDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + } else if (primary.deny !== shouldDeny) { + await updatePermission({ + id: primary.id, + deny: shouldDeny + } as any); + } + + if (duplicates.length > 0) { + await Promise.all( + duplicates + .map((permission) => permission.id) + .filter((id): id is string => !!id) + .map((id) => deletePermission(id)) + ); + } + }, + onError: (error) => { + toastError(error as any); + }, + onSettled: () => { + refetchPermissions(); + } + }); + + const setSelectiveSubjectAccess = async ( + resource: TResource, + subject: PermissionSubject, + access: "allow" | "deny" | "default" + ) => { + setMutatingSubjectId(subject.id); + try { + await setSelectiveSubjectAccessMutation({ resource, subject, access }); + } finally { + setMutatingSubjectId((cur) => (cur === subject.id ? null : cur)); + } + }; + + // ── Loading state ─────────────────────────────────────────────────── + + const loading = + isResourcesLoading || + isPermissionsLoading || + isResourcesRefetching || + isPermissionsRefetching || + isUpdatingGlobalOverride || + isAllowingSelective || + isSettingSelectiveSubjectAccess; + + const isInitialLoading = + (isResourcesLoading || isPermissionsLoading) && resources.length === 0; + + return { + permissionsByResource, + globalOverrideByResource, + selectedResource, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + allowSelectiveAccess, + isAllowingSelective, + setSelectiveSubjectAccess, + mutatingSubjectId, + isSettingSelectiveSubjectAccess, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: () => { + refetchResources(); + refetchPermissions(); + } + }; +} diff --git a/src/pages/Settings/PermissionsPage.tsx b/src/pages/Settings/PermissionsPage.tsx index 0e4fda18dd..5d00eba87f 100644 --- a/src/pages/Settings/PermissionsPage.tsx +++ b/src/pages/Settings/PermissionsPage.tsx @@ -1,19 +1,14 @@ import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; import AddPermissionButton from "@flanksource-ui/components/Permissions/ManagePermissions/Forms/AddPermissionButton"; +import PermissionsTabsLinks from "@flanksource-ui/components/Permissions/PermissionsTabsLinks"; import PermissionsView, { permissionsActionsList } from "@flanksource-ui/components/Permissions/PermissionsView"; +import { ReactSelectDropdown } from "@flanksource-ui/components/ReactSelectDropdown"; import { tables } from "@flanksource-ui/context/UserAccessContext/permissions"; -import { - BreadcrumbNav, - BreadcrumbRoot -} from "@flanksource-ui/ui/BreadcrumbNav"; -import { Head } from "@flanksource-ui/ui/Head"; -import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; -import { useMemo, useRef, useState } from "react"; import { parseTristateKeyState } from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { ReactSelectDropdown } from "@flanksource-ui/components/ReactSelectDropdown"; export function PermissionsPage() { const [isLoading, setIsLoading] = useState(false); @@ -63,68 +58,53 @@ export function PermissionsPage() { ); return ( - <> - - - Permissions - , - - - - ]} + refetchFunctionRef.current?.()} + headerAction={ + + + + } + > + + + { + const nextParams = new URLSearchParams(searchParams); + if (!value || value === "all") { + nextParams.delete("action"); + } else { + nextParams.set("action", value); + } + setSearchParams(nextParams); + }} + className="min-w-[180px]" + dropDownClassNames="w-[260px] left-0" + hideControlBorder + prefix={Action:} + /> + + + { + refetchFunctionRef.current = refetch; + }} /> - } - onRefresh={() => refetchFunctionRef.current?.()} - contentClass="p-0 h-full" - loading={isLoading} - > - - - { - const nextParams = new URLSearchParams(searchParams); - if (!value || value === "all") { - nextParams.delete("action"); - } else { - nextParams.set("action", value); - } - setSearchParams(nextParams); - }} - className="min-w-[180px]" - dropDownClassNames="w-[260px] left-0" - hideControlBorder - prefix={Action:} - /> - - - { - refetchFunctionRef.current = refetch; - }} - /> - - - > + + ); } diff --git a/src/pages/Settings/PermissionsSubjectsPage.tsx b/src/pages/Settings/PermissionsSubjectsPage.tsx new file mode 100644 index 0000000000..d68e3765b4 --- /dev/null +++ b/src/pages/Settings/PermissionsSubjectsPage.tsx @@ -0,0 +1,126 @@ +import { fetchAllPermissionSubjects } from "@flanksource-ui/api/services/permissions"; +import PermissionSubjectPanel, { + PermissionSubjectGroup +} from "@flanksource-ui/components/Permissions/PermissionSubjectPanel"; +import PermissionsTabsLinks from "@flanksource-ui/components/Permissions/PermissionsTabsLinks"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const SUBJECT_TYPE_ORDER = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +} as const; + +export function PermissionsSubjectsPage() { + const [subjectSearch, setSubjectSearch] = useState(""); + const [selectedSubjectId, setSelectedSubjectId] = useState( + null + ); + + const debouncedSearch = useDebouncedValue(subjectSearch, 200) ?? ""; + + const { + data: subjects = [], + isLoading, + isRefetching, + refetch + } = useQuery({ + queryKey: ["permissions", "subjects", "all"], + queryFn: fetchAllPermissionSubjects + }); + + const sortedSubjects = useMemo(() => { + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + return subjects + .filter((subject) => { + if (!loweredSearch) { + return true; + } + + return subject.name.toLowerCase().includes(loweredSearch); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + }, [debouncedSearch, subjects]); + + const groupedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of sortedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()).map(([type, list]) => ({ + type, + list + })); + }, [sortedSubjects]); + + useEffect(() => { + if ( + selectedSubjectId && + sortedSubjects.some((subject) => subject.id === selectedSubjectId) + ) { + return; + } + + setSelectedSubjectId(sortedSubjects[0]?.id ?? null); + }, [selectedSubjectId, sortedSubjects]); + + const selectedSubject = useMemo( + () => + sortedSubjects.find((subject) => subject.id === selectedSubjectId) ?? + null, + [selectedSubjectId, sortedSubjects] + ); + + return ( + refetch()} + > + + + Subjects + + Browse permission subjects. Subject details and actions will be + added here. + + + + + + + + + {selectedSubject + ? `Selected subject: ${selectedSubject.name}` + : "Select a subject."} + + + + + + ); +} diff --git a/src/pages/Settings/mcp/McpCheckAccessPage.tsx b/src/pages/Settings/mcp/McpCheckAccessPage.tsx new file mode 100644 index 0000000000..57ba435ca9 --- /dev/null +++ b/src/pages/Settings/mcp/McpCheckAccessPage.tsx @@ -0,0 +1,50 @@ +import { SubjectAccessReviewAction } from "@flanksource-ui/api/services/rbac"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import { + PermissionAccessCheckConfig, + PermissionAccessCheckForm +} from "@flanksource-ui/components/Permissions/PermissionAccessCheckModal"; + +const mcpAccessCheckActions: SubjectAccessReviewAction[] = [ + "mcp:run", + "mcp:use" +]; + +const mcpAccessCheckConfig: PermissionAccessCheckConfig = { + actions: mcpAccessCheckActions, + title: "MCP Access Check", + description: + "Select a subject and action to check MCP access. Resource is required for mcp:run and fixed to MCP for mcp:use.", + allowedResourceTypes: ["playbook", "view"], + hideResourceForActions: ["mcp:use"], + resourceOverrideByAction: { + "mcp:use": { + global: "mcp" + } + } +}; + +export default function McpCheckAccessPage() { + return ( + + + + Check Access + + Permissions granted from the MCP settings page are only one part of + the overall access model. Additional permissions, created elsewhere + in the UI or from Kubernetes resources, may override or supersede + them. + + + + This check evaluates the final effective access across all + permissions. + + + + + + + ); +} diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx new file mode 100644 index 0000000000..87fce8321b --- /dev/null +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -0,0 +1,286 @@ +import { + addPermission, + deletePermission, + fetchAllPermissionSubjects, + fetchMcpUserPermissions, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { useTokensListQuery } from "@flanksource-ui/api/query-hooks/useTokensQuery"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import UserList from "@flanksource-ui/components/MCP/UserList"; +import TokensTable from "@flanksource-ui/components/Tokens/List/TokensTable"; +import { toastError } from "@flanksource-ui/components/Toast/toast"; +import SetupMcpModal from "@flanksource-ui/components/Users/SetupMcpModal"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { AiFillPlusCircle } from "react-icons/ai"; + +const MCP_OBJECT = "mcp"; +const MCP_ACTION = "mcp:use"; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +function isMcpUserAccessPermission(permission: PermissionsSummary) { + return ( + permission.action === MCP_ACTION && + permission.object === MCP_OBJECT && + !!permission.subject && + !!permission.id && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); +} + +function mapPermissionSubjectType(type: PermissionSubject["type"]) { + if (type === "permission_subject_group" || type === "role") { + return "group" as const; + } + + if (type === "access_token_person") { + return "person" as const; + } + + return type; +} + +export default function McpOverviewPage() { + const { user } = useUser(); + const [mutatingSubjectId, setMutatingSubjectId] = useState( + null + ); + const [isSetupMcpModalOpen, setIsSetupMcpModalOpen] = useState(false); + + const { + data: subjects = [], + isLoading: isUsersLoading, + refetch: refetchUsers, + isRefetching: isUsersRefetching + } = useQuery({ + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchAllPermissionSubjects + }); + + const { + data: userPermissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: ["mcp", "users", "permissions"], + queryFn: fetchMcpUserPermissions + }); + + 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); + } + + return map; + }, [userPermissions]); + + const groupedSubjects = useMemo(() => { + const seen = new Set(); + const grouped = new Map(); + + for (const subject of subjects) { + if (subject.type === "access_token_person") { + continue; + } + + if (seen.has(subject.id)) { + continue; + } + seen.add(subject.id); + + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()) + .sort((a, b) => SUBJECT_TYPE_ORDER[a[0]] - SUBJECT_TYPE_ORDER[b[0]]) + .map(([type, groupedItems]) => ({ + type, + subjects: groupedItems.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ) + })); + }, [subjects]); + + const { mutate: setUserAccess, isLoading: isUpdatingUserAccess } = + useMutation({ + mutationFn: async ({ + subjectId, + subjectType, + access + }: { + subjectId: string; + subjectType: PermissionSubject["type"]; + access: "deny" | "default" | "allow"; + }) => { + // Re-fetch to avoid acting on stale data from the render closure + const latestPermissions = await fetchMcpUserPermissions(); + const existingPermissions = latestPermissions + .filter(isMcpUserAccessPermission) + .filter( + (permission) => + permission.subject === subjectId && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); + + if (access === "default") { + await Promise.all( + existingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const targetDeny = access === "deny"; + const primaryPermission = existingPermissions[0]; + const duplicatePermissionIds = existingPermissions + .slice(1) + .map((permission) => permission.id!); + + if (!primaryPermission) { + if (!user?.id) { + throw new Error("User must be logged in to create permissions"); + } + + await addPermission({ + object: MCP_OBJECT, + action: MCP_ACTION, + subject: subjectId, + subject_type: mapPermissionSubjectType(subjectType), + deny: targetDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user.id + } as any); + + return; + } + + await Promise.all([ + ...(primaryPermission.deny !== targetDeny + ? [ + updatePermission({ + id: primaryPermission.id, + deny: targetDeny + } as any) + ] + : []), + ...duplicatePermissionIds.map((id) => deletePermission(id)) + ]); + }, + onSettled: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { + data: tokens = [], + isLoading: isTokensLoading, + refetch: refetchTokens, + isRefetching: isTokensRefetching + } = useTokensListQuery({ + keepPreviousData: true, + staleTime: 0, + cacheTime: 0 + }); + + const loading = + isUsersLoading || + isPermissionsLoading || + isUsersRefetching || + isPermissionsRefetching || + isUpdatingUserAccess; + + const isInitialLoading = + (isUsersLoading || isPermissionsLoading) && subjects.length === 0; + + return ( + setIsSetupMcpModalOpen(true)} + > + + + } + onRefresh={() => { + refetchUsers(); + refetchPermissions(); + refetchTokens(); + }} + > + { + setMutatingSubjectId(subject.id); + setUserAccess( + { subjectId: subject.id, subjectType: subject.type, access }, + { + onSettled: () => { + setMutatingSubjectId((current) => + current === subject.id ? null : current + ); + } + } + ); + }} + /> + + + + MCP access tokens + + + + + setIsSetupMcpModalOpen(false)} + /> + + ); +} diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx new file mode 100644 index 0000000000..84b9fe03bc --- /dev/null +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -0,0 +1,263 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { fetchMcpRunPermissions } from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorPanel, { + SubjectAccess +} from "@flanksource-ui/components/Permissions/SubjectSelectorPanel"; +import { Input } from "@flanksource-ui/components/ui/input"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useMcpResourcePermissions } from "@flanksource-ui/lib/permissions/useMcpResourcePermissions"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const getPlaybookRefs = (permission: PermissionsSummary) => + permission.object_selector?.playbooks ?? []; + +export default function McpPlaybooksPage() { + const { + data: playbooks = [], + isLoading, + refetch, + isRefetching + } = useQuery({ + queryKey: ["mcp", "playbooks", "all"], + queryFn: getAllPlaybookNames + }); + + const { + globalOverrideByResource, + selectedResource: selectedPlaybook, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + setSelectiveSubjectAccess, + isSettingSelectiveSubjectAccess, + mutatingSubjectId, + allowSelectiveAccess, + isAllowingSelective, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: refetchAll + } = useMcpResourcePermissions({ + resources: playbooks, + isResourcesLoading: isLoading, + isResourcesRefetching: isRefetching, + refetchResources: refetch, + permissionsQueryKey: ["mcp", "playbooks", "permissions"], + fetchPermissions: fetchMcpRunPermissions, + getRefs: getPlaybookRefs, + objectSelectorKey: "playbooks" + }); + + const [isSubjectPanelSwitching, setIsSubjectPanelSwitching] = useState(false); + const [playbookSearch, setPlaybookSearch] = useState(""); + + const debouncedSearch = useDebouncedValue(playbookSearch, 200) ?? ""; + + useEffect(() => { + if (!selectedPlaybook) { + setIsSubjectPanelSwitching(false); + return; + } + + setIsSubjectPanelSwitching(true); + const timer = setTimeout(() => { + setIsSubjectPanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedPlaybook?.id]); + + const groupedPlaybooks = useMemo(() => { + const grouped = new Map(); + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + for (const playbook of playbooks) { + const displayName = playbook.title || playbook.name; + const haystack = + `${displayName} ${playbook.namespace || ""} ${playbook.category || ""}`.toLowerCase(); + + if (loweredSearch && !haystack.includes(loweredSearch)) { + continue; + } + + const category = playbook.category?.trim() || "Other"; + const categoryPlaybooks = grouped.get(category) ?? []; + categoryPlaybooks.push(playbook); + grouped.set(category, categoryPlaybooks); + } + + return Array.from(grouped.entries()) + .sort(([a], [b]) => { + if (a === "Other") { + return 1; + } + if (b === "Other") { + return -1; + } + + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }) + .map(([category, categoryPlaybooks]) => ({ + category, + playbooks: categoryPlaybooks.sort((a, b) => + (a.title || a.name).localeCompare(b.title || b.name, undefined, { + sensitivity: "base" + }) + ) + })); + }, [debouncedSearch, playbooks]); + + const visiblePlaybooks = useMemo( + () => groupedPlaybooks.flatMap((group) => group.playbooks), + [groupedPlaybooks] + ); + + useEffect(() => { + if ( + selectedPlaybook?.id && + visiblePlaybooks.some((playbook) => playbook.id === selectedPlaybook.id) + ) { + return; + } + + setSelectedResourceId(visiblePlaybooks[0]?.id ?? null); + }, [selectedPlaybook?.id, setSelectedResourceId, visiblePlaybooks]); + + return ( + + + + + Playbook permissions + + + Control which playbooks MCP clients can invoke through this gateway. + + + + + + setPlaybookSearch(event.target.value)} + /> + + + {groupedPlaybooks.length === 0 ? ( + + No playbooks found + + ) : ( + groupedPlaybooks.map((group) => ( + + + {group.category} + + + {group.playbooks.map((playbook) => { + const isActive = selectedPlaybook?.id === playbook.id; + + return ( + setSelectedResourceId(playbook.id)} + className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${ + isActive + ? "bg-blue-50 text-blue-800" + : "text-gray-800 hover:bg-gray-50" + }`} + > + + + + + {playbook.title || playbook.name} + + + ); + })} + + )) + )} + + + + + {selectedPlaybook ? ( + + + setGlobalOverride( + selectedPlaybook, + access === "allow" + ? "allow" + : access === "deny" + ? "deny" + : "none" + ) + } + onSetSubjectAccess={(subject, access) => + setSelectiveSubjectAccess(selectedPlaybook, subject, access) + } + onSetManySubjectAccess={(selections) => + allowSelectiveAccess(selectedPlaybook, selections) + } + /> + + {isSubjectPanelSwitching ? ( + + ) : null} + + ) : ( + + Select a playbook row to manage custom subject access. + + )} + + + + + ); +} diff --git a/src/pages/Settings/mcp/McpSubjectAccessPage.tsx b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx new file mode 100644 index 0000000000..ddeeda5937 --- /dev/null +++ b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx @@ -0,0 +1,568 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + addPermission, + deletePermission, + fetchAllPermissionSubjects, + fetchMcpRunPermissions, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { + fetchEffectiveSubjectResourceAccess, + EffectiveSubjectResourceAccessResult +} from "@flanksource-ui/api/services/rbac"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import PermissionSubjectPanel from "@flanksource-ui/components/Permissions/PermissionSubjectPanel"; +import ResourceSelectorPanel, { + McpSubjectResource, + ResourceAccess +} from "@flanksource-ui/components/Permissions/ResourceSelectorPanel"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +function getPermissionRefs( + permission: PermissionsSummary, + kind: "playbook" | "view" +) { + return kind === "playbook" + ? (permission.object_selector?.playbooks ?? []) + : (permission.object_selector?.views ?? []); +} + +function permissionMatchesResource( + permission: PermissionsSummary, + resource: McpSubjectResource +) { + const refs = getPermissionRefs(permission, resource.kind); + + return refs.some((ref) => { + if (!ref?.name || ref.name === "*") { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +export default function McpSubjectAccessPage() { + const { user } = useUser(); + const [subjectSearch, setSubjectSearch] = useState(""); + const [selectedSubjectId, setSelectedSubjectId] = useState( + null + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [mutatingResourceIds, setMutatingResourceIds] = useState< + Record + >({}); + const [isResourcePanelSwitching, setIsResourcePanelSwitching] = + useState(false); + const [ + hasTriggeredEffectiveAccessCheck, + setHasTriggeredEffectiveAccessCheck + ] = useState(false); + + const debouncedSearch = useDebouncedValue(subjectSearch, 200) ?? ""; + + const { + data: subjects = [], + isLoading: isSubjectsLoading, + isRefetching: isSubjectsRefetching, + refetch: refetchSubjects + } = useQuery({ + queryKey: ["mcp", "subject-access", "subjects"], + queryFn: fetchAllPermissionSubjects + }); + + const { + data: playbooks = [], + isLoading: isPlaybooksLoading, + isRefetching: isPlaybooksRefetching, + refetch: refetchPlaybooks + } = useQuery({ + queryKey: ["mcp", "subject-access", "playbooks"], + queryFn: getAllPlaybookNames + }); + + const { + data: viewsResponse, + isLoading: isViewsLoading, + isRefetching: isViewsRefetching, + refetch: refetchViews + } = useQuery({ + queryKey: ["mcp", "subject-access", "views"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000) + }); + + const { + data: permissions = [], + isLoading: isPermissionsLoading, + isRefetching: isPermissionsRefetching, + refetch: refetchPermissions + } = useQuery({ + queryKey: ["mcp", "subject-access", "permissions"], + queryFn: fetchMcpRunPermissions + }); + + const views = useMemo(() => viewsResponse?.data ?? [], [viewsResponse?.data]); + + const sortedSubjects = useMemo(() => { + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + return subjects + .filter((subject) => subject.type !== "access_token_person") + .filter((subject) => { + if (!loweredSearch) { + return true; + } + + return subject.name.toLowerCase().includes(loweredSearch); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + }, [debouncedSearch, subjects]); + + const groupedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of sortedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()).map(([type, list]) => ({ + type, + list + })); + }, [sortedSubjects]); + + useEffect(() => { + if ( + selectedSubjectId && + sortedSubjects.some((subject) => subject.id === selectedSubjectId) + ) { + return; + } + + setSelectedSubjectId(sortedSubjects[0]?.id ?? null); + }, [selectedSubjectId, sortedSubjects]); + + const selectedSubject = useMemo( + () => + sortedSubjects.find((subject) => subject.id === selectedSubjectId) ?? + null, + [selectedSubjectId, sortedSubjects] + ); + + useEffect(() => { + if (!selectedSubject) { + setIsResourcePanelSwitching(false); + return; + } + + setIsResourcePanelSwitching(true); + const timer = setTimeout(() => { + setIsResourcePanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedSubject?.id]); + + const resources = useMemo(() => { + const playbookResources = playbooks + .map((playbook) => ({ + id: playbook.id, + kind: "playbook" as const, + name: playbook.name, + displayName: playbook.title || playbook.name, + namespace: playbook.namespace, + icon: playbook.icon || "playbook", + subtitle: playbook.namespace + ? `${playbook.namespace} · playbook` + : "playbook" + })) + .sort((a, b) => + (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { + sensitivity: "base" + } + ) + ); + + const viewResources = views + .map((view) => ({ + id: view.id, + kind: "view" as const, + name: view.name, + displayName: view.spec?.title || view.name, + namespace: view.namespace, + icon: view.spec?.icon || "workflow", + subtitle: view.namespace ? `${view.namespace} · view` : "view" + })) + .sort((a, b) => + (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { + sensitivity: "base" + } + ) + ); + + return [...playbookResources, ...viewResources]; + }, [playbooks, views]); + + const { + data: effectiveAccessResponse, + isFetching: isCheckingEffectiveAccess, + refetch: refetchEffectiveAccess + } = useQuery({ + queryKey: [ + "mcp", + "subject-access", + "effective-access", + selectedSubject?.id ?? "none", + resources.length + ], + enabled: false, + queryFn: async () => { + if (!selectedSubject) { + return { + subject: "", + action: "mcp:run" as const, + results: [] as EffectiveSubjectResourceAccessResult[] + }; + } + + return fetchEffectiveSubjectResourceAccess({ + subject: selectedSubject.id, + action: "mcp:run", + resources: resources.map((resource) => ({ + id: resource.id, + type: resource.kind + })) + }); + } + }); + + const effectiveAccessByResourceKey = useMemo(() => { + const map: Record = {}; + + for (const result of effectiveAccessResponse?.results ?? []) { + map[`${result.resourceType}:${result.resourceId}`] = result.allowed; + } + + return map; + }, [effectiveAccessResponse?.results]); + + const hasEffectiveAccessResults = + hasTriggeredEffectiveAccessCheck && + (effectiveAccessResponse?.results?.length ?? 0) > 0; + + useEffect(() => { + setHasTriggeredEffectiveAccessCheck(false); + }, [selectedSubject?.id]); + + const loading = + isSubjectsLoading || + isPlaybooksLoading || + isViewsLoading || + isPermissionsLoading || + isSubjectsRefetching || + isPlaybooksRefetching || + isViewsRefetching || + isPermissionsRefetching || + isSubmitting; + + const isInitialLoading = + (isSubjectsLoading || + isPlaybooksLoading || + isViewsLoading || + isPermissionsLoading) && + resources.length === 0; + + const setResourceAccess = async ( + subject: PermissionSubject, + targetResource: McpSubjectResource, + access: ResourceAccess, + latestPermissions?: PermissionsSummary[] + ) => { + const currentPermissions = + latestPermissions ?? (await fetchMcpRunPermissions()); + const subjectType = mapSubjectType(subject.type); + + const matchingPermissions = currentPermissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && + permission.subject === subject.id && + permission.subject_type === subjectType && + permission.id && + permissionMatchesResource(permission, targetResource) + ); + + if (access === "default") { + await Promise.all( + matchingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const deny = access === "deny"; + const [primaryPermission, ...duplicates] = matchingPermissions; + + if (!primaryPermission) { + await addPermission({ + action: "mcp:run", + object_selector: { + [targetResource.kind === "playbook" ? "playbooks" : "views"]: [ + { name: targetResource.name, namespace: targetResource.namespace } + ] + }, + subject: subject.id, + subject_type: subjectType, + deny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + return; + } + + await Promise.all([ + ...(primaryPermission.deny === deny + ? [] + : [ + updatePermission({ + id: primaryPermission.id, + deny + } as any) + ]), + ...duplicates.map((permission) => deletePermission(permission.id!)) + ]); + }; + + const applyAccess = async ( + targetResources: McpSubjectResource[], + access: ResourceAccess + ) => { + if (!selectedSubject || targetResources.length === 0) { + return; + } + + const subjectType = mapSubjectType(selectedSubject.type); + const targetKinds = [ + ...new Set(targetResources.map((resource) => resource.kind)) + ]; + + setIsSubmitting(true); + setMutatingResourceIds( + Object.fromEntries(targetResources.map((resource) => [resource.id, true])) + ); + + try { + const latestPermissions = await fetchMcpRunPermissions(); + + if (targetResources.length > 1) { + for (const kind of targetKinds) { + const selectorKey = kind === "playbook" ? "playbooks" : "views"; + + const existingForKind = latestPermissions.filter((permission) => { + if ( + permission.action !== "mcp:run" || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + permission.subject !== selectedSubject.id || + permission.subject_type !== subjectType || + !permission.id + ) { + return false; + } + + return getPermissionRefs(permission, kind).length > 0; + }); + + if (access === "default") { + await Promise.all( + existingForKind.map((permission) => + deletePermission(permission.id!) + ) + ); + continue; + } + + const deny = access === "deny"; + const wildcardPermissions = existingForKind.filter((permission) => + getPermissionRefs(permission, kind).some( + (ref) => ref?.name === "*" && !ref?.namespace + ) + ); + + const [primaryWildcard, ...duplicateWildcards] = wildcardPermissions; + + if (!primaryWildcard) { + await addPermission({ + action: "mcp:run", + object_selector: { + [selectorKey]: [{ name: "*" }] + }, + subject: selectedSubject.id, + subject_type: subjectType, + deny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + } else if (primaryWildcard.deny !== deny) { + await updatePermission({ + id: primaryWildcard.id, + deny + } as any); + } + + const wildcardIdsToKeep = new Set( + primaryWildcard?.id ? [primaryWildcard.id] : [] + ); + + const permissionIdsToDelete = [ + ...duplicateWildcards.map((permission) => permission.id!), + ...existingForKind + .filter((permission) => !wildcardIdsToKeep.has(permission.id!)) + .filter( + (permission) => + !wildcardPermissions.some( + (wildcard) => wildcard.id === permission.id + ) + ) + .map((permission) => permission.id!) + ]; + + if (permissionIdsToDelete.length > 0) { + await Promise.all( + permissionIdsToDelete.map((id) => deletePermission(id)) + ); + } + } + + toastSuccess("Updated subject access"); + } else { + await setResourceAccess( + selectedSubject, + targetResources[0], + access, + latestPermissions + ); + toastSuccess("Updated subject access"); + } + } catch (error) { + toastError(error as any); + } finally { + setMutatingResourceIds({}); + setIsSubmitting(false); + refetchPermissions(); + } + }; + + return ( + { + refetchSubjects(); + refetchPlaybooks(); + refetchViews(); + refetchPermissions(); + }} + > + + + + Subject access + + + See all playbooks and views a specific user, role, or group can + access through this gateway. + + + + + + + + {selectedSubject ? ( + + { + setHasTriggeredEffectiveAccessCheck(true); + refetchEffectiveAccess(); + }} + onSetResourceAccess={(resource, access) => + applyAccess([resource], access) + } + onSetManyResourceAccess={applyAccess} + /> + + {isResourcePanelSwitching ? ( + + ) : null} + + ) : ( + + Select a subject to inspect MCP resource access. + + )} + + + + + ); +} diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx new file mode 100644 index 0000000000..2bf0bef36f --- /dev/null +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -0,0 +1,262 @@ +import { fetchMcpRunPermissions } from "@flanksource-ui/api/services/permissions"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorPanel, { + SubjectAccess +} from "@flanksource-ui/components/Permissions/SubjectSelectorPanel"; +import { Input } from "@flanksource-ui/components/ui/input"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useMcpResourcePermissions } from "@flanksource-ui/lib/permissions/useMcpResourcePermissions"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const getViewRefs = (permission: PermissionsSummary) => + permission.object_selector?.views ?? []; + +export default function McpViewsPage() { + const { + isLoading, + data: viewsResponse, + refetch, + isRefetching + } = useQuery({ + queryKey: ["mcp", "views", "all"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000) + }); + + const views = useMemo(() => viewsResponse?.data ?? [], [viewsResponse?.data]); + + const { + globalOverrideByResource, + selectedResource: selectedView, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + setSelectiveSubjectAccess, + isSettingSelectiveSubjectAccess, + mutatingSubjectId, + allowSelectiveAccess, + isAllowingSelective, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: refetchAll + } = useMcpResourcePermissions({ + resources: views, + isResourcesLoading: isLoading, + isResourcesRefetching: isRefetching, + refetchResources: refetch, + permissionsQueryKey: ["mcp", "views", "permissions"], + fetchPermissions: fetchMcpRunPermissions, + getRefs: getViewRefs, + objectSelectorKey: "views" + }); + + const [isSubjectPanelSwitching, setIsSubjectPanelSwitching] = useState(false); + const [viewSearch, setViewSearch] = useState(""); + + const debouncedSearch = useDebouncedValue(viewSearch, 200) ?? ""; + + useEffect(() => { + if (!selectedView) { + setIsSubjectPanelSwitching(false); + return; + } + + // SubjectSelectorPanel remounts when the selected view changes (key={selectedView.id}). + // Keep this overlay on briefly so the switch feels intentional instead of a flicker. + setIsSubjectPanelSwitching(true); + const timer = setTimeout(() => { + setIsSubjectPanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedView?.id]); + + const groupedViews = useMemo(() => { + const grouped = new Map(); + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + for (const view of views) { + const displayName = view.spec?.title || view.name; + const group = view.namespace?.trim() || "Global"; + const haystack = `${displayName} ${view.namespace || ""}`.toLowerCase(); + + if (loweredSearch && !haystack.includes(loweredSearch)) { + continue; + } + + const list = grouped.get(group) ?? []; + list.push(view); + grouped.set(group, list); + } + + return Array.from(grouped.entries()) + .sort(([a], [b]) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ) + .map(([group, groupedItems]) => ({ + group, + views: groupedItems.sort((a, b) => + (a.spec?.title || a.name).localeCompare( + b.spec?.title || b.name, + undefined, + { + sensitivity: "base" + } + ) + ) + })); + }, [debouncedSearch, views]); + + const visibleViews = useMemo( + () => groupedViews.flatMap((group) => group.views), + [groupedViews] + ); + + useEffect(() => { + if ( + selectedView?.id && + visibleViews.some((view) => view.id === selectedView.id) + ) { + return; + } + + setSelectedResourceId(visibleViews[0]?.id ?? null); + }, [selectedView?.id, setSelectedResourceId, visibleViews]); + + return ( + + + + + View permissions + + + Control which views MCP clients can invoke through this gateway. + + + + + + setViewSearch(event.target.value)} + /> + + + {groupedViews.length === 0 ? ( + + No views found + + ) : ( + groupedViews.map((group) => ( + + + {group.group} + + + {group.views.map((view) => { + const isActive = selectedView?.id === view.id; + + return ( + setSelectedResourceId(view.id)} + className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${ + isActive + ? "bg-blue-50 text-blue-800" + : "text-gray-800 hover:bg-gray-50" + }`} + > + + + + + {view.spec?.title || view.name} + + + ); + })} + + )) + )} + + + + {/* Keep a stable right-column width/height for both states so layout doesn't jump. */} + + {selectedView ? ( + + + setGlobalOverride( + selectedView, + access === "allow" + ? "allow" + : access === "deny" + ? "deny" + : "none" + ) + } + onSetSubjectAccess={(subject, access) => + setSelectiveSubjectAccess(selectedView, subject, access) + } + onSetManySubjectAccess={(selections) => + allowSelectiveAccess(selectedView, selections) + } + /> + + {isSubjectPanelSwitching ? ( + + ) : null} + + ) : ( + + Select a view row to manage custom subject access. + + )} + + + + + ); +} 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..81655b93ee 100644 --- a/src/ui/FormControls/Switch.tsx +++ b/src/ui/FormControls/Switch.tsx @@ -1,40 +1,78 @@ import clsx from "clsx"; import { useEffect, useState } from "react"; +type SwitchSize = "default" | "sm" | "lg"; + type Props = { value?: T; onChange?: (value: T) => void; options: T[]; + size?: SwitchSize; className?: string; itemsClassName?: string; + activeItemClassName?: string; + getActiveItemClassName?: (option: T) => string | undefined; } & Omit, "onChange">; export function Switch({ onChange = () => {}, options, value, + size = "default", className, itemsClassName = "flex-1", + activeItemClassName, + getActiveItemClassName, ...props }: Props) { - const [activeOption, setActiveOption] = useState(() => value ?? options[0]); - 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 = - "p-1.5 lg:pl-2.5 lg:pr-3.5 rounded-md flex items-center text-sm font-medium"; + const fallbackOption = options[0]; + const [activeOption, setActiveOption] = useState( + () => value ?? fallbackOption + ); + const sizeClasses: Record< + SwitchSize, + { container: string; item: string; activeItem: string } + > = { + sm: { + container: "rounded-md p-0.5", + item: "px-2 py-1 text-xs", + activeItem: "shadow-sm" + }, + default: { + container: "rounded-lg p-0.5", + item: "p-1.5 lg:pl-2.5 lg:pr-3.5 text-sm", + activeItem: "shadow-sm" + }, + lg: { + container: "rounded-lg p-1", + item: "px-3 py-2 text-sm lg:px-4 lg:py-2.5", + activeItem: "shadow" + } + }; + + const activeClasses = clsx( + "rounded-md flex items-center font-medium bg-white ring-1 ring-black ring-opacity-5", + sizeClasses[size].item, + sizeClasses[size].activeItem + ); + const inActiveClasses = clsx( + "rounded-md flex items-center font-medium", + sizeClasses[size].item + ); function handleClick(view: T) { onChange(view); } useEffect(() => { - setActiveOption(value ?? options[0]); - }, [options, value]); + setActiveOption(value ?? fallbackOption); + }, [fallbackOption, value]); return ( ({ type="button" className={`${itemsClassName} items-center whitespace-nowrap rounded-md text-sm font-medium text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-100`} tabIndex={0} - onClick={(e) => handleClick(option)} + onClick={() => handleClick(option)} key={option.toString()} > {option} diff --git a/src/ui/Modal/index.tsx b/src/ui/Modal/index.tsx index 027b631006..6ca4a0b41d 100644 --- a/src/ui/Modal/index.tsx +++ b/src/ui/Modal/index.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { XIcon } from "@heroicons/react/solid"; import clsx from "clsx"; import { atom, useAtom } from "jotai"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { BsArrowsFullscreen, BsFullscreenExit } from "react-icons/bs"; import { useWindowSize } from "react-use-size"; import DialogButton from "../Buttons/DialogButton"; @@ -44,6 +44,7 @@ export interface IModalProps { children?: React.ReactNode; containerClassName?: string; dialogClassName?: string; + allowBodyScroll?: boolean; } export function useDialogSize(size?: string): { @@ -142,6 +143,7 @@ export function Modal({ containerClassName = "overflow-auto max-h-full", dialogClassName = "fixed z-50 inset-0 overflow-y-auto min-h-2xl:my-20 py-4", helpLink: helpLinkProps, + allowBodyScroll = false, ...rest }: IModalProps) { const [_helpLink, setHelpLink] = useAtom(modalHelpLinkAtom); @@ -150,13 +152,44 @@ export function Modal({ const isSmall = _size === "very-small" || _size === "small"; const helpLink = _helpLink || helpLinkProps; + const resolvedDialogClassName = allowBodyScroll + ? "fixed z-50 inset-0 overflow-y-auto py-4" + : dialogClassName; + const resolvedDialogPanelClassName = clsx( + "flex justify-center", + allowBodyScroll ? "items-start" : "items-center", + allowBodyScroll ? "mx-auto" : sizeClass.classNames, + sizeClass.width + ); + + useEffect(() => { + if (!open || !allowBodyScroll) { + return; + } + + const html = document.documentElement; + const body = document.body; + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + const prevBodyPaddingRight = body.style.paddingRight; + + html.style.overflow = "auto"; + body.style.overflow = "auto"; + body.style.paddingRight = "0px"; + + return () => { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + body.style.paddingRight = prevBodyPaddingRight; + }; + }, [allowBodyScroll, open]); return ( { // reset the help link when the modal is closed, this is to ensure // that the help link is not displayed when the modal is reopened and @@ -173,21 +206,18 @@ export function Modal({ transition className="fixed inset-0 bg-black/30 duration-300 ease-out data-[closed]:opacity-0" /> - + {children}
Resource Type
Resource
Subject
{errors.subjectId}
Action
{errors.action}
+ {errors.resourceId} +
+ {overrideResourceLabel ?? "N/A"} +
+ Browse permission subjects. Subject details and actions will be + added here. +
+ Permissions granted from the MCP settings page are only one part of + the overall access model. Additional permissions, created elsewhere + in the UI or from Kubernetes resources, may override or supersede + them. +
+ This check evaluates the final effective access across all + permissions. +
+ Control which playbooks MCP clients can invoke through this gateway. +
+ See all playbooks and views a specific user, role, or group can + access through this gateway. +
+ Control which views MCP clients can invoke through this gateway. +