Skip to content
Merged
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
30 changes: 29 additions & 1 deletion src/Frontend/src/components/configuration/UserPermissions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const groups: ApplicationCapabilityGroup[] = [
capabilities: [
{ label: "View", ref: ApiRoutes.viewEndpoints },
{ label: "View heartbeats", ref: ApiRoutes.viewHeartbeats },
{ label: "Manage heartbeat tracking", ref: ApiRoutes.manageEndpointSettings },
{ label: "Remove instance", ref: ApiRoutes.deleteEndpointInstance },
],
},
Expand All @@ -93,7 +94,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 +109,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 +159,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
29 changes: 19 additions & 10 deletions src/Frontend/src/components/heartbeats/HeartbeatConfiguration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ import ResultsCount from "../ResultsCount.vue";
import FAIcon from "@/components/FAIcon.vue";
import { faCloud, faServer } from "@fortawesome/free-solid-svg-icons";
import useHeartbeatsStoreAutoRefresh from "@/composables/useHeartbeatsStoreAutoRefresh";
import PermissionGate from "@/components/PermissionGate.vue";

enum Operation {
Track = "track",
DoNotTrack = "do not track",
}

const manageDeniedTooltip = "You don't have permission to manage heartbeat tracking.";

const { store } = useHeartbeatsStoreAutoRefresh();
const { sortedEndpoints, filteredEndpoints, defaultTrackingInstancesValue } = storeToRefs(store);
const { sortedEndpoints, filteredEndpoints, defaultTrackingInstancesValue, canManageEndpointSettings } = storeToRefs(store);
const showBulkWarningDialog = ref(false);
const dialogWarningOperation = ref(Operation.Track);

Expand Down Expand Up @@ -70,14 +73,18 @@ async function toggleDefaultSetting() {
<div class="row filters">
<div class="col-sm-12">
<span class="buttonsContainer">
<button type="button" class="btn btn-default btn-sm" :disabled="filteredEndpoints.length === 0" @click="showBulkOperationWarningDialog(Operation.Track)">
<FAIcon :icon="faServer" class="icon" :class="{ 'text-black': filteredEndpoints.length > 0 }" />
Track Instances on All Endpoints
</button>
<button type="button" class="btn btn-default btn-sm" :disabled="filteredEndpoints.length === 0" @click="showBulkOperationWarningDialog(Operation.DoNotTrack)">
<FAIcon :icon="faCloud" class="icon" :class="{ 'text-black': filteredEndpoints.length > 0 }" />
Do Not Track Instances on All Endpoints
</button>
<PermissionGate :allowed="canManageEndpointSettings" :reason="manageDeniedTooltip">
<button type="button" class="btn btn-default btn-sm" :disabled="filteredEndpoints.length === 0 || !canManageEndpointSettings" @click="showBulkOperationWarningDialog(Operation.Track)">
<FAIcon :icon="faServer" class="icon" :class="{ 'text-black': filteredEndpoints.length > 0 }" />
Track Instances on All Endpoints
</button>
</PermissionGate>
<PermissionGate :allowed="canManageEndpointSettings" :reason="manageDeniedTooltip">
<button type="button" class="btn btn-default btn-sm" :disabled="filteredEndpoints.length === 0 || !canManageEndpointSettings" @click="showBulkOperationWarningDialog(Operation.DoNotTrack)">
<FAIcon :icon="faCloud" class="icon" :class="{ 'text-black': filteredEndpoints.length > 0 }" />
Do Not Track Instances on All Endpoints
</button>
</PermissionGate>
</span>
</div>
</div>
Expand All @@ -96,7 +103,9 @@ async function toggleDefaultSetting() {
<div class="defaultSetting">
<label>Track Instances by default on new endpoints</label>
<div class="switch">
<OnOffSwitch id="defaultTIV" @toggle="toggleDefaultSetting" :value="defaultTrackingInstancesValue" />
<PermissionGate :allowed="canManageEndpointSettings" :reason="manageDeniedTooltip">
<OnOffSwitch id="defaultTIV" @toggle="toggleDefaultSetting" :value="defaultTrackingInstancesValue" :disabled="!canManageEndpointSettings" />
</PermissionGate>
</div>
</div>
<p>
Expand Down
9 changes: 7 additions & 2 deletions src/Frontend/src/components/heartbeats/HeartbeatsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ import ColumnHeader from "../ColumnHeader.vue";
import FAIcon from "@/components/FAIcon.vue";
import { faBellSlash, faCheck, faCloud, faServer } from "@fortawesome/free-solid-svg-icons";
import useHeartbeatsStoreAutoRefresh from "@/composables/useHeartbeatsStoreAutoRefresh";
import PermissionGate from "@/components/PermissionGate.vue";

defineProps<{
data: LogicalEndpoint[];
columns: ColumnNames[];
}>();

const manageDeniedTooltip = "You don't have permission to manage heartbeat tracking.";

const { store } = useHeartbeatsStoreAutoRefresh();
const { sortByInstances, itemsPerPage } = storeToRefs(store);
const { sortByInstances, itemsPerPage, canManageEndpointSettings } = storeToRefs(store);
const route = useRoute();

async function changeEndpointSettings(endpoint: LogicalEndpoint) {
Expand Down Expand Up @@ -97,7 +100,9 @@ function endpointHealth(endpoint: LogicalEndpoint) {
</div>
<div v-if="columns.includes(ColumnNames.TrackToggle)" role="cell" aria-label="tracked-instances" class="col-2 centre">
<div class="switch">
<OnOffSwitch :id="endpoint.name" @toggle="changeEndpointSettings(endpoint)" :value="endpoint.track_instances" />
<PermissionGate :allowed="canManageEndpointSettings" :reason="manageDeniedTooltip">
<OnOffSwitch :id="endpoint.name" @toggle="changeEndpointSettings(endpoint)" :value="endpoint.track_instances" :disabled="!canManageEndpointSettings" />
</PermissionGate>
</div>
</div>
<div v-if="columns.includes(ColumnNames.Muted)" role="cell" aria-label="muted" class="col-1 centre">
Expand Down
1 change: 1 addition & 0 deletions src/Frontend/src/composables/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const ApiRoutes = {
viewEndpoints: { method: "GET", path: "/api/endpoints" }, // error:endpoints:view (EndpointsMonitoringController)
manageThroughput: { method: "POST", path: "/api/licensing/settings/masks/update" }, // error:throughput:manage (LicensingController)
// ---- actions ----
manageEndpointSettings: { method: "PATCH", path: "/api/endpointssettings/{}" },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we can either check for GET or PATCH when doing the ability to manage endpoint settings. Is there a benefit to using the GET instead of PATCH?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we had a separate view endpoint settings to the manage endpoint settings, then we'd need to use the GET and the PATCH. But since we only have the manage endpoint settings which includes viewing the settings, I think we're fine with using PATCH for this

manageNotifications: { method: "POST", path: "/api/notifications/email" },
testNotifications: { method: "POST", path: "/api/notifications/email/test" },
manageRedirects: { method: "POST", path: "/api/redirects" },
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
Loading