Skip to content
Closed
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
51 changes: 50 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,52 @@ describe("useAllowedRoutes", () => {
store.loadAttempted = true;
expect(useAllowedRoutes().ready.value).toBe(true);
});

it("canCallAsync awaits the real load itself, 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 { canCall, canCallAsync, ready } = useAllowedRoutes();

// Before the manifest resolves, gating hasn't kicked in yet: this is the fail-open window
// canCall (sync) can't help but expose, and which canCallAsync closes on its own.
expect(ready.value).toBe(false);
expect(canCall(ApiRoutes.retryMessage)).toBe(true);

expect(await canCallAsync(ApiRoutes.retryMessage)).toBe(true);
expect(await canCallAsync(ApiRoutes.viewFailedMessages)).toBe(false);
expect(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);
});
});
24 changes: 23 additions & 1 deletion src/Frontend/src/composables/useAllowedRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,42 @@ 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();
}

// 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. canCallAsync awaits this before checking; no-ops once the manifest load has been
// attempted (or doesn't apply).
async function ensureManifestLoaded(): Promise<void> {
if (!ready.value) {
await fetchManifest();
}
}

// Reactive/template use (v-if, computed): synchronous, fails open until the manifest has loaded.
// Use this for display gating, where a fail-open flash while the manifest loads is acceptable.
// 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 {
return !shouldGate.value || store.routes.has(normalizeRouteKey(entry.method, entry.path));
}

// One-off gate before a network call: awaits the manifest load first, so the check reflects the
// real permission rather than the fail-open default. Callers should await this instead of
// separately awaiting ensureManifestLoaded() then calling canCall().
async function canCallAsync(entry: RouteRef, resource?: object): Promise<boolean> {
await ensureManifestLoaded();
return canCall(entry, resource);
}

function canAnyCall(entries: RouteRef[]): boolean {
return entries.some((e) => canCall(e));
}

return { fetchManifest, canCall, canAnyCall, shouldGate, ready, supported };
return { fetchManifest, ensureManifestLoaded, canCall, canCallAsync, 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
7 changes: 7 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 { canCallAsync } = useAllowedRoutes();

// Computed properties for license formatting
const licenseEdition = computed(() => {
Expand All @@ -59,6 +62,10 @@ export const useLicenseStore = defineStore("LicenseStore", () => {
});

async function refresh() {
if (!(await canCallAsync(ApiRoutes.viewLicense))) {
return;
}

loading.value = true;
try {
const lic = await getLicense();
Expand Down
6 changes: 5 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, canCallAsync } = useAllowedRoutes();
const canManageRedirects = computed(() => canCall(ApiRoutes.manageRedirects));

async function getKnownQueues() {
Expand All @@ -39,6 +39,10 @@ export const useRedirectsStore = defineStore("RedirectsStore", () => {
}

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

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

Expand Down
Loading