From f090e06ec125e442edb6a912298fbf02f9768abb Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 26 Jun 2026 15:56:06 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Add=20API=20route=20registry=20?= =?UTF-8?q?and=20structural=20route-key=20matcher=20for=20UI=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map each gated UI capability to the ServiceControl HTTP route it represents (apiRoutes.ts), and normalize a (method, path) pair into a comparison key with route parameters collapsed to {} (routeMatching.ts). Matching couples only to method and path structure, so it survives server-side route parameter renames. Co-Authored-By: Dennis van der Stelt Co-Authored-By: williambza --- src/Frontend/src/composables/apiRoutes.ts | 37 +++++++++++++++++++ .../src/composables/routeMatching.spec.ts | 14 +++++++ src/Frontend/src/composables/routeMatching.ts | 7 ++++ 3 files changed, 58 insertions(+) create mode 100644 src/Frontend/src/composables/apiRoutes.ts create mode 100644 src/Frontend/src/composables/routeMatching.spec.ts create mode 100644 src/Frontend/src/composables/routeMatching.ts diff --git a/src/Frontend/src/composables/apiRoutes.ts b/src/Frontend/src/composables/apiRoutes.ts new file mode 100644 index 000000000..3ac666ace --- /dev/null +++ b/src/Frontend/src/composables/apiRoutes.ts @@ -0,0 +1,37 @@ +// Maps each gated UI capability to the ServiceControl HTTP route it represents. +// This is the ONLY place coupling ServicePulse to ServiceControl's route surface: +// the UI gates on routes it already calls, never on the internal permission grammar. +// Paths use {} for each route parameter; matching is param-name-insensitive (see routeMatching.ts). +// Each entry tracks a route in ServiceControl's APIApprovals.HttpApiRoutes (Primary or Monitoring instance). +export type RouteRef = { method: string; path: string }; + +export const ApiRoutes = { + // ---- nav / verb-level (GET routes gated by the matching :view permission) ---- + viewHeartbeats: { method: "GET", path: "/api/heartbeats/stats" }, // error:heartbeats:view (EndpointsMonitoringController) + viewMonitoredEndpoints: { method: "GET", path: "/monitored-endpoints" }, // Monitoring instance serves at root (no /api prefix) — monitoring:endpoint:view (DiagramApiController) + viewAuditMessages: { method: "GET", path: "/api/messages2" }, // error:messages:view (GetMessages2Controller) + viewFailedMessages: { method: "GET", path: "/api/errors" }, // error:messages:view (GetAllErrorsController) + viewCustomChecks: { method: "GET", path: "/api/customchecks" }, // error:customchecks:view (CustomCheckController) + viewEventLog: { method: "GET", path: "/api/eventlogitems" }, // error:eventlog:view (EventLogApiController) + viewThroughput: { method: "GET", path: "/api/licensing/report/available" }, // error:throughput:view (LicensingController) + viewLicense: { method: "GET", path: "/api/license" }, // error:licensing:view (LicenseController) + viewConnections: { method: "GET", path: "/api/connection" }, // error:connections:view (ConnectionController) + viewNotifications: { method: "GET", path: "/api/notifications/email" }, // error:notifications:view (NotificationsController) + viewRedirects: { method: "GET", path: "/api/redirects" }, // error:redirects:view (MessageRedirectsController) + viewEndpoints: { method: "GET", path: "/api/endpoints" }, // error:endpoints:view (EndpointsMonitoringController) + manageThroughput: { method: "POST", path: "/api/licensing/settings/masks/update" }, // error:throughput:manage (LicensingController) + // ---- actions ---- + manageNotifications: { method: "POST", path: "/api/notifications/email" }, + testNotifications: { method: "POST", path: "/api/notifications/email/test" }, + manageRedirects: { method: "POST", path: "/api/redirects" }, + dismissCustomCheck: { method: "DELETE", path: "/api/customchecks/{}" }, + retryMessage: { method: "POST", path: "/api/errors/retry" }, + editMessage: { method: "POST", path: "/api/edit/{}" }, + deleteMessage: { method: "PATCH", path: "/api/errors/archive" }, + restoreMessage: { method: "PATCH", path: "/api/errors/unarchive" }, + retryGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/retry" }, + deleteGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/archive" }, + restoreGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/unarchive" }, + deleteEndpointInstance: { method: "DELETE", path: "/api/endpoints/{}" }, + deleteMonitoredEndpoint: { method: "DELETE", path: "/monitored-instance/{}/{}" }, // Monitoring instance serves at root (no /api prefix) +} as const satisfies Record; diff --git a/src/Frontend/src/composables/routeMatching.spec.ts b/src/Frontend/src/composables/routeMatching.spec.ts new file mode 100644 index 000000000..6ebbfd012 --- /dev/null +++ b/src/Frontend/src/composables/routeMatching.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRouteKey } from "@/composables/routeMatching"; + +describe("normalizeRouteKey", () => { + it.each([ + ["POST", "/api/errors/{id}/retry", "POST /api/errors/{}/retry"], + ["post", "/api/errors/{failedMessageId}/retry", "POST /api/errors/{}/retry"], // param-name-insensitive + verb case + ["GET", "/api/errors", "GET /api/errors"], + ["DELETE", "/api/monitored-instance/{name}/{instanceId}", "DELETE /api/monitored-instance/{}/{}"], + ["GET", "api/messages2", "GET /api/messages2"], // adds leading slash + ])("normalizes %s %s", (method, path, expected) => { + expect(normalizeRouteKey(method, path)).toBe(expected); + }); +}); diff --git a/src/Frontend/src/composables/routeMatching.ts b/src/Frontend/src/composables/routeMatching.ts new file mode 100644 index 000000000..64e75ba5f --- /dev/null +++ b/src/Frontend/src/composables/routeMatching.ts @@ -0,0 +1,7 @@ +// Normalizes a (method, path) into a comparison key for allowed-route matching. +// Param names are collapsed to {} so matching couples only to method + path STRUCTURE +// (the stable public contract), surviving server route-parameter renames. +export function normalizeRouteKey(method: string, path: string): string { + const normalizedPath = path.replace(/\{[^}]*\}/g, "{}").replace(/^\/?/, "/"); + return `${method.toUpperCase()} ${normalizedPath}`; +} From 5c5b961926566cb122936fa04ceecd33d6e27a4d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 26 Jun 2026 15:56:06 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20Fetch=20the=20allowed-route=20m?= =?UTF-8?q?anifest=20and=20add=20the=20route-gating=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AllowedRoutesStore, which fetches the my/routes manifest from the Primary and Monitoring instances, merges them, and dedupes concurrent fetches. Add the useAllowedRoutes composable (canCall / canAnyCall) that resource-owning view models build on, plus the startup fetch in App.vue. Gating is fail-open until the user is authenticated and the manifest has loaded, but a malformed manifest entry is skipped rather than aborting the load, so a single bad entry can never leave the store unloaded and silently fail every gate open. Co-Authored-By: Dennis van der Stelt Co-Authored-By: williambza --- src/Frontend/src/App.vue | 17 +++- .../components/monitoring/monitoringClient.ts | 9 ++ .../src/components/serviceControlClient.ts | 9 +- .../src/composables/useAllowedRoutes.spec.ts | 61 +++++++++++++ .../src/composables/useAllowedRoutes.ts | 33 +++++++ .../src/composables/useAuthenticatedFetch.ts | 25 ++++- src/Frontend/src/resources/RootUrls.ts | 2 + .../src/stores/AllowedRoutesStore.spec.ts | 91 +++++++++++++++++++ src/Frontend/src/stores/AllowedRoutesStore.ts | 84 +++++++++++++++++ .../test/preconditions/authentication.ts | 6 ++ 10 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 src/Frontend/src/composables/useAllowedRoutes.spec.ts create mode 100644 src/Frontend/src/composables/useAllowedRoutes.ts create mode 100644 src/Frontend/src/stores/AllowedRoutesStore.spec.ts create mode 100644 src/Frontend/src/stores/AllowedRoutesStore.ts diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index de55293cb..ccaa60e86 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -1,5 +1,5 @@