From c81e574957abb5ddb71f3c67ffbf3aadaebd5ffd Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Tue, 23 Jun 2026 12:01:11 +0200 Subject: [PATCH 01/25] =?UTF-8?q?=E2=9C=A8=20Add=20permission-based=20UI?= =?UTF-8?q?=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate UI on the current user's permissions, fetched from ServiceControl's GET api/my/permissions/all (a flat list of permission strings such as "error:messages:retry"; see ServiceControl PR #5538). - PermissionsStore: holds the user and a Set of granted permissions. - usePermissions: can(permission)/canAny(permissions) (set membership) plus fetchDescriptor() (deduped, fail-safe). Gating is fail-open — it only applies once authorization is enabled, the user is authenticated and the descriptor has loaded, so the UI is unchanged for auth-disabled/older deployments. No UI consumes this yet. --- .../src/composables/usePermissions.spec.ts | 131 ++++++++++++++++++ .../src/composables/usePermissions.ts | 60 ++++++++ src/Frontend/src/stores/PermissionsStore.ts | 37 +++++ 3 files changed, 228 insertions(+) create mode 100644 src/Frontend/src/composables/usePermissions.spec.ts create mode 100644 src/Frontend/src/composables/usePermissions.ts create mode 100644 src/Frontend/src/stores/PermissionsStore.ts diff --git a/src/Frontend/src/composables/usePermissions.spec.ts b/src/Frontend/src/composables/usePermissions.spec.ts new file mode 100644 index 000000000..96c6cf4fa --- /dev/null +++ b/src/Frontend/src/composables/usePermissions.spec.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; + +vi.mock("@/components/serviceControlClient", () => ({ + default: { fetchFromServiceControl: vi.fn() }, +})); + +import serviceControlClient from "@/components/serviceControlClient"; +import logger from "@/logger"; +import { usePermissions } from "@/composables/usePermissions"; +import { usePermissionsStore } from "@/stores/PermissionsStore"; +import { useAuthStore } from "@/stores/AuthStore"; + +const fetchFromServiceControl = vi.mocked(serviceControlClient.fetchFromServiceControl); + +// Minimal Response-like stub for the bits the composable reads. +function response(status: number, body?: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: `status ${status}`, + json: () => Promise.resolve(body), + } as Response; +} + +function withState(opts: { authEnabled: boolean; isAuthenticated: boolean; permissions: string[] | null }) { + const auth = useAuthStore(); + auth.authEnabled = opts.authEnabled; + auth.isAuthenticated = opts.isAuthenticated; + if (opts.permissions !== null) { + usePermissionsStore().setDescriptor({ user: "alice", permissions: opts.permissions }); + } + return usePermissions(); +} + +describe("usePermissions", () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })); + fetchFromServiceControl.mockReset(); + }); + + describe("can / canAny (fail-open gating)", () => { + test("fails open when authorization is disabled", () => { + const { can, shouldGate } = withState({ authEnabled: false, isAuthenticated: true, permissions: [] }); + expect(shouldGate.value).toBe(false); + expect(can("error:messages:retry")).toBe(true); + }); + + test("fails open when the user is not authenticated", () => { + const { can } = withState({ authEnabled: true, isAuthenticated: false, permissions: [] }); + expect(can("error:messages:retry")).toBe(true); + }); + + test("fails open until the descriptor has loaded", () => { + const { can } = withState({ authEnabled: true, isAuthenticated: true, permissions: null }); + expect(can("error:messages:retry")).toBe(true); + }); + + test("gates per permission once enabled, authenticated and loaded", () => { + const { can, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, permissions: ["error:messages:view"] }); + expect(shouldGate.value).toBe(true); + expect(can("error:messages:view")).toBe(true); + expect(can("error:messages:retry")).toBe(false); + }); + + test("canAny is true when any permission is held", () => { + const { canAny } = withState({ authEnabled: true, isAuthenticated: true, permissions: ["error:redirects:view"] }); + expect(canAny(["error:licensing:view", "error:redirects:view"])).toBe(true); + expect(canAny(["error:licensing:view", "error:notifications:view"])).toBe(false); + }); + }); + + describe("fetchDescriptor", () => { + test("200 fetches my/permissions/all and populates the store", async () => { + const { fetchDescriptor } = withState({ authEnabled: true, isAuthenticated: true, permissions: null }); + fetchFromServiceControl.mockResolvedValue(response(200, { user: "alice", permissions: ["error:messages:view"] })); + + await fetchDescriptor(); + + expect(fetchFromServiceControl).toHaveBeenCalledWith("my/permissions/all"); + const store = usePermissionsStore(); + expect(store.loaded).toBe(true); + expect(store.permissions.has("error:messages:view")).toBe(true); + }); + + test("a non-OK response leaves the store intact and warns", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const { fetchDescriptor } = withState({ authEnabled: true, isAuthenticated: true, permissions: ["error:messages:view"] }); + fetchFromServiceControl.mockResolvedValue(response(401)); + + await fetchDescriptor(); + + expect(usePermissionsStore().permissions.has("error:messages:view")).toBe(true); // unchanged + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + test("a thrown request is logged and leaves the store intact", async () => { + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + const failure = new Error("boom"); + const { fetchDescriptor } = withState({ authEnabled: true, isAuthenticated: true, permissions: null }); + fetchFromServiceControl.mockRejectedValue(failure); + + await fetchDescriptor(); + + expect(usePermissionsStore().loaded).toBe(false); + expect(error).toHaveBeenCalledWith("Error fetching permissions", failure); + error.mockRestore(); + }); + + test("concurrent calls share a single in-flight request", async () => { + let resolveFetch: (value: Response) => void = () => {}; + const pending = new Promise((resolve) => (resolveFetch = resolve)); + fetchFromServiceControl.mockReturnValue(pending); + + const { fetchDescriptor } = withState({ authEnabled: true, isAuthenticated: true, permissions: null }); + const first = fetchDescriptor(); + const second = fetchDescriptor(); + + expect(fetchFromServiceControl).toHaveBeenCalledTimes(1); + + resolveFetch(response(200, { user: "alice", permissions: [] })); + await Promise.all([first, second]); + + // Slot released after settling, so a later call fetches again. + await fetchDescriptor(); + expect(fetchFromServiceControl).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/Frontend/src/composables/usePermissions.ts b/src/Frontend/src/composables/usePermissions.ts new file mode 100644 index 000000000..973f36f27 --- /dev/null +++ b/src/Frontend/src/composables/usePermissions.ts @@ -0,0 +1,60 @@ +import { computed } from "vue"; +import { storeToRefs } from "pinia"; +import { usePermissionsStore } from "@/stores/PermissionsStore"; +import { useAuthStore } from "@/stores/AuthStore"; +import serviceControlClient from "@/components/serviceControlClient"; +import logger from "@/logger"; + +// Module-singleton in-flight guard so concurrent callers (e.g. an app-level load and a +// view's onMounted) share one request instead of fetching the descriptor twice. +let inFlight: Promise | null = null; + +// Permission-aware composable. Consumers (button v-ifs, nav filters) are UX-only: they +// hide what the user cannot do. ServiceControl remains the real authority; the descriptor +// this reads is a cache of the effective permission set the server would enforce. +// +// Gating is fail-open: it only kicks in once authorization is enabled, the user is +// authenticated and the descriptor has loaded. Otherwise everything is shown, so the UI +// is unchanged for auth-disabled and older-ServiceControl deployments. +export function usePermissions() { + const store = usePermissionsStore(); + const { authEnabled, isAuthenticated } = storeToRefs(useAuthStore()); + + const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && store.loaded); + + async function load(): Promise { + try { + const response = await serviceControlClient.fetchFromServiceControl("my/permissions/all"); + + // Leave any existing descriptor intact on failure (fail-safe — don't widen or drop + // the user's permissions because of a transient error or a 401), and log it. + if (!response.ok) { + logger.warn(`Failed to fetch permissions: ${response.status} ${response.statusText}`); + return; + } + + store.setDescriptor(await response.json()); + } catch (error) { + logger.error("Error fetching permissions", error); + } + } + + // Fetch (or join an in-flight fetch of) the current user's permissions. + function fetchDescriptor(): Promise { + return (inFlight ??= load().finally(() => (inFlight = null))); + } + + // Whether the current user holds `permission` (e.g. "error:messages:retry"). + // Fail-open until gating applies. + function can(permission: string): boolean { + return !shouldGate.value || store.permissions.has(permission); + } + + // Whether the user holds ANY of the given permissions. Handy for showing a parent nav + // item when the user can reach at least one child. + function canAny(permissions: string[]): boolean { + return permissions.some((permission) => can(permission)); + } + + return { fetchDescriptor, can, canAny, shouldGate }; +} diff --git a/src/Frontend/src/stores/PermissionsStore.ts b/src/Frontend/src/stores/PermissionsStore.ts new file mode 100644 index 000000000..3f40320df --- /dev/null +++ b/src/Frontend/src/stores/PermissionsStore.ts @@ -0,0 +1,37 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref } from "vue"; + +// The JSON shape returned by GET api/my/permissions/all (snake_case on the wire). +// Permissions is a flat list of permission strings, e.g. "error:messages:retry". +// (ServiceControl has no per-resource scopes yet; when it gains them this descriptor +// will grow a scope per entry — see the dormant permissionMatching.ts.) +export interface PermissionsDescriptor { + user: string; + permissions: string[]; +} + +// Holds the current user's effective permission set. Pure state only: fetching lives +// in usePermissions. A Set gives O(1) membership checks for can(). +export const usePermissionsStore = defineStore("PermissionsStore", () => { + const user = ref(""); + const permissions = ref>(new Set()); + const loaded = ref(false); + + function setDescriptor(descriptor: PermissionsDescriptor) { + user.value = descriptor.user; + permissions.value = new Set(descriptor.permissions); + loaded.value = true; + } + + function clear() { + user.value = ""; + permissions.value = new Set(); + loaded.value = false; + } + + return { user, permissions, loaded, setDescriptor, clear }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(usePermissionsStore, import.meta.hot)); +} From e5a3abaedbfc812c44d434764fedf00e49ceedf6 Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Tue, 23 Jun 2026 12:01:11 +0200 Subject: [PATCH 02/25] =?UTF-8?q?=E2=9C=A8=20Add=20dormant=20resource-scop?= =?UTF-8?q?e=20matcher=20for=20future=20per-queue=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not wired into the UI. ServiceControl does not yet support per-resource (per-queue) scopes — limiting a permission to queues like "sales.*" — so this is kept ready for when it does, at which point can(permission, resource) can enforce a grant's allow/deny patterns. - permissionMatching: pure matchesPattern/entryPermits (exact, prefix.*, *, deny-wins, case-insensitive), self-contained. - scope-vectors.json: shared test vectors so the frontend matcher and the future ServiceControl ResourceScope/FilterByQueueScope cannot drift; both suites run the same cases. --- .../composables/permissionMatching.spec.ts | 55 +++++++++++++++++++ .../src/composables/permissionMatching.ts | 52 ++++++++++++++++++ .../src/composables/scope-vectors.json | 20 +++++++ 3 files changed, 127 insertions(+) create mode 100644 src/Frontend/src/composables/permissionMatching.spec.ts create mode 100644 src/Frontend/src/composables/permissionMatching.ts create mode 100644 src/Frontend/src/composables/scope-vectors.json diff --git a/src/Frontend/src/composables/permissionMatching.spec.ts b/src/Frontend/src/composables/permissionMatching.spec.ts new file mode 100644 index 000000000..7b175aafe --- /dev/null +++ b/src/Frontend/src/composables/permissionMatching.spec.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "vitest"; +import { matchesPattern, entryPermits, type PermissionEntry } from "@/composables/permissionMatching"; +import scopeVectors from "@/composables/scope-vectors.json"; + +describe("permissionMatching", () => { + // Shared vectors: these MUST produce the same result in the ServiceControl + // ResourceScope/FilterByQueueScope tests. entryPermits with a resource is the + // frontend equivalent of the server's ResourceScope.Permits. + describe("scope-vectors.json (shared with ServiceControl)", () => { + test.each(scopeVectors.vectors)("$name", ({ allow, deny, resource, permits }) => { + const entry: PermissionEntry = { permission: "messages:view", scope: { allow, deny } }; + expect(entryPermits(entry, resource)).toBe(permits); + }); + }); + + describe("matchesPattern", () => { + test("universal wildcard matches everything", () => { + expect(matchesPattern("*", "anything")).toBe(true); + }); + + test("prefix crosses dots but excludes the bare prefix", () => { + expect(matchesPattern("sales.*", "sales.orders.eu")).toBe(true); + expect(matchesPattern("sales.*", "sales")).toBe(false); + }); + + test("is case-insensitive on both sides", () => { + expect(matchesPattern("Sales.Orders", "sales.orders")).toBe(true); + expect(matchesPattern("SALES.*", "sales.orders")).toBe(true); + }); + }); + + describe("entryPermits scope semantics", () => { + test("null scope is unrestricted for any resource", () => { + const entry: PermissionEntry = { permission: "messages:retry", scope: null }; + expect(entryPermits(entry, "anything")).toBe(true); + }); + + test("null scope passes the verb-level check", () => { + const entry: PermissionEntry = { permission: "messages:retry", scope: null }; + expect(entryPermits(entry)).toBe(true); + }); + + test("verb-level check (no resource) passes for a scoped entry", () => { + // Holding the permission for *some* scope counts at the verb level. + const entry: PermissionEntry = { permission: "messages:retry", scope: { allow: ["sales.*"], deny: [] } }; + expect(entryPermits(entry)).toBe(true); + }); + + test("resource-level check enforces the scope", () => { + const entry: PermissionEntry = { permission: "messages:retry", scope: { allow: ["sales.*"], deny: [] } }; + expect(entryPermits(entry, "sales.orders")).toBe(true); + expect(entryPermits(entry, "finance.invoicing")).toBe(false); + }); + }); +}); diff --git a/src/Frontend/src/composables/permissionMatching.ts b/src/Frontend/src/composables/permissionMatching.ts new file mode 100644 index 000000000..4a5918c65 --- /dev/null +++ b/src/Frontend/src/composables/permissionMatching.ts @@ -0,0 +1,52 @@ +// DORMANT — not currently wired into the UI. ServiceControl does not yet expose +// per-resource (per-queue) scopes; today's my/permissions/all returns a flat list of +// permission strings (see usePermissions/PermissionsStore). This module is kept ready +// for when the backend gains allow/deny scopes, at which point a PermissionEntry will +// carry a ScopeDescriptor and can(permission, resource) can enforce it. + +// A scope's allow/deny resource patterns. null (on an entry) means unrestricted. +export interface ScopeDescriptor { + allow: string[]; + deny: string[]; +} + +// A single scoped permission grant (the future descriptor entry shape). +export interface PermissionEntry { + permission: string; + scope: ScopeDescriptor | null; +} + +// Resource-scope pattern matching. This MUST stay in lockstep with the +// ServiceControl server-side rules: `ResourceScope.Matches` (C#) and +// `FilterByQueueScope` (RavenDB query translation). The shared behaviour is +// pinned by scope-vectors.json, which both this suite and the ServiceControl +// test suite consume. +// +// Patterns: +// "*" matches everything +// "prefix.*" prefix match crossing dots: "sales.*" matches "sales.orders" +// and "sales.secret.payroll", but NOT bare "sales" +// exact case-insensitive equality +// +// Matching is case-insensitive: the server lowercases both sides (queue +// addresses are stored lowercased in the index), so we do the same here. +export function matchesPattern(pattern: string, resource: string): boolean { + const p = pattern.toLowerCase(); + const r = resource.toLowerCase(); + if (p === "*") return true; + if (p === r) return true; + if (p.endsWith(".*")) return r.startsWith(p.slice(0, -1)); // "sales.*" -> "sales." + return false; +} + +// Whether a single permission entry permits access. +// scope null -> unrestricted (any resource; verb-level true) +// resource undefined -> verb-level check: holding the permission counts +// resource provided -> allow AND NOT deny, deny wins +export function entryPermits(entry: PermissionEntry, resource?: string): boolean { + if (entry.scope === null) return true; + if (resource === undefined) return true; + const allowed = entry.scope.allow.some((pattern) => matchesPattern(pattern, resource)); + const denied = entry.scope.deny.some((pattern) => matchesPattern(pattern, resource)); + return allowed && !denied; +} diff --git a/src/Frontend/src/composables/scope-vectors.json b/src/Frontend/src/composables/scope-vectors.json new file mode 100644 index 000000000..29e0788b2 --- /dev/null +++ b/src/Frontend/src/composables/scope-vectors.json @@ -0,0 +1,20 @@ +{ + "description": "Shared resource-scope test vectors. Consumed by ServicePulse (permissionMatching.spec.ts) and, later, by ServiceControl (ResourceScope/FilterByQueueScope tests) so all implementations of the matcher agree. permits = does scope {allow,deny} permit `resource`.", + "vectors": [ + { "name": "exact allow matches", "allow": ["sales.orders"], "deny": [], "resource": "sales.orders", "permits": true }, + { "name": "exact allow does not match other", "allow": ["sales.orders"], "deny": [], "resource": "finance.invoicing", "permits": false }, + { "name": "prefix matches child", "allow": ["sales.*"], "deny": [], "resource": "sales.orders", "permits": true }, + { "name": "prefix matches deeper child", "allow": ["sales.*"], "deny": [], "resource": "sales.secret.payroll", "permits": true }, + { "name": "prefix does not match bare prefix", "allow": ["sales.*"], "deny": [], "resource": "sales", "permits": false }, + { "name": "universal wildcard matches anything", "allow": ["*"], "deny": [], "resource": "anything.at.all", "permits": true }, + { "name": "empty allow denies everything", "allow": [], "deny": [], "resource": "sales.orders", "permits": false }, + { "name": "deny prefix wins over allow wildcard", "allow": ["*"], "deny": ["finance.*"], "resource": "finance.accounts", "permits": false }, + { "name": "deny exact denies only exact", "allow": ["*"], "deny": ["finance"], "resource": "finance.payroll", "permits": true }, + { "name": "deny exact matches exact", "allow": ["*"], "deny": ["finance"], "resource": "finance", "permits": false }, + { "name": "carve-out: allow prefix, deny sub-prefix (public allowed)", "allow": ["sales.*"], "deny": ["sales.secret.*"], "resource": "sales.public.orders", "permits": true }, + { "name": "carve-out: allow prefix, deny sub-prefix (secret denied)", "allow": ["sales.*"], "deny": ["sales.secret.*"], "resource": "sales.secret.data", "permits": false }, + { "name": "case-insensitive prefix pattern", "allow": ["Sales.*"], "deny": [], "resource": "sales.orders", "permits": true }, + { "name": "case-insensitive exact pattern", "allow": ["Finance.Payroll"], "deny": [], "resource": "finance.payroll", "permits": true }, + { "name": "case-insensitive deny pattern", "allow": ["*"], "deny": ["Finance.*"], "resource": "finance.accounts", "permits": false } + ] +} From c6513f6edc6a2a88011a4629ae25d2e3fa75a88e Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Tue, 23 Jun 2026 13:50:26 +0200 Subject: [PATCH 03/25] =?UTF-8?q?=F0=9F=90=9B=20Handle=20errors=20in=20Thr?= =?UTF-8?q?oughputStore=20auto-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh() runs from a watch/auto-refresh, so a rejected throughput connection test (ServiceControl unreachable, or the auth token cleared during logout) surfaced as an unhandled promise rejection. Catch and log instead. --- src/Frontend/src/stores/ThroughputStore.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Frontend/src/stores/ThroughputStore.ts b/src/Frontend/src/stores/ThroughputStore.ts index e90b48cc4..60e702a07 100644 --- a/src/Frontend/src/stores/ThroughputStore.ts +++ b/src/Frontend/src/stores/ThroughputStore.ts @@ -5,6 +5,7 @@ import createThroughputClient from "@/views/throughputreport/throughputClient"; import { Transport } from "@/views/throughputreport/transport"; import useIsThroughputSupported from "@/views/throughputreport/isThroughputSupported"; import monitoringClient from "@/components/monitoring/monitoringClient"; +import logger from "@/logger"; export const useThroughputStore = defineStore("ThroughputStore", () => { const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; @@ -13,8 +14,15 @@ export const useThroughputStore = defineStore("ThroughputStore", () => { const throughputClient = createThroughputClient(); const refresh = async () => { - if (isThroughputSupported.value) { + if (!isThroughputSupported.value) { + return; + } + try { testResults.value = await throughputClient.test(); + } catch (error) { + // This runs on a watch/auto-refresh, so a rejection here would be unhandled. Swallow + // and log (e.g. ServiceControl unreachable, or the token cleared during logout). + logger.error("Failed to run throughput connection test", error); } }; From 0ddcdfdc0c69623e194c6e5ed1eadb0ff877c85e Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Tue, 23 Jun 2026 13:50:26 +0200 Subject: [PATCH 04/25] =?UTF-8?q?=E2=9C=A8=20Gate=20nav=20menu=20on=20user?= =?UTF-8?q?=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageHeader now shows each nav item only when the user holds the matching ServiceControl permission (e.g. Heartbeats -> error:heartbeats:view, Throughput -> error:throughput:view, Monitoring -> monitoring:endpoint:view, Audit -> audit:message:view), replacing the coarse 7-bool usePermissionGate. - App.vue loads my/permissions/all once authenticated. - usePermissions exposes ready (gate-on-ready): the nav renders nothing until permissions are known, so items never appear and then disappear. Fail-open on auth-disabled / failed load (loadAttempted settles, can() permits). - Move the descriptor fetch + in-flight dedupe into PermissionsStore so each Pinia instance owns it; a module-level singleton leaked a stale request across app re-mounts, leaving ready stuck false. - Tests: hasUserPermissions precondition (mocks my/permissions/all, granted by default when auth is enabled); ready unit tests. ConfigurationView still uses the old summary path until the config-tab step (F5). --- src/Frontend/src/App.vue | 16 +++++ src/Frontend/src/components/PageHeader.vue | 35 ++++++----- .../src/composables/usePermissions.spec.ts | 32 ++++++++++ .../src/composables/usePermissions.ts | 32 +++------- src/Frontend/src/stores/PermissionsStore.ts | 43 +++++++++++-- .../test/preconditions/authentication.ts | 63 +++++++++++++++++++ 6 files changed, 177 insertions(+), 44 deletions(-) diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 567f2973e..a133bf6f8 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -10,6 +10,7 @@ import { storeToRefs } from "pinia"; import { useAuthStore } from "@/stores/AuthStore"; import { useUserPermissionsStore } from "@/stores/UserPermissionsStore"; import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; +import { usePermissions } from "@/composables/usePermissions"; const authStore = useAuthStore(); const route = useRoute(); @@ -27,6 +28,21 @@ watch( { immediate: true } ); +// Load the user's effective permissions (my/permissions/all) once authenticated, so the +// nav and (later) other UI can gate on them. Fail-safe: a missing/old endpoint just leaves +// permissions unloaded and the UI fails open. (The summary fetch above is still used by +// ConfigurationView and is removed in F5 once everything reads usePermissions.) +const { fetchDescriptor } = usePermissions(); +watch( + [authEnabled, isAuthenticated], + ([enabled, authenticated]) => { + if (enabled && authenticated) { + fetchDescriptor(); + } + }, + { immediate: true } +); + // Check if the current route allows anonymous access (e.g., logged-out page) const isAnonymousRoute = computed(() => route.meta?.allowAnonymous === true); const shouldShowApp = computed(() => !authEnabled.value || isAuthenticated.value || isAnonymousRoute.value); diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue index b5c810211..fdb03e618 100644 --- a/src/Frontend/src/components/PageHeader.vue +++ b/src/Frontend/src/components/PageHeader.vue @@ -15,7 +15,7 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue"; import monitoringClient from "@/components/monitoring/monitoringClient"; import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue"; import { useAuthStore } from "@/stores/AuthStore"; -import usePermissionGate from "@/composables/usePermissionGate"; +import { usePermissions } from "@/composables/usePermissions"; import { storeToRefs } from "pinia"; const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; @@ -23,22 +23,27 @@ const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; const authStore = useAuthStore(); const { authEnabled, isAuthenticated } = storeToRefs(authStore); -const { has } = usePermissionGate(); +const { can, ready } = usePermissions(); +// Each item gates on the specific permission ServiceControl enforces for that area. +// Gate-on-ready: render nothing until permissions are known, so items never appear and +// then disappear. can() fails open, so auth-disabled / failed-load shows everything. // prettier-ignore -const menuItems = computed( - () => [ - DashboardMenuItem, - ...(has("failed_messages_read") ? [HeartbeatsMenuItem] : []), - ...(isMonitoringEnabled && has("monitoring_read") ? [MonitoringMenuItem] : []), - ...(has("auditing_read") ? [AuditMenuItem] : []), - ...(has("failed_messages_read") ? [FailedMessagesMenuItem] : []), - ...(has("failed_messages_read") ? [CustomChecksMenuItem] : []), - ...(has("failed_messages_read") ? [EventsMenuItem] : []), - ...(has("admin_read") ? [ThroughputMenuItem] : []), - ConfigurationMenuItem, - FeedbackButton, -]); +const menuItems = computed(() => { + if (!ready.value) return []; + return [ + DashboardMenuItem, + ...(can("error:heartbeats:view") ? [HeartbeatsMenuItem] : []), + ...(isMonitoringEnabled && can("monitoring:endpoint:view") ? [MonitoringMenuItem] : []), + ...(can("audit:message:view") ? [AuditMenuItem] : []), + ...(can("error:messages:view") ? [FailedMessagesMenuItem] : []), + ...(can("error:customchecks:view") ? [CustomChecksMenuItem] : []), + ...(can("error:eventlog:view") ? [EventsMenuItem] : []), + ...(can("error:throughput:view") ? [ThroughputMenuItem] : []), + ConfigurationMenuItem, + FeedbackButton, + ]; +});