This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new e95a60c  rbac: verb-guard gated routes; OAP chip only links Cluster 
for cluster:read
e95a60c is described below

commit e95a60c87a8c111b399ffbd61411ed2884929d38
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 21:33:23 2026 +0800

    rbac: verb-guard gated routes; OAP chip only links Cluster for cluster:read
    
    A viewer could reach the maintainer-only Cluster Status page via URL or
    the topbar OAP chip — its data comes from the shared `auth`-gated
    /api/oap/info + /api/preflight (used by the topbar/shell for every user,
    so the BFF can't gate them per-page), so the page rendered fully.
    
    Add a router verb guard: routes declare `meta.verb`; an authenticated
    user lacking it is bounced home. Annotate the operate/admin routes
    (cluster, alerting-rules, dsl, inspect, ttl, config, live-debug, and the
    admin pages) with their verbs, mirroring the sidebar gates and the BFF
    route policy. The topbar OAP chip now links to /operate/cluster only for
    cluster:read users; others get a static (non-navigational) health chip.
---
 apps/ui/src/shell/AppTopbar.vue   | 24 +++++++++++++++++++++---
 apps/ui/src/shell/router/index.ts | 33 ++++++++++++++++++++++++++++++++-
 2 files changed, 53 insertions(+), 4 deletions(-)

diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 882fa82..5b02983 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -23,6 +23,7 @@ import { useAlarmCount } from '@/shell/useAlarmCount';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
 import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type 
TimeStep } from '@/controls/timeRange';
 import { useThemeStore, AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
+import { useAuthStore } from '@/state/auth';
 import { useTimeDefaultsStore } from '@/state/timeDefaults';
 
 // Per-user "Save as my default" / "Reset to org default" on the time
@@ -69,6 +70,10 @@ function onThemeChipBlur(e: FocusEvent): void {
 const route = useRoute();
 
 const { info, reachable, tzOffsetLabel, healthState } = useOapInfo();
+const auth = useAuthStore();
+// The Cluster Status page is maintainer-tier; only link the chip there
+// when the user can actually read it (matches the route's verb gate).
+const canViewCluster = computed(() => auth.hasVerb('cluster:read'));
 
 /* Alarm badge — independent 60s timer, rolling 20m window. The
  * badge sits next to OAP / time / refresh because alarms are a
@@ -392,7 +397,13 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
          `controls/debugPanel.ts` + `shell/DebugEventPanel.vue`. -->
     <div class="sw-top-spacer" />
     <div class="sw-top-actions">
-      <RouterLink class="sw-btn oap-chip" :class="`is-${healthState}`" 
:title="oapChipTooltip" to="/operate/cluster">
+      <component
+        :is="canViewCluster ? RouterLink : 'div'"
+        class="sw-btn oap-chip"
+        :class="[`is-${healthState}`, { 'is-static': !canViewCluster }]"
+        :title="oapChipTooltip"
+        v-bind="canViewCluster ? { to: '/operate/cluster' } : {}"
+      >
         <span class="dot" />
         <span v-if="reachable" class="ver">OAP</span>
         <span v-else class="ver">offline</span>
@@ -400,8 +411,9 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
              noise next to the health dot for an operator-rare check.
              The tooltip (`oapChipTooltip`) still surfaces the value
              when reachable, and the Cluster Status page → Query pane
-             shows it prominently. -->
-      </RouterLink>
+             shows it prominently. Non-cluster:read users get a static
+             chip (no link to the maintainer-only Cluster page). -->
+      </component>
       <div ref="timeClusterEl" class="time-cluster">
         <button
           type="button"
@@ -870,6 +882,12 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
   font-size: 10.5px;
   gap: 6px;
 }
+/* Non-clickable health chip for users without cluster:read. */
+.oap-chip.is-static {
+  cursor: default;
+  display: inline-flex;
+  align-items: center;
+}
 .oap-chip .dot {
   width: 6px;
   height: 6px;
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index d75f4d7..edfab5f 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -133,13 +133,18 @@ const shellRoutes: RouteRecordRaw[] = [
   // grouping. Read-only; OAP auto-recovers, no acknowledge / silence.
   { path: 'alarms', name: 'alarms', component: () => 
import('@/features/alarms/AlarmsView.vue') },
   // Cluster
-  { path: 'operate/cluster', component: () => 
import('@/features/operate/cluster/ClusterStatusView.vue') },
+  {
+    path: 'operate/cluster',
+    component: () => 
import('@/features/operate/cluster/ClusterStatusView.vue'),
+    meta: { verb: 'cluster:read' },
+  },
   // Alerting rules — read-only catalog backed by admin /status/alarm/*.
   // Gated on admin-server reachability at the page-body level.
   {
     path: 'operate/alerting-rules',
     name: 'alerting-rules',
     component: () => 
import('@/features/operate/alerting-rules/AlertingRulesView.vue'),
+    meta: { verb: 'alarm-rule:read' },
   },
   // ── DSL Management ─────────────────────────────────────────────────
   // Static sub-routes are declared first so they aren't shadowed by
@@ -150,22 +155,26 @@ const shellRoutes: RouteRecordRaw[] = [
     path: 'operate/dsl/edit',
     name: 'edit',
     component: () => import('@/features/operate/dsl/DslEditorView.vue'),
+    meta: { verb: 'rule:read' },
   },
   {
     path: 'operate/dsl/dump',
     name: 'dump',
     component: () => import('@/features/operate/dsl/DslDumpView.vue'),
+    meta: { verb: 'rule:read' },
   },
   {
     path: 'operate/dsl/:catalog(otel-rules|telegraf-rules|lal|log-mal-rules)',
     name: 'catalog',
     component: () => import('@/features/operate/dsl/DslCatalogView.vue'),
     props: true,
+    meta: { verb: 'rule:read' },
   },
   {
     path: 'operate/oal',
     name: 'oal-catalog',
     component: () => import('@/features/operate/dsl/OalCatalogView.vue'),
+    meta: { verb: 'rule:read' },
   },
   // Inspect — gated on the `inspect` module (and `receiver-runtime-rule`
   // for rule attribution; degrades cleanly to "unknown" attribution
@@ -174,18 +183,21 @@ const shellRoutes: RouteRecordRaw[] = [
     path: 'operate/inspect',
     name: 'inspect',
     component: () => import('@/features/operate/inspect/InspectView.vue'),
+    meta: { verb: 'inspect:read' },
   },
   // Data retention (TTL) — query-port read of getRecordsTTL/getMetricsTTL.
   {
     path: 'operate/ttl',
     name: 'ttl',
     component: () => import('@/features/operate/ttl/TtlView.vue'),
+    meta: { verb: 'ttl:read' },
   },
   // OAP runtime config — admin-port /debugging/config/dump, read-only.
   {
     path: 'operate/config',
     name: 'oap-config',
     component: () => import('@/features/operate/config/ConfigView.vue'),
+    meta: { verb: 'config:read' },
   },
   // Live debugger — gated on `dsl-debugging`. History is local-only
   // (browser localStorage) so it stays useful even when admin is down.
@@ -194,16 +206,19 @@ const shellRoutes: RouteRecordRaw[] = [
     path: 'operate/live-debug/history',
     name: 'debug-history',
     component: () => 
import('@/features/operate/live-debug/DebugHistoryView.vue'),
+    meta: { verb: 'live-debug:read' },
   },
   {
     path: 'operate/live-debug/:tab(mal|lal|oal)?',
     name: 'live-debugger',
     component: () => 
import('@/features/operate/live-debug/LiveDebuggerView.vue'),
+    meta: { verb: 'live-debug:read' },
   },
   // Admin
   {
     path: 'admin/layer-dashboards',
     component: () => 
import('@/features/admin/layer-templates/LayerDashboardsAdmin.vue'),
+    meta: { verb: 'dashboard:read' },
   },
   // Alert page setup — sits under Dashboard setup in the sidebar but
   // routes off the admin tree since it's an operator-only config view.
@@ -211,6 +226,7 @@ const shellRoutes: RouteRecordRaw[] = [
     path: 'admin/alert-page-setup',
     name: 'alert-page-setup',
     component: () => 
import('@/features/admin/alert-page/AlertPageSetupView.vue'),
+    meta: { verb: 'alarm-setup:read' },
   },
   // Global defaults — theme + time-defaults combined. Two OAP singletons
   // edited in one place because they share the "set once and leave" cadence.
@@ -218,26 +234,31 @@ const shellRoutes: RouteRecordRaw[] = [
     path: 'admin/global-defaults',
     name: 'global-defaults',
     component: () => 
import('@/features/admin/global-defaults/GlobalDefaultsAdmin.vue'),
+    meta: { verb: 'setup:read' },
   },
   {
     path: 'admin/overview-templates',
     name: 'overview-templates',
     component: () => 
import('@/features/admin/overview-templates/OverviewTemplatesAdmin.vue'),
+    meta: { verb: 'overview:write' },
   },
   {
     path: 'admin/users',
     name: 'admin-users',
     component: () => import('@/features/admin/users/UsersAdminView.vue'),
+    meta: { verb: 'user:read' },
   },
   {
     path: 'admin/auth-status',
     name: 'admin-auth-status',
     component: () => import('@/features/admin/auth-status/AuthStatusView.vue'),
+    meta: { verb: 'auth:read' },
   },
   {
     path: 'admin/roles',
     name: 'admin-roles',
     component: () => import('@/features/admin/roles/RolesView.vue'),
+    meta: { verb: 'role:read' },
   },
 ];
 
@@ -277,6 +298,16 @@ router.beforeEach(async (to) => {
   if (to.name === 'login' && auth.isAuthenticated) {
     return { path: '/' };
   }
+  // Verb gate: a route may declare `meta.verb`; an authenticated user
+  // without it is bounced home. Defense-in-depth on top of the sidebar
+  // hiding the link and the BFF enforcing the same verb per data route —
+  // it stops a viewer reaching a maintainer page by URL or the topbar
+  // OAP chip (where the page's data comes from shared `auth` endpoints
+  // the BFF can't gate per-page).
+  const requiredVerb = to.meta.verb as string | undefined;
+  if (requiredVerb && auth.isAuthenticated && !auth.hasVerb(requiredVerb)) {
+    return { path: '/' };
+  }
 });
 
 // Every successful navigation posts a single "Navigated to X" line so

Reply via email to