diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 7acf51f8..4bbc0f97 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.6; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.7: применить права backend к каждому запросу dashboard reader и проверить ограниченный JWT; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя. +Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.7; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.8: убрать timeout/partial/error→0 в backend sidebar counts через полные каталоги; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index d34814e3..d4b1d1a7 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.6; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.7; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.4.3.7 — права каждого источника дашборда (2026-10-03) + +- Все запросы useDashboardLive проходят ensureAuthConfig и per-path/method gate до запроса и публикации, включая standalone WireGuard/events. Dashboard:read не выдаёт servers/uptime/filters/BGP/network/traffic rights; fallback diagnostic POST требует servers:write как на backend. Unknown route не получает разрешение по умолчанию. Отозванные источники очищаются перед обновлением. +- Mapper сверяется unit-тестом с реальным backend permissionForRequest для 13 используемых API-путей. Legacy VXLAN GET на backend использует dashboard:read; frontend сохраняет эту политику, не меняет серверные права или сетевые операции. +- 18 browser scenarios dashboard/limited JWT/sidebar/alerts targets, mapper unit, typecheck/lint прошли. Дополнительная общая regression из 11Б.4.4.3.6 завершилась: 107/107 dashboard/GRE/uptime/VXLAN/WireGuard scenarios прошли за 3.4 минуты. Это профильная регрессия общей оболочки, не полная приёмка этапа 12. +- Сам dashboard ещё использует некоторые legacy DTO/нулевые fallback для неизвестных полей; текущий блок исправляет права, не заявляет новую приёмку достоверности всех upstream данных. Реальный RouterOS/PG не проверялся. +- Следующий точный шаг 11Б.4.4.3.8: убрать timeout/partial/error→0 в backend sidebar counts через полные каталоги; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя. + ## Блок 11Б.4.4.3.6 — права и достоверность общей сводки (2026-10-03) - AppSidebar ждёт ensureAuthConfig, проверяет dashboard:read для counts и filters:read для GRE перед запросом и публикацией. Читатели независимы, bounded/abort/revision/source tag не дают старому ответу заменить новый. Badge скрывается при отсутствии права раздела, неизвестное optional поле не превращается в 0, partial GRE сохраняет последний complete count. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 751f9b27..d0b9a99d 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -304,3 +304,7 @@ UI manual unknown retry принят локально: explicit duplicate risk, ### Продолжение 2026-10-03 — 11Б.4.4.3.6 Общие Sidebar/SystemMonitor readers проверяют auth/права, unknown counts не становятся нулями, health независим от summary; API unit/browser9/typecheck/lint прошли. Дополнительная links/dashboard регрессия107 ещё выполняется. Backend count helpers с catch→0 требуют отдельного исправления. Следующий блок 11Б.4.4.3.7: применить права backend к каждому запросу dashboard reader и проверить ограниченный JWT; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя. + +### Продолжение 2026-10-03 — 11Б.4.4.3.7 + +Dashboard per-request permissions принят локально: mapper parity13paths, limited JWT2, browser18/typecheck/lint прошли. Предыдущая regression107 завершилась успешно. Legacy dashboard DTO/fallback не переприняты. Следующий блок 11Б.4.4.3.8: убрать timeout/partial/error→0 в backend sidebar counts через полные каталоги; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 1c3d0b99..73f286b7 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -31,12 +31,21 @@ import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-op import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state" +import {can,ensureAuthConfig} from "@/lib/auth" +import {dashboardRequestPermission} from "@/lib/dashboard-permissions" +async function dashboardRequest(path:string,work:()=>Promise,method="GET"):Promise{ + await ensureAuthConfig();const permission=dashboardRequestPermission(method,path) + if(!can(permission))throw Error("Нет доступа к источнику: "+permission) + const result=await work();if(!can(permission))throw Error("Доступ к источнику отозван: "+permission) + return result +} + const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids" const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - return requestJson(backendUrl, path, init) + return dashboardRequest(path,()=>requestJson(backendUrl,path,init),init?.method??"GET") } } @@ -273,6 +282,13 @@ export function useDashboardLive() { setLoading(true) setInternetPathLoading(true) try { + await ensureAuthConfig();if(!current())return + if(!can("mm:servers:read"))setLiveServers(null) + if(!can("mm:uptime:read"))setLiveProbes(null) + if(!can("mm:bgp:read"))setBgp(null) + if(!can("mm:traffic:read"))setTrafficSeries(null) + if(!can("mm:network:read"))setInternetPath(null) + setOverlayItems(previous=>previous?.filter(item=>can(item.kind==="gre"?"mm:filters:read":item.kind==="wg"?"mm:network:read":"mm:dashboard:read"))??null) const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([ apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"), apiFetch("/api/servers"), @@ -280,7 +296,7 @@ export function useDashboardLive() { apiFetch>("/api/bgp/sessions"), apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"), - listWireGuard(backendUrl), + dashboardRequest("/api/wireguard",()=>listWireGuard(backendUrl)), apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"), apiFetch<{ tunnels?: Array<{ id: string; serverId: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"), ]) @@ -458,7 +474,7 @@ export function useDashboardLive() { const current = () => request === eventsRequest.current setEventsLoading(true) try { - const rows = await listEvents(backendUrl, { limit: 6 }) + const rows = await dashboardRequest("/api/events",()=>listEvents(backendUrl, { limit: 6 })) if (!current()) return setRecentEvents(rows) recordSource("events", null) diff --git a/lib/dashboard-permissions.test.ts b/lib/dashboard-permissions.test.ts new file mode 100644 index 00000000..4ff3cc6f --- /dev/null +++ b/lib/dashboard-permissions.test.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {dashboardRequestPermission} from "./dashboard-permissions" +import {permissionForRequest} from "../backend/src/lib/permissions" +test("dashboard reader gates match real backend permissions including fallback diagnostic POST",()=>{ + for(const [method,path] of [["GET","/api/uptime/overview?range=1h"],["GET","/api/servers"],["GET","/api/filters/rules"],["GET","/api/bgp/sessions"],["GET","/api/internet-path/latest"],["GET","/api/filters/gre-tunnels"],["GET","/api/wireguard"],["GET","/api/traffic/servers?range=1h"],["GET","/api/vxlan"],["GET","/api/events?limit=6"],["GET","/api/servers/1/wan-runtime"],["POST","/api/servers/1/probes/run"],["GET","/api/uptime/speed-probes"]])assert.equal(dashboardRequestPermission(method,path),permissionForRequest(method,path)) + assert.throws(()=>dashboardRequestPermission("GET","/api/unmapped"));assert.throws(()=>dashboardRequestPermission("POST","/api/events")) +}) diff --git a/lib/dashboard-permissions.ts b/lib/dashboard-permissions.ts new file mode 100644 index 00000000..1a2d694c --- /dev/null +++ b/lib/dashboard-permissions.ts @@ -0,0 +1,12 @@ +/** Limited to API paths consumed by useDashboardLive; matches backend request gates. */ +export function dashboardRequestPermission(method:string,path:string):string{ + const pathname=path.split("?")[0],write=method.toUpperCase()!=="GET" + if(pathname.startsWith("/api/servers"))return write?"mm:servers:write":"mm:servers:read" + if(pathname.startsWith("/api/uptime"))return write?"mm:uptime:write":"mm:uptime:read" + if(pathname.startsWith("/api/filters"))return write?"mm:filters:write":"mm:filters:read" + if(pathname.startsWith("/api/bgp"))return write?"mm:bgp:write":"mm:bgp:read" + if(pathname.startsWith("/api/traffic"))return write?"mm:traffic:write":"mm:traffic:read" + if(pathname.startsWith("/api/internet-path")||pathname.startsWith("/api/wireguard"))return write?"mm:network:write":"mm:network:read" + if(!write&&(pathname==="/api/vxlan"||pathname.startsWith("/api/events")))return "mm:dashboard:read" + throw Error("Неизвестный источник дашборда") +} diff --git a/tests/e2e/dashboard-permissions.spec.ts b/tests/e2e/dashboard-permissions.spec.ts new file mode 100644 index 00000000..11f8a2cb --- /dev/null +++ b/tests/e2e/dashboard-permissions.spec.ts @@ -0,0 +1,6 @@ +import {test,expect} from "@playwright/test" +for(const bgp of [false,true])test("Dashboard limited JWT requests only authorized data sources/"+bgp,async({page})=>{ + const permissions=["mm:dashboard:read",...(bgp?["mm:bgp:read"]:[])],token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions,exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture" + await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);const calls:string[]=[] + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:true}});calls.push(path);return r.fulfill({json:path==="/api/vxlan"?{tunnels:[]}:path==="/api/events"?{events:[]}:path==="/api/bgp/sessions"?[{state:"established",prefixesRx:321}]:{}})});await page.goto("/dashboard");const refresh=page.getByRole("button",{name:"Обновить данные",exact:true});await expect(refresh).toBeEnabled();await expect(page.locator('[data-source="servers"]')).toContainText("Нет доступа");await refresh.click();await expect(refresh).toBeEnabled();for(const path of ["/api/servers","/api/uptime/overview","/api/filters/rules","/api/filters/gre-tunnels","/api/internet-path/latest","/api/wireguard","/api/traffic/servers"])expect(calls).not.toContain(path);expect(calls.includes("/api/bgp/sessions")).toBe(bgp);const tile=page.getByRole("link").filter({hasText:"Префиксы BGP"});await expect(tile).toContainText(bgp?"321":"—");expect(calls).toContain("/api/vxlan") +})