From 5e31eee5ac2ae16b78c36f2a293cf91994432127 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 3 Jul 2026 10:06:54 +0200 Subject: [PATCH 1/2] Only call license etc. if user has permissions --- .../src/composables/useAllowedRoutes.spec.ts | 28 +++++++++++++++++- .../src/composables/useAllowedRoutes.ts | 12 +++++++- src/Frontend/src/stores/LicenseStore.ts | 8 +++++ src/Frontend/src/stores/RedirectsStore.ts | 7 ++++- .../src/stores/ThroughputStore.spec.ts | 29 +++++++++++++++++-- src/Frontend/src/stores/ThroughputStore.ts | 7 +++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/Frontend/src/composables/useAllowedRoutes.spec.ts b/src/Frontend/src/composables/useAllowedRoutes.spec.ts index 8843a47c4..d6a3e5888 100644 --- a/src/Frontend/src/composables/useAllowedRoutes.spec.ts +++ b/src/Frontend/src/composables/useAllowedRoutes.spec.ts @@ -4,9 +4,10 @@ import { useAllowedRoutesStore } from "@/stores/AllowedRoutesStore"; import { useAuthStore } from "@/stores/AuthStore"; import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; import { ApiRoutes } from "@/composables/apiRoutes"; +import serviceControlClient from "@/components/serviceControlClient"; vi.mock("@/components/serviceControlClient", () => ({ - default: { fetchFromServiceControl: vi.fn(), fetchTypedFromServiceControl: vi.fn() }, + default: { fetchFromServiceControl: vi.fn(), fetchTypedFromServiceControl: vi.fn(), fetchFromUrl: vi.fn() }, })); vi.mock("@/components/monitoring/monitoringClient", () => ({ default: { isMonitoringEnabled: false, fetchAllowedRoutes: vi.fn() }, @@ -58,4 +59,29 @@ describe("useAllowedRoutes", () => { store.loadAttempted = true; expect(useAllowedRoutes().ready.value).toBe(true); }); + + it("ensureManifestLoaded awaits the real load, closing the race where canCall fails open before the manifest arrives", async () => { + const auth = useAuthStore(); + auth.authEnabled = true; + auth.isAuthenticated = true; + + vi.mocked(serviceControlClient.fetchTypedFromServiceControl).mockResolvedValue([{} as Response, { my_routes_url: "http://sc/my-routes" }]); + vi.mocked(serviceControlClient.fetchFromUrl).mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ method: "POST", url_template: "/api/errors/retry" }]), + } as Response); + + const { ensureManifestLoaded, canCall, ready } = useAllowedRoutes(); + + // Before the manifest resolves, gating hasn't kicked in yet: this is the fail-open window a + // caller must not act on. + expect(ready.value).toBe(false); + expect(canCall(ApiRoutes.retryMessage)).toBe(true); + + await ensureManifestLoaded(); + + expect(ready.value).toBe(true); + expect(canCall(ApiRoutes.retryMessage)).toBe(true); + expect(canCall(ApiRoutes.viewFailedMessages)).toBe(false); + }); }); diff --git a/src/Frontend/src/composables/useAllowedRoutes.ts b/src/Frontend/src/composables/useAllowedRoutes.ts index 56508966a..8e58c22fa 100644 --- a/src/Frontend/src/composables/useAllowedRoutes.ts +++ b/src/Frontend/src/composables/useAllowedRoutes.ts @@ -22,6 +22,16 @@ export function useAllowedRoutes() { return store.refresh(); } + // Callers that gate a network call (rather than just a template) on canCall must await this + // first: otherwise a call made before the manifest finishes loading races fetchManifest() and + // fails open (shouldGate is false until store.loaded flips), letting the request through + // regardless of permission. No-ops once the manifest load has been attempted (or doesn't apply). + async function ensureManifestLoaded(): Promise { + if (!ready.value) { + await fetchManifest(); + } + } + // resource: reserved for future resource-level scope (ignored today). See design Future-proofing. // eslint-disable-next-line @typescript-eslint/no-unused-vars function canCall(entry: RouteRef, _resource?: object): boolean { @@ -32,5 +42,5 @@ export function useAllowedRoutes() { return entries.some((e) => canCall(e)); } - return { fetchManifest, canCall, canAnyCall, shouldGate, ready, supported }; + return { fetchManifest, ensureManifestLoaded, canCall, canAnyCall, shouldGate, ready, supported }; } diff --git a/src/Frontend/src/stores/LicenseStore.ts b/src/Frontend/src/stores/LicenseStore.ts index 0a784e706..71fbbe0dc 100644 --- a/src/Frontend/src/stores/LicenseStore.ts +++ b/src/Frontend/src/stores/LicenseStore.ts @@ -5,6 +5,8 @@ import serviceControlClient from "@/components/serviceControlClient"; import { type default as LicenseInfo, LicenseStatus } from "@/resources/LicenseInfo"; import { LicenseWarningLevel } from "@/composables/LicenseStatus"; import { useGetDayDiffFromToday } from "@/composables/formatter"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; export const useLicenseStore = defineStore("LicenseStore", () => { const license = reactive({ @@ -40,6 +42,7 @@ export const useLicenseStore = defineStore("LicenseStore", () => { }); const loading = ref(false); + const { canCall, ensureManifestLoaded } = useAllowedRoutes(); // Computed properties for license formatting const licenseEdition = computed(() => { @@ -59,6 +62,11 @@ export const useLicenseStore = defineStore("LicenseStore", () => { }); async function refresh() { + await ensureManifestLoaded(); + if (!canCall(ApiRoutes.viewLicense)) { + return; + } + loading.value = true; try { const lic = await getLicense(); diff --git a/src/Frontend/src/stores/RedirectsStore.ts b/src/Frontend/src/stores/RedirectsStore.ts index 1cc7dcc5f..63dbc2cde 100644 --- a/src/Frontend/src/stores/RedirectsStore.ts +++ b/src/Frontend/src/stores/RedirectsStore.ts @@ -24,7 +24,7 @@ export const useRedirectsStore = defineStore("RedirectsStore", () => { const { store: environmentStore } = useEnvironmentAndVersionsAutoRefresh(); const hasResponseStatusInHeader = environmentStore.serviceControlIsGreaterThan("5.2.0"); - const { canCall } = useAllowedRoutes(); + const { canCall, ensureManifestLoaded } = useAllowedRoutes(); const canManageRedirects = computed(() => canCall(ApiRoutes.manageRedirects)); async function getKnownQueues() { @@ -39,6 +39,11 @@ export const useRedirectsStore = defineStore("RedirectsStore", () => { } async function refresh() { + await ensureManifestLoaded(); + if (!canCall(ApiRoutes.viewRedirects)) { + return; + } + await Promise.all([getRedirects(), getKnownQueues()]); } diff --git a/src/Frontend/src/stores/ThroughputStore.spec.ts b/src/Frontend/src/stores/ThroughputStore.spec.ts index 0a97893fa..1fa4dbb39 100644 --- a/src/Frontend/src/stores/ThroughputStore.spec.ts +++ b/src/Frontend/src/stores/ThroughputStore.spec.ts @@ -9,11 +9,16 @@ import { setActivePinia, storeToRefs } from "pinia"; import type { Driver } from "../../test/driver"; import { disableMonitoring } from "../../test/drivers/vitest/setup"; import { useEnvironmentAndVersionsStore } from "./EnvironmentAndVersionsStore"; +import type { ManifestEntry } from "@/stores/AllowedRoutesStore"; + +function makeRoutes(keys: string[]): Map { + return new Map(keys.map((k) => [k, { method: "", url_template: "" }])); +} describe("ThroughputStore tests", () => { - async function setup(preSetup: (driver: Driver) => Promise) { + async function setup(preSetup: (driver: Driver) => Promise, initialState?: Record) { const driver = makeDriverForTests(); - setActivePinia(createTestingPinia({ stubActions: false })); + setActivePinia(createTestingPinia({ stubActions: false, initialState })); await preSetup(driver); await driver.setUp(serviceControlWithThroughput); @@ -37,6 +42,26 @@ describe("ThroughputStore tests", () => { expect(hasErrors.value).toBe(false); }); + test("does not call the licensing connection test when lacking permission to view the license", async () => { + let called = false; + const { testResults } = await setup( + async (driver) => { + await driver.setUp(precondition.hasLicensingSettingTest({ transport: Transport.AmazonSQS })); + driver.mockEndpointDynamic(`${window.defaultConfig.service_control_url}licensing/settings/test`, "get", () => { + called = true; + return Promise.resolve({ body: {} }); + }); + }, + { + auth: { authEnabled: true, isAuthenticated: true }, + AllowedRoutesStore: { routes: makeRoutes([]), loaded: true, loadAttempted: true }, + } + ); + + expect(called).toBe(false); + expect(testResults.value).toBe(null); + }); + describe("when transport is a broker", () => { const transport = Transport.AmazonSQS; diff --git a/src/Frontend/src/stores/ThroughputStore.ts b/src/Frontend/src/stores/ThroughputStore.ts index 60e702a07..70d759c44 100644 --- a/src/Frontend/src/stores/ThroughputStore.ts +++ b/src/Frontend/src/stores/ThroughputStore.ts @@ -6,17 +6,24 @@ import { Transport } from "@/views/throughputreport/transport"; import useIsThroughputSupported from "@/views/throughputreport/isThroughputSupported"; import monitoringClient from "@/components/monitoring/monitoringClient"; import logger from "@/logger"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; export const useThroughputStore = defineStore("ThroughputStore", () => { const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; const testResults = ref(null); const isThroughputSupported = useIsThroughputSupported(); const throughputClient = createThroughputClient(); + const { canCall, ensureManifestLoaded } = useAllowedRoutes(); const refresh = async () => { if (!isThroughputSupported.value) { return; } + await ensureManifestLoaded(); + if (!canCall(ApiRoutes.viewLicense)) { + return; + } try { testResults.value = await throughputClient.test(); } catch (error) { From c60d643ff18da0c51b9fc636afef33633e4c8b22 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 3 Jul 2026 10:22:07 +0200 Subject: [PATCH 2/2] Show user roles on permissions tab --- .../configuration/UserPermissions.vue | 29 ++++++++++++++++- .../src/composables/useAllowedRoutes.spec.ts | 2 +- .../src/composables/useAllowedRoutes.ts | 4 ++- .../src/stores/AllowedRoutesStore.spec.ts | 28 +++++++++++------ src/Frontend/src/stores/AllowedRoutesStore.ts | 31 +++++++++++++++---- 5 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/Frontend/src/components/configuration/UserPermissions.vue b/src/Frontend/src/components/configuration/UserPermissions.vue index 4f1eaccbe..9e9a561b8 100644 --- a/src/Frontend/src/components/configuration/UserPermissions.vue +++ b/src/Frontend/src/components/configuration/UserPermissions.vue @@ -93,7 +93,7 @@ import FAIcon from "@/components/FAIcon.vue"; import ConditionalRender from "@/components/ConditionalRender.vue"; import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; -const { canCall, supported } = useAllowedRoutes(); +const { canCall, supported, roles } = useAllowedRoutes(); const rows = computed(() => groups.map((g) => ({ @@ -108,6 +108,11 @@ const rows = computed(() =>

Your permissions

+
+ Roles: + {{ role }} +
+