Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/Frontend/src/components/configuration/UserPermissions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -108,6 +108,11 @@ const rows = computed(() =>
<div class="box">
<h3>Your permissions</h3>

<div v-if="roles.length" class="user-roles">
<span class="roles-label">Roles:</span>
<span v-for="role in roles" :key="role" class="role-badge">{{ role }}</span>
</div>

<ConditionalRender :supported="supported">
<template #unsupported>
<div class="container not-supported">
Expand Down Expand Up @@ -153,6 +158,28 @@ const rows = computed(() =>
<style scoped>
@import "@/components/notsupported.css";

.user-roles {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}

.roles-label {
font-weight: 600;
color: #6c757d;
}

.role-badge {
padding: 2px 10px;
font-size: 13px;
font-weight: 600;
color: #2a2a2a;
background-color: #e9ecef;
border-radius: 12px;
}

.permissions-table {
border-collapse: collapse;
table-layout: fixed;
Expand Down
28 changes: 27 additions & 1 deletion src/Frontend/src/composables/useAllowedRoutes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down Expand Up @@ -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({ roles: [], routes: [{ 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);
});
});
14 changes: 13 additions & 1 deletion src/Frontend/src/composables/useAllowedRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,23 @@ export function useAllowedRoutes() {
// Whether ServiceControl advertised my_routes_url, i.e. whether this version supports
// reporting the allowed-route manifest at all (independent of whether it loaded successfully).
const supported = computed(() => store.supported);
// The current user's role claims, as reported by the my/routes manifest.
const roles = computed(() => store.roles);

function fetchManifest(): Promise<void> {
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<void> {
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 {
Expand All @@ -32,5 +44,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, roles };
}
28 changes: 18 additions & 10 deletions src/Frontend/src/stores/AllowedRoutesStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ describe("AllowedRoutesStore", () => {
});

it("merges Primary and Monitoring manifests into normalized keys", async () => {
scFetch.mockResolvedValue(ok([{ method: "POST", url_template: "/api/errors/{id}/retry" }]));
monFetch.mockResolvedValue(ok([{ method: "DELETE", url_template: "/api/monitored-instance/{n}/{i}" }]));
scFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "POST", url_template: "/api/errors/{id}/retry" }] }));
monFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "DELETE", url_template: "/api/monitored-instance/{n}/{i}" }] }));
const store = useAllowedRoutesStore();
await store.refresh();
expect(scFetch).toHaveBeenCalledWith(MY_ROUTES_URL);
Expand All @@ -47,16 +47,24 @@ describe("AllowedRoutesStore", () => {
expect(store.loaded).toBe(true);
});

it("merges and deduplicates roles from Primary and Monitoring", async () => {
scFetch.mockResolvedValue(ok({ roles: ["admin", "support"], routes: [] }));
monFetch.mockResolvedValue(ok({ roles: ["support"], routes: [] }));
const store = useAllowedRoutesStore();
await store.refresh();
expect(store.roles.sort()).toEqual(["admin", "support"]);
});

it("fails open per instance: a 404 from one instance contributes nothing but does not throw", async () => {
scFetch.mockResolvedValue(ok([{ method: "GET", url_template: "/api/errors" }]));
scFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "GET", url_template: "/api/errors" }] }));
monFetch.mockResolvedValue({ ok: false, status: 404, json: () => Promise.resolve({}) });
const store = useAllowedRoutesStore();
await store.refresh();
expect(store.routes.has("GET /api/errors")).toBe(true);
expect(store.loadAttempted).toBe(true);
});

it("treats a non-array 200 response body as a failed instance: loaded stays false when both return non-array", async () => {
it("treats a body without a routes array as a failed instance: loaded stays false when both return malformed bodies", async () => {
scFetch.mockResolvedValue(ok({}));
monFetch.mockResolvedValue(ok(null));
const store = useAllowedRoutesStore();
Expand All @@ -79,8 +87,8 @@ describe("AllowedRoutesStore", () => {
// This test pins the cross-repo path contract: the manifest entry the Monitoring
// instance returns must round-trip through normalizeRouteKey to produce the same
// key as the registry uses for viewMonitoredEndpoints.
scFetch.mockResolvedValue(ok([]));
monFetch.mockResolvedValue(ok([{ method: "GET", url_template: "/monitored-endpoints" }]));
scFetch.mockResolvedValue(ok({ roles: [], routes: [] }));
monFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "GET", url_template: "/monitored-endpoints" }] }));
const store = useAllowedRoutesStore();
await store.refresh();
const expectedKey = normalizeRouteKey(ApiRoutes.viewMonitoredEndpoints.method, ApiRoutes.viewMonitoredEndpoints.path);
Expand All @@ -91,8 +99,8 @@ describe("AllowedRoutesStore", () => {
it("skips a malformed entry instead of aborting the load and failing gates open", async () => {
// Regression: an entry without a template once threw inside the merge, leaving the store
// unloaded so every gate failed OPEN. A bad entry must be skipped and the rest still load.
scFetch.mockResolvedValue(ok([{ method: "GET", url_template: "/api/errors" }, { method: "POST" }]));
monFetch.mockResolvedValue(ok([]));
scFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "GET", url_template: "/api/errors" }, { method: "POST" }] }));
monFetch.mockResolvedValue(ok({ roles: [], routes: [] }));
const store = useAllowedRoutesStore();
await store.refresh();
expect(store.loaded).toBe(true);
Expand All @@ -102,7 +110,7 @@ describe("AllowedRoutesStore", () => {

it("skips the primary fetch entirely when the root document omits my_routes_url", async () => {
rootFetch.mockResolvedValue(rootDoc());
monFetch.mockResolvedValue(ok([{ method: "GET", url_template: "/monitored-endpoints" }]));
monFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "GET", url_template: "/monitored-endpoints" }] }));
const store = useAllowedRoutesStore();
await store.refresh();
expect(scFetch).not.toHaveBeenCalled();
Expand All @@ -112,7 +120,7 @@ describe("AllowedRoutesStore", () => {

it("skips the primary fetch and fails open when the root document request itself rejects", async () => {
rootFetch.mockRejectedValue(new Error("network error"));
monFetch.mockResolvedValue(ok([{ method: "GET", url_template: "/monitored-endpoints" }]));
monFetch.mockResolvedValue(ok({ roles: [], routes: [{ method: "GET", url_template: "/monitored-endpoints" }] }));
const store = useAllowedRoutesStore();
await store.refresh();
expect(scFetch).not.toHaveBeenCalled();
Expand Down
31 changes: 25 additions & 6 deletions src/Frontend/src/stores/AllowedRoutesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,21 @@ export interface ManifestEntry {
[k: string]: unknown;
}

// The my/routes payload: the caller's role claims alongside the routes they may invoke
// (ServiceControl's MyRoutesResponse — roles reported once at the top level, not per entry).
interface ManifestResponse {
roles: string[];
routes: ManifestEntry[];
}

// Holds the allowed-route manifest the current token may call, merged from the instances
// ServicePulse calls directly (Primary + Monitoring). A Map (not a Set) preserves each entry so a
// future per-route `scope` field survives (resource-level checks). Keys are normalizeRouteKey().
export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => {
const routes = ref<Map<string, ManifestEntry>>(new Map());
// Role claims reported by the instances, deduplicated (Primary and Monitoring authenticate the
// same token, so their role sets are expected to overlap or match).
const roles = ref<string[]>([]);
const loaded = ref(false);
const loadAttempted = ref(false);
// Whether the primary ServiceControl instance's root document advertised my_routes_url,
Expand All @@ -30,13 +40,16 @@ export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => {
// stale request can never resolve into a different store instance.
let inFlight: Promise<void> | null = null;

async function fetchInstance(get: () => Promise<Response | undefined>): Promise<ManifestEntry[] | null> {
async function fetchInstance(get: () => Promise<Response | undefined>): Promise<ManifestResponse | null> {
try {
const response = await get();
if (!response || !response.ok) return null; // per-instance fail-open
const json = await response.json();
if (!Array.isArray(json)) return null; // guard against non-array bodies (error envelopes, etc.)
return json as ManifestEntry[];
// Guard against malformed bodies (error envelopes, etc.): routes must be an array; roles
// defaults to empty rather than failing the whole instance, since it is supplementary.
if (!json || !Array.isArray(json.routes)) return null;
const entryRoles = Array.isArray(json.roles) ? json.roles.filter((r: unknown): r is string => typeof r === "string") : [];
return { roles: entryRoles, routes: json.routes as ManifestEntry[] };
} catch (error) {
logger.warn("Failed to fetch allowed routes", error);
return null;
Expand Down Expand Up @@ -64,8 +77,9 @@ export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => {
fetchInstance(() => (monitoringClient.isMonitoringEnabled ? monitoringClient.fetchAllowedRoutes() : Promise.resolve(undefined))),
]);
const merged = new Map<string, ManifestEntry>();
for (const list of [primary, monitoring]) {
for (const entry of list ?? []) {
const mergedRoles = new Set<string>();
for (const result of [primary, monitoring]) {
for (const entry of result?.routes ?? []) {
// Skip any entry missing a method or template rather than throwing: a single malformed
// entry must never abort the load, which would leave the store unloaded and silently
// fail every permission gate OPEN (showing actions the user cannot perform).
Expand All @@ -75,8 +89,12 @@ export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => {
}
merged.set(normalizeRouteKey(entry.method, entry.url_template), entry);
}
for (const role of result?.roles ?? []) {
mergedRoles.add(role);
}
}
routes.value = merged;
roles.value = [...mergedRoles];
loaded.value = primary !== null || monitoring !== null;
} finally {
loadAttempted.value = true;
Expand All @@ -90,12 +108,13 @@ export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => {

function clear() {
routes.value = new Map();
roles.value = [];
loaded.value = false;
loadAttempted.value = false;
supported.value = false;
}

return { routes, loaded, loadAttempted, supported, refresh, clear };
return { routes, roles, loaded, loadAttempted, supported, refresh, clear };
});

if (import.meta.hot) {
Expand Down
8 changes: 8 additions & 0 deletions src/Frontend/src/stores/LicenseStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LicenseInfo>({
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion src/Frontend/src/stores/RedirectsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -39,6 +39,11 @@ export const useRedirectsStore = defineStore("RedirectsStore", () => {
}

async function refresh() {
await ensureManifestLoaded();
if (!canCall(ApiRoutes.viewRedirects)) {
return;
}

await Promise.all([getRedirects(), getKnownQueues()]);
}

Expand Down
29 changes: 27 additions & 2 deletions src/Frontend/src/stores/ThroughputStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ManifestEntry> {
return new Map(keys.map((k) => [k, { method: "", url_template: "" }]));
}

describe("ThroughputStore tests", () => {
async function setup(preSetup: (driver: Driver) => Promise<void>) {
async function setup(preSetup: (driver: Driver) => Promise<void>, initialState?: Record<string, unknown>) {
const driver = makeDriverForTests();
setActivePinia(createTestingPinia({ stubActions: false }));
setActivePinia(createTestingPinia({ stubActions: false, initialState }));

await preSetup(driver);
await driver.setUp(serviceControlWithThroughput);
Expand All @@ -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;

Expand Down
Loading