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