fix(mcp): восстановить вход после отзыва доступа
This commit is contained in:
1 parent
54f3d19afb
commit
cbc59ef850
6 files changed
+248
-18
No files matched your search
@@ -180,6 +180,41 @@ try {
|
||||
assert.deepEqual(listed.json().profiles, [{ ...profile, state: { reason: profile.state.reason, candidateId: "wan-b", failureCount: 3 } }])
|
||||
assert.ok(!listed.body.includes("NEVER_PRIVATE_"))
|
||||
} finally { pool.query = originalQuery }
|
||||
const monitoredOperation = "58d8e404-7209-4fd3-88b3-91f9bed9425a"
|
||||
const monitoringHeaders = { authorization: "Bearer " + await token(["mm:mcp:read"]) }
|
||||
const monitoringUrl = `/api/mcp/operations?operationId=${monitoredOperation}&limit=1`
|
||||
assert.equal((await app.inject({ url: monitoringUrl })).statusCode, 401)
|
||||
let operationVisible = true
|
||||
let stepReads = 0
|
||||
pool.query = (async (sql: string, values: unknown[]) => {
|
||||
if (sql.includes("FROM mcp_operation_steps")) {
|
||||
stepReads++
|
||||
assert.match(sql, /SELECT step_index,server_id,state,finished_at/)
|
||||
assert.match(sql, /LIMIT 101$/)
|
||||
assert.deepEqual(values, [monitoredOperation])
|
||||
return { rows: Array.from({ length: 101 }, (_, step_index) => ({ step_index, state: "verified" })) }
|
||||
}
|
||||
assert.match(sql, /c\.owner_id=\$2/)
|
||||
assert.match(sql, /r\.id=\$3::uuid/)
|
||||
if (sql.startsWith("SELECT count")) {
|
||||
assert.deepEqual(values.slice(0, 3), [false, "human-admin", monitoredOperation])
|
||||
return { rows: [{ total: operationVisible ? 1 : 0 }] }
|
||||
}
|
||||
assert.match(sql, /FROM mcp_operations r/)
|
||||
assert.deepEqual(values, [false, "human-admin", monitoredOperation, 2])
|
||||
return { rows: operationVisible ? [{ id: monitoredOperation, state: "partial" }] : [] }
|
||||
}) as typeof pool.query
|
||||
try {
|
||||
const result = await app.inject({ url: monitoringUrl, headers: monitoringHeaders })
|
||||
assert.equal(result.statusCode, 200, result.body)
|
||||
assert.equal(result.json().items[0].steps.length, 100)
|
||||
assert.equal(result.json().items[0].stepsTruncated, true)
|
||||
assert.equal(result.json().items[0].state, "partial")
|
||||
operationVisible = false
|
||||
assert.deepEqual((await app.inject({ url: monitoringUrl, headers: monitoringHeaders })).json(), { items: [], total: 0, nextCursor: null })
|
||||
assert.equal(stepReads, 1, "An inaccessible operation must not load its steps")
|
||||
assert.equal((await app.inject({ url: monitoringUrl.replace("/operations", "/plans"), headers: monitoringHeaders })).statusCode, 400)
|
||||
} finally { pool.query = originalQuery }
|
||||
env.authRequired = false
|
||||
assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503)
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503)
|
||||
|
||||
@@ -238,6 +238,10 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
||||
const owner = table === "drift" ? "EXISTS(SELECT 1 FROM mcp_operations o JOIN mcp_connections c ON c.id=o.connection_id WHERE o.id=r.operation_id AND c.owner_id=$2)" : "EXISTS(SELECT 1 FROM mcp_connections c WHERE c.id=r.connection_id AND c.owner_id=$2)"
|
||||
const clauses = [`($1::boolean OR ${owner})`]
|
||||
const bind = (value: unknown) => { values.push(value); return "$" + values.length }
|
||||
if (query.operationId) {
|
||||
if (table !== "operations") throw new McpError("INVALID_TARGET", "ID операции используется только в журнале операций", 400)
|
||||
clauses.push(`r.id=${bind(query.operationId)}::uuid`)
|
||||
}
|
||||
if (query.connectionId && table !== "drift") clauses.push(`r.connection_id=${bind(query.connectionId)}::uuid`)
|
||||
if (query.status && ["requests", "plans", "operations"].includes(table)) clauses.push(`r.${table === "requests" ? "status" : "state"}=${bind(query.status)}`)
|
||||
if (query.tool && table === "requests") clauses.push(`r.tool=${bind(query.tool)}`)
|
||||
@@ -254,8 +258,12 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
||||
const count = await pool.query<{ total: number }>(`SELECT count(*)::int AS total FROM ${relation} r WHERE ${clauses.join(" AND ")}`, values)
|
||||
const { rows } = await pool.query(`SELECT ${table === "requests" ? MCP_REQUEST_MONITOR_PROJECTION : "r.*"} FROM ${relation} r WHERE ${clauses.join(" AND ")} ORDER BY r.${dateField} DESC,r.id DESC LIMIT ${bind(query.limit + 1)}`, values)
|
||||
const entries = rows.slice(0, query.limit)
|
||||
const steps = table === "operations" && query.operationId && entries.length > 0
|
||||
? (await pool.query(`SELECT step_index,server_id,state,finished_at FROM mcp_operation_steps
|
||||
WHERE operation_id=$1 ORDER BY step_index LIMIT 101`, [query.operationId])).rows
|
||||
: undefined
|
||||
const receipts = table === "operations" ? await humanArtifactReceipts(entries, actor, { query: (sql, values) => pool.query(sql, values), authorize: (id, actor, kind) => kind === "ipsec-existing-certificate-bundle" ? existingCertificateArtifact.metadata(id, actor, checkClaims) : kind === "ipsec-user-bundle" ? humanIpsecUserBundleMetadata(id, actor, checkClaims) : kind === "wireguard-export" ? humanWireguardExportMetadata(id, actor, checkClaims) : kind === "database-backup" ? humanDatabaseBackupMetadata(id, actor, checkClaims) : humanBackupDownloadMetadata(id, actor, checkClaims), checkClaims }) : new Map()
|
||||
return { items: table === "plans" ? await Promise.all(entries.map(row => publicPlan(row as Awaited<ReturnType<typeof loadPlan>>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record<string, unknown>, artifactReceipts: (receipts.get(row.id) ?? []).map((receipt: { artifactId: string }) => ({ ...receipt, downloadPath: `/api/mcp/artifacts/${receipt.artifactId}` })) } : redact(row)), total: count.rows[0].total, nextCursor: rows.length > query.limit ? String(entries.at(-1)?.id) : null }
|
||||
return { items: table === "plans" ? await Promise.all(entries.map(row => publicPlan(row as Awaited<ReturnType<typeof loadPlan>>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record<string, unknown>, ...(steps && { steps: steps.slice(0, 100), stepsTruncated: steps.length > 100 }), artifactReceipts: (receipts.get(row.id) ?? []).map((receipt: { artifactId: string }) => ({ ...receipt, downloadPath: `/api/mcp/artifacts/${receipt.artifactId}` })) } : redact(row)), total: count.rows[0].total, nextCursor: rows.length > query.limit ? String(entries.at(-1)?.id) : null }
|
||||
})
|
||||
}
|
||||
app.get("/mcp/plans/:id", async request => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { mcpConnectionCreateSchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
|
||||
import { mcpConnectionCreateSchema, mcpPageQuerySchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
|
||||
import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can, getClaims, hasPermission } from "@/lib/auth"
|
||||
@@ -21,7 +22,7 @@ import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permiss
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
@@ -393,6 +394,10 @@ const recordQueryFilters = {
|
||||
|
||||
function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFilters }) {
|
||||
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(null)
|
||||
const [startedOperation, setStartedOperation] = useState<string | null>(null)
|
||||
const operationId = kind === "operations" ? urlParams.get("operation") : null
|
||||
const operationLink = new URLSearchParams(urlParams)
|
||||
if (startedOperation) operationLink.set("operation", startedOperation)
|
||||
const fields: readonly string[] = recordQueryFilters[kind]
|
||||
const filterValue = (field: string) => urlParams.get(kind + "." + field) ?? ""
|
||||
const cursor = filterValue("cursor")
|
||||
@@ -405,19 +410,53 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
|
||||
const queryKey = queryFields.toString()
|
||||
const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey])
|
||||
const query = useQuery(load, `${kind}?${queryKey}`)
|
||||
const journalConnection = kind === "requests" ? filterValue("connectionId") : ""
|
||||
const navigationClass = buttonVariants({ variant: "outline", className: "self-start" })
|
||||
const selectRecord = useCallback((row: RecordRow) => {
|
||||
if (kind !== "operations") { setSelected(row); return }
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("operation", String(row.id))
|
||||
window.history.pushState(null, "", url.pathname + url.search)
|
||||
}, [kind])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) },
|
||||
{ id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) },
|
||||
{ id: "state", header: "Результат", cell: ({ row }) => <Badge variant={["completed", "applied"].includes(String(row.original.status ?? row.original.state)) ? "success-light" : "info-light"}>{String(row.original.status ?? row.original.state ?? "событие")}</Badge> },
|
||||
{ accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => <span className="font-mono text-xs">{String(row.original.connection_id ?? row.original.connectionId ?? "—")}</span> },
|
||||
{ accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") },
|
||||
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => setSelected(row.original)}>Открыть</Button> },
|
||||
], [])
|
||||
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => selectRecord(row.original)}>Открыть</Button> },
|
||||
], [selectRecord])
|
||||
const pendingPlanRequests = useRef(new Set<string>())
|
||||
const [pendingPlans, setPendingPlans] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id))
|
||||
const planExpiry = kind === "plans" && typeof selected?.expiresAt === "string" ? Date.parse(selected.expiresAt) : null
|
||||
const [planClock, setPlanClock] = useState(0)
|
||||
useEffect(() => {
|
||||
if (planExpiry === null || !Number.isFinite(planExpiry)) return
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const update = () => {
|
||||
clearTimeout(timer)
|
||||
const now = Date.now()
|
||||
setPlanClock(now)
|
||||
if (now < planExpiry) timer = setTimeout(update, Math.min(planExpiry - now, 2_147_483_647))
|
||||
}
|
||||
update()
|
||||
document.addEventListener("visibilitychange", update)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
document.removeEventListener("visibilitychange", update)
|
||||
}
|
||||
}, [planExpiry])
|
||||
const planExpired = planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= planClock)
|
||||
const currentPlan = kind === "plans" && selected ? query.data?.items.find(item => item.id === selected.id) : null
|
||||
const planChanged = kind === "plans" && !!selected && (!currentPlan || query.error || pretty(currentPlan) !== pretty(selected))
|
||||
const planActionDisabled = selectedPlanPending || planExpired || !!planChanged
|
||||
const submitPlan = async (action: "approve" | "reject" | "apply") => {
|
||||
if (!selected) return
|
||||
if (!selected || planChanged) return
|
||||
if (planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= Date.now())) {
|
||||
setPlanClock(Date.now())
|
||||
return
|
||||
}
|
||||
const plan = selected
|
||||
const planId = String(plan.id)
|
||||
const pending = pendingPlanRequests.current
|
||||
@@ -426,8 +465,9 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
|
||||
setPendingPlans(new Set(pending))
|
||||
try {
|
||||
if (action === "apply") {
|
||||
await api.apply(planId)
|
||||
toast.success("Операция принята; результат появится в списке операций")
|
||||
const operation = await api.apply(planId)
|
||||
setStartedOperation(operation.id)
|
||||
toast.success("Запрос принят. Откройте выполнение операции для проверки результата.")
|
||||
} else {
|
||||
await api.decision(planId, action === "approve")
|
||||
}
|
||||
@@ -440,9 +480,20 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
|
||||
setPendingPlans(new Set(pending))
|
||||
}
|
||||
}
|
||||
const closeDetails = () => {
|
||||
setSelected(null)
|
||||
if (operationId) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("operation")
|
||||
window.history.replaceState(null, "", url.pathname + url.search)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования">
|
||||
<ErrorMessage message={query.error} />
|
||||
{startedOperation && <Link className={navigationClass} href={`/mcp?${operationLink}`}>Открыть выполнение операции</Link>}
|
||||
{journalConnection && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "plans", "plans.connectionId": journalConnection, "operations.connectionId": journalConnection })}`}>Планы и операции подключения</Link>}
|
||||
{journalConnection && can("mm:mcp:admin") && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "settings", "audit.connectionId": journalConnection })}#mcp-audit`}>Административный аудит подключения</Link>}
|
||||
<p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error && query.data ? " · показаны ранее полученные данные" : ""}</p>
|
||||
<RecordFilters fields={fields} value={filterValue} update={setFilter} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -450,7 +501,7 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
|
||||
{fields.includes("status") && <Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={filterValue("status")} onChange={event => setFilter("status", event.target.value)} />}
|
||||
<Button variant="outline" onClick={query.refresh}><RefreshCwIcon />Обновить</Button>
|
||||
</div>
|
||||
<Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={setSelected} />
|
||||
<Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={selectRecord} />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Найдено: {query.data?.total ?? "—"}</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -458,10 +509,65 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
|
||||
<Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setFilter("cursor", query.data?.nextCursor ?? "")}>Далее</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle><DialogDescription>Секретные поля скрыты. Подтверждение относится к этому содержимому.</DialogDescription></DialogHeader>{kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}{kind === "operations" && selected && <ArtifactDownloads key={String(selected.id)} api={api} operation={selected} />}{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button disabled={selectedPlanPending} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={selectedPlanPending} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={selectedPlanPending} onClick={() => { void submitPlan("reject") }}>Отклонить</Button></div>}</DialogContent></Dialog>
|
||||
<Dialog open={!!selected || !!operationId} onOpenChange={open => { if (!open) closeDetails() }}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle>
|
||||
<DialogDescription>{kind === "plans" ? "Подтверждение относится к показанному плану." : "Данные журнала с маскировкой секретных полей."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{operationId ? <OperationMonitor key={operationId} api={api} id={operationId} /> : kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}
|
||||
{planExpired && <p role="status" className="text-sm text-muted-foreground">Срок плана истёк. Подготовьте новый план.</p>}
|
||||
{planChanged && !planExpired && <p role="status" className="text-sm text-muted-foreground">Текущая версия плана недоступна или изменилась. Откройте план заново.</p>}
|
||||
{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button disabled={planActionDisabled} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}
|
||||
{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={planActionDisabled} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={planActionDisabled} onClick={() => { void submitPlan("reject") }}>Отклонить</Button></div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const operationStates: Record<string, string> = {
|
||||
pending: "Ожидает выполнения", running: "Выполняется", applied: "Применена", failed: "Ошибка выполнения",
|
||||
blocked: "Заблокирована", cancelled: "Отменена", partial: "Выполнена частично", unknown: "Исход не подтверждён",
|
||||
}
|
||||
const stepStates: Record<string, string> = { intent: "Записано намерение", applied: "Применён", acknowledged: "Получено подтверждение приёма", verified: "Результат проверен" }
|
||||
|
||||
function OperationMonitor({ api, id }: { api: McpApi; id: string }) {
|
||||
const recoveryLink = new URLSearchParams(useSearchParams())
|
||||
recoveryLink.delete("operation")
|
||||
recoveryLink.set("reconcile", id)
|
||||
const load = useCallback((signal: AbortSignal) => api.records("operations", new URLSearchParams({ operationId: id, limit: "1" }), signal), [api, id])
|
||||
const query = useQuery(load, id)
|
||||
const operation = query.data?.items.find(item => item.id === id)
|
||||
const state = String(operation?.state ?? "")
|
||||
const steps = Array.isArray(operation?.steps) ? operation.steps as RecordRow[] : []
|
||||
return <>
|
||||
<ErrorMessage message={query.error} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all font-mono text-xs">{id}</span>
|
||||
<Button variant="outline" size="sm" onClick={query.refresh}>Обновить выполнение</Button>
|
||||
</div>
|
||||
<p aria-live="polite" className="text-sm">
|
||||
{operation ? operationStates[state] ?? state : query.data ? "Операция отсутствует в доступном журнале" : query.error ? "Состояние недоступно" : "Получаем состояние операции…"}
|
||||
{query.error && operation && " · последнее полученное состояние"}
|
||||
</p>
|
||||
{operation && <>
|
||||
{(state === "partial" || state === "unknown") && <div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<p className="text-muted-foreground">Перед обратным изменением сверьте фактическое состояние. Повтор может повторно изменить конфигурацию.</p>
|
||||
{can("mm:mcp:admin") && <Link className={buttonVariants({ variant: "outline", size: "sm" })} href={`/mcp?${recoveryLink}#mcp-recovery`}>Перейти к сверке</Link>}
|
||||
</div>}
|
||||
{steps.length > 0 && <section aria-label="Шаги выполнения" className="rounded-md border p-3 text-sm">
|
||||
<h3 className="font-medium">Шаги выполнения</h3>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1">
|
||||
{steps.map(step => <li key={String(step.step_index)}>{stepStates[String(step.state)] ?? String(step.state)}{step.server_id != null && ` · сервер ${step.server_id}`}{step.finished_at != null && ` · ${date(step.finished_at)}`}</li>)}
|
||||
</ol>
|
||||
{operation.stepsTruncated === true && <p className="mt-2 text-muted-foreground">Показаны первые 100 шагов.</p>}
|
||||
</section>}
|
||||
<pre className="max-h-[45dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(operation)}</pre>
|
||||
{!query.error && <ArtifactDownloads api={api} operation={operation} />}
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
function RecordFilters({ fields, value, update }: { fields: readonly string[]; value: (field: string) => string; update: (field: string, value: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -554,13 +660,27 @@ function Permissions({ api, operations }: { api: McpApi; operations: McpOperatio
|
||||
</Panel>
|
||||
</div>
|
||||
}
|
||||
function OperationRecovery({ api }: { api: McpApi }) {
|
||||
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
|
||||
function OperationRecovery({ api, operationId }: { api: McpApi; operationId: string }) {
|
||||
const [id, setId] = useState(operationId), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
|
||||
const target = id.trim()
|
||||
const validId = mcpPageQuerySchema.shape.operationId.safeParse(target).success
|
||||
const run = async (recover: boolean) => {
|
||||
if (busy || !validId) return
|
||||
setBusy(true); setError(""); setResult(null)
|
||||
try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
try { setResult(recover ? await api.recoverOperation(target) : await api.reconcileOperation(target)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений."><ErrorMessage message={error} /><Field label="ID исходной операции"><Input value={id} onChange={event => { setId(event.target.value); setResult(null) }} /></Field><div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy || !id} onClick={() => { void run(false) }}>Сверить состояние операции</Button>{can("mm:mcp:admin") && <Button disabled={busy || !id} onClick={() => { void run(true) }}>Подготовить обратный план</Button>}</div>{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}</Panel>
|
||||
return <section id="mcp-recovery">
|
||||
<Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений.">
|
||||
<ErrorMessage message={error} />
|
||||
<Field label="ID исходной операции"><Input value={id} disabled={busy} aria-invalid={!!target && !validId} onChange={event => { setId(event.target.value); setResult(null); setError("") }} /></Field>
|
||||
{target && !validId && <p className="text-sm text-destructive">Укажите UUID операции из журнала.</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy || !validId} onClick={() => { void run(false) }}>Сверить состояние операции</Button>
|
||||
<Button disabled={busy || !validId} onClick={() => { void run(true) }}>Подготовить обратный план</Button>
|
||||
</div>
|
||||
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}
|
||||
</Panel>
|
||||
</section>
|
||||
}
|
||||
function ManagementChannels({ api }: { api: McpApi }) {
|
||||
const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api]))
|
||||
@@ -646,9 +766,10 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b
|
||||
const params = useSearchParams(), initial = params.get("tab") as Tab | null
|
||||
const oauthRequestId = params.get("oauthRequest")
|
||||
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
|
||||
const recoveryId = params.get("reconcile") ?? ""
|
||||
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
||||
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
|
||||
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
|
||||
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
|
||||
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery key={recoveryId} api={api} operationId={recoveryId} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
|
||||
}
|
||||
@@ -149,6 +149,7 @@ export const mcpSettingsSchema = z.object({
|
||||
export const mcpSettingsPatchSchema = mcpSettingsSchema.partial().strict().refine(value => Object.keys(value).length > 0)
|
||||
export const mcpPageQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
operationId: z.string().uuid().optional(),
|
||||
cursor: z.union([z.string().uuid(), z.string().regex(/^[1-9]\d{0,18}$/)]).optional(),
|
||||
connectionId: z.string().uuid().optional(),
|
||||
status: z.string().max(50).optional(),
|
||||
|
||||
@@ -49,10 +49,16 @@ export function withAuthHeaders(init?: HeadersInit): Headers {
|
||||
return headers
|
||||
}
|
||||
|
||||
function handleUnauthorized(): never {
|
||||
let portalHandoffStarted = false
|
||||
|
||||
function handleUnauthorized(authorization: string | null): never {
|
||||
if (typeof window !== "undefined" && isAuthEnabled()) {
|
||||
const token = getToken()
|
||||
if (!portalHandoffStarted && (!token || authorization === `Bearer ${token}`)) {
|
||||
const ok = redirectToPortalLogin()
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
portalHandoffStarted = true
|
||||
}
|
||||
}
|
||||
throw new ApiClientError("Unauthorized", 401)
|
||||
}
|
||||
@@ -86,7 +92,7 @@ export async function requestJson<T>(
|
||||
headers,
|
||||
})
|
||||
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
if (res.status === 401) handleUnauthorized(headers.get("Authorization"))
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
@@ -116,7 +122,7 @@ export async function requestBlob(
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
if (res.status === 401) handleUnauthorized(headers.get("Authorization"))
|
||||
if (!res.ok) {
|
||||
throw new ApiClientError(await parseErrorMessage(res), res.status)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,65 @@ test('synthetic: bind OAuth consent to the displayed request and submit only onc
|
||||
expect(decisions).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('synthetic: concurrent unauthorized MCP reads hand off to Portal once', async ({ page, baseURL }) => {
|
||||
await setup(page, ['mm:mcp:read'])
|
||||
const portal = 'http://127.0.0.1:3165'
|
||||
const operationPath = `/mcp?tab=plans&operation=${id}`
|
||||
await page.route('**/api/auth/config', route => route.fulfill({ json: { required: true, portal_url: portal } }))
|
||||
let revoked = false, refusals = 0
|
||||
let delayedRefusal: Promise<void> | undefined
|
||||
const headers = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'Authorization' }
|
||||
await page.route(/\/api\/mcp\/(operations|plans|drift)\?/, async route => {
|
||||
if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
|
||||
const selected = new URL(route.request().url()).searchParams.get('operationId') === id
|
||||
if (selected && delayedRefusal) {
|
||||
const wait = delayedRefusal
|
||||
delayedRefusal = undefined
|
||||
await wait
|
||||
return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } })
|
||||
}
|
||||
if (revoked) {
|
||||
refusals++
|
||||
return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } })
|
||||
}
|
||||
return route.fulfill({ headers, json: { items: selected ? [{ id, state: 'applied', steps: [{ step_index: 0, state: 'verified' }] }] : [], total: selected ? 1 : 0, nextCursor: null } })
|
||||
})
|
||||
const handoffs: URL[] = []
|
||||
await page.route(url => url.origin === portal && url.pathname === '/', async route => {
|
||||
handoffs.push(new URL(route.request().url()))
|
||||
await expect.poll(() => refusals).toBeGreaterThan(1)
|
||||
await route.fulfill({ contentType: 'text/html', body: '<h1>Portal sign-in</h1>' })
|
||||
})
|
||||
|
||||
await page.goto(operationPath)
|
||||
const step = page.getByRole('region', { name: 'Шаги выполнения' }).getByText('Результат проверен')
|
||||
await expect(step).toBeVisible()
|
||||
const refresh = page.getByRole('button', { name: 'Обновить выполнение' })
|
||||
let releaseRefusal!: () => void
|
||||
delayedRefusal = new Promise<void>(resolve => { releaseRefusal = resolve })
|
||||
try {
|
||||
await Promise.all([
|
||||
page.waitForRequest(request => request.method() === 'GET' && new URL(request.url()).searchParams.get('operationId') === id),
|
||||
refresh.click(),
|
||||
])
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('mmapp_token', 'header.' + btoa(JSON.stringify({ sub: 'new-session', apps: ['mm'], permissions: ['mm:mcp:read'], exp: 4102444800 })) + '.signature')
|
||||
})
|
||||
} finally { releaseRefusal() }
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible()
|
||||
expect(handoffs).toHaveLength(0)
|
||||
|
||||
revoked = true
|
||||
await refresh.click()
|
||||
await expect(page.getByRole('heading', { name: 'Portal sign-in' })).toBeVisible()
|
||||
expect(handoffs).toHaveLength(1)
|
||||
const callback = new URL(handoffs[0].searchParams.get('return_to')!)
|
||||
expect(callback.origin).toBe(new URL(baseURL!).origin)
|
||||
expect(callback.pathname).toBe('/auth/callback')
|
||||
expect(callback.searchParams.get('next')).toBe(operationPath)
|
||||
await expect(step).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('synthetic: deny OAuth consent without an available connection', async ({ page, baseURL }) => {
|
||||
await setup(page, ['mm:mcp:read'])
|
||||
const requestId = 'fa58fa40-4bcb-4616-9316-cb69463b3151'
|
||||
|
||||
Reference in new issue
Block a user