fix(ui): проверять права каждого источника дашборда

This commit is contained in:
Denozordec committed 2026-10-03 19:38:30 +07:00
1 parent 1999e8160e
commit 587dd94f82
7 files changed
+59 -5

No files matched your search

+1 -1
View File
@@ -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).
+9 -1
View File
@@ -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.
+4
View File
@@ -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 требует отдельного сценария остановки отправителя.
+19 -3
View File
@@ -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<T>(path:string,work:()=>Promise<T>,method="GET"):Promise<T>{
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<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
return dashboardRequest(path,()=>requestJson<T>(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<BackendServerRow[]>("/api/servers"),
@@ -280,7 +296,7 @@ export function useDashboardLive() {
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/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)
+8
View File
@@ -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"))
})
+12
View File
@@ -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("Неизвестный источник дашборда")
}
+6
View File
@@ -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")
})