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
242 changes: 117 additions & 125 deletions src/Frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"prettier": "3.8.3",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0",
"vite": "8.0.14",
"vite": "8.0.16",
"vite-plugin-checker": "0.14.1",
"vite-plugin-vue-devtools": "8.1.2",
"vitest": "4.1.7",
Expand Down
16 changes: 15 additions & 1 deletion src/Frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, watch } from "vue";
import { RouterView, useRoute } from "vue-router";
import PageFooter from "./components/PageFooter.vue";
import PageHeader from "./components/PageHeader.vue";
Expand All @@ -8,11 +8,25 @@ import LicenseNotifications from "@/components/LicenseNotifications.vue";
import BackendChecksNotifications from "@/components/BackendChecksNotifications.vue";
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useUserPermissionsStore } from "@/stores/UserPermissionsStore";
import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";

const authStore = useAuthStore();
const route = useRoute();
const { isAuthenticated, authEnabled } = storeToRefs(authStore);

const permissionsStore = useUserPermissionsStore();
const environmentStore = useEnvironmentAndVersionsStore();
watch(
[authEnabled, isAuthenticated, () => environmentStore.environment.supportsUserPermissions],
([enabled, authenticated, supported]) => {
if (enabled && authenticated && supported) {
permissionsStore.refresh();
}
},
{ immediate: true }
);

// Check if the current route allows anonymous access (e.g., logged-out page)
const isAnonymousRoute = computed(() => route.meta?.allowAnonymous === true);
const shouldShowApp = computed(() => !authEnabled.value || isAuthenticated.value || isAnonymousRoute.value);
Expand Down
17 changes: 10 additions & 7 deletions src/Frontend/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,27 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue";
import monitoringClient from "@/components/monitoring/monitoringClient";
import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue";
import { useAuthStore } from "@/stores/AuthStore";
import usePermissionGate from "@/composables/usePermissionGate";
import { storeToRefs } from "pinia";

const isMonitoringEnabled = monitoringClient.isMonitoringEnabled;

const authStore = useAuthStore();
const { authEnabled, isAuthenticated } = storeToRefs(authStore);

const { has } = usePermissionGate();

// prettier-ignore
const menuItems = computed(
() => [
DashboardMenuItem,
HeartbeatsMenuItem,
...(isMonitoringEnabled ? [MonitoringMenuItem] : []),
AuditMenuItem,
FailedMessagesMenuItem,
CustomChecksMenuItem,
EventsMenuItem,
ThroughputMenuItem,
...(has("failed_messages_read") ? [HeartbeatsMenuItem] : []),
...(isMonitoringEnabled && has("monitoring_read") ? [MonitoringMenuItem] : []),
...(has("auditing_read") ? [AuditMenuItem] : []),
...(has("failed_messages_read") ? [FailedMessagesMenuItem] : []),
...(has("failed_messages_read") ? [CustomChecksMenuItem] : []),
...(has("failed_messages_read") ? [EventsMenuItem] : []),
...(has("admin_read") ? [ThroughputMenuItem] : []),
ConfigurationMenuItem,
FeedbackButton,
]);
Expand Down
116 changes: 116 additions & 0 deletions src/Frontend/src/components/configuration/UserPermissions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
import FAIcon from "@/components/FAIcon.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { useUserPermissionsStore } from "@/stores/UserPermissionsStore";

const store = useUserPermissionsStore();

onMounted(async () => {
await store.refresh();
});
</script>

<template>
<section name="user-permissions">
<div class="box">
<LoadingSpinner v-if="store.loading" />
<div v-else-if="store.error" class="alert alert-danger">{{ store.error }}</div>
<template v-else-if="store.summary && store.descriptor">
<div class="row">
<div class="col-12">
<h3>Permissions Summary</h3>
<table class="table permissions-table">
<thead>
<tr>
<th>Area</th>
<th class="text-center">Read</th>
<th class="text-center">Write</th>
</tr>
</thead>
<tbody>
<tr>
<td>Failed Messages</td>
<td class="text-center">
<FAIcon :icon="store.summary.failed_messages_read ? faCheck : faTimes" :class="store.summary.failed_messages_read ? 'text-success' : 'text-danger'" />
</td>
<td class="text-center">
<FAIcon :icon="store.summary.failed_messages_write ? faCheck : faTimes" :class="store.summary.failed_messages_write ? 'text-success' : 'text-danger'" />
</td>
</tr>
<tr>
<td>Auditing</td>
<td class="text-center">
<FAIcon :icon="store.summary.auditing_read ? faCheck : faTimes" :class="store.summary.auditing_read ? 'text-success' : 'text-danger'" />
</td>
<td class="text-center">—</td>
</tr>
<tr>
<td>Monitoring</td>
<td class="text-center">
<FAIcon :icon="store.summary.monitoring_read ? faCheck : faTimes" :class="store.summary.monitoring_read ? 'text-success' : 'text-danger'" />
</td>
<td class="text-center">
<FAIcon :icon="store.summary.monitoring_write ? faCheck : faTimes" :class="store.summary.monitoring_write ? 'text-success' : 'text-danger'" />
</td>
</tr>
<tr>
<td>Admin</td>
<td class="text-center">
<FAIcon :icon="store.summary.admin_read ? faCheck : faTimes" :class="store.summary.admin_read ? 'text-success' : 'text-danger'" />
</td>
<td class="text-center">
<FAIcon :icon="store.summary.admin_write ? faCheck : faTimes" :class="store.summary.admin_write ? 'text-success' : 'text-danger'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-12">
<h3>All Permissions</h3>
<p class="user-label">User: {{ store.descriptor.user }}</p>
<ul class="permissions-list">
<li v-for="permission in store.descriptor.permissions" :key="permission">{{ permission }}</li>
</ul>
</div>
</div>
</template>
</div>
</section>
</template>

<style scoped>
.permissions-table {
max-width: 480px;
}

.permissions-table th,
.permissions-table td {
padding: 10px 16px;
}

.user-label {
color: #666;
margin-bottom: 12px;
}

.permissions-list {
list-style: none;
padding: 0;
margin: 0;
font-family: monospace;
font-size: 13px;
}

.permissions-list li {
padding: 4px 0;
border-bottom: 1px solid #f0f0f0;
}

.permissions-list li:last-child {
border-bottom: none;
}
</style>
9 changes: 7 additions & 2 deletions src/Frontend/src/components/serviceControlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ class ServiceControlClient {
}
}

public async fetchTypedFromServiceControl<T>(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
const response = await authFetch(`${this.url}${suffix}`, { signal });
public fetchTypedFromServiceControl<T>(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
return this.fetchTypedFromUrl<T>(`${this.url}${suffix}`, signal);
}

// Fetch from an absolute URL, e.g. one discovered from the ServiceControl root document.
public async fetchTypedFromUrl<T>(url: string, signal?: AbortSignal): Promise<[Response, T]> {
const response = await authFetch(url, { signal });
if (!response.ok) throw new HttpError(response.status, response.statusText ?? "No response");
const data = await response.json();

Expand Down
75 changes: 75 additions & 0 deletions src/Frontend/src/composables/usePermissionGate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, test, expect, beforeEach } from "vitest";
import { createTestingPinia } from "@pinia/testing";
import { setActivePinia } from "pinia";
import usePermissionGate from "@/composables/usePermissionGate";
import { useAuthStore } from "@/stores/AuthStore";
import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore";

const summaryWith = (overrides: Partial<PermissionsSummary> = {}): PermissionsSummary => ({
failed_messages_read: false,
failed_messages_write: false,
auditing_read: false,
monitoring_read: false,
monitoring_write: false,
admin_read: false,
admin_write: false,
...overrides,
});

describe("usePermissionGate", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
});

function withState(opts: { authEnabled: boolean; isAuthenticated: boolean; summary: PermissionsSummary | null }) {
const auth = useAuthStore();
auth.authEnabled = opts.authEnabled;
auth.isAuthenticated = opts.isAuthenticated;
useUserPermissionsStore().summary = opts.summary;
return usePermissionGate();
}

test("fails open (shows everything) when authorization is disabled", () => {
const { has, shouldGate } = withState({ authEnabled: false, isAuthenticated: true, summary: summaryWith() });

expect(shouldGate.value).toBe(false);
expect(has("admin_read")).toBe(true);
expect(has("failed_messages_read")).toBe(true);
});

test("fails open when the user is not authenticated", () => {
const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: false, summary: summaryWith() });

expect(shouldGate.value).toBe(false);
expect(has("admin_read")).toBe(true);
});

test("fails open while the permission summary has not loaded yet", () => {
const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: null });

expect(shouldGate.value).toBe(false);
expect(has("admin_read")).toBe(true);
});

test("gates per flag once enabled, authenticated and loaded", () => {
const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: summaryWith({ admin_read: true }) });

expect(shouldGate.value).toBe(true);
expect(has("admin_read")).toBe(true);
expect(has("failed_messages_read")).toBe(false);
});

test("reacts to the summary being updated", () => {
const auth = useAuthStore();
auth.authEnabled = true;
auth.isAuthenticated = true;
const permissions = useUserPermissionsStore();
permissions.summary = summaryWith();

const { has } = usePermissionGate();
expect(has("monitoring_read")).toBe(false);

permissions.summary = summaryWith({ monitoring_read: true });
expect(has("monitoring_read")).toBe(true);
});
});
22 changes: 22 additions & 0 deletions src/Frontend/src/composables/usePermissionGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore";

// Centralises the "should this UI element be shown for the current user" decision.
// Gating only kicks in once authorization is enabled, the user is authenticated and
// the permission summary has loaded; otherwise everything is shown (fail-open) so the
// UI is unchanged for unauthenticated/older-ServiceControl setups. Server-side checks
// remain the real enforcement — this only hides UI the user cannot use anyway.
export default function usePermissionGate() {
const { authEnabled, isAuthenticated } = storeToRefs(useAuthStore());
const { summary } = storeToRefs(useUserPermissionsStore());

const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && summary.value !== null);

function has(flag: keyof PermissionsSummary): boolean {
return !shouldGate.value || summary.value?.[flag] === true;
}

return { has, shouldGate };
}
2 changes: 2 additions & 0 deletions src/Frontend/src/resources/RootUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export default interface RootUrls {
event_log_items: string;
archived_groups_url: string;
get_archive_group: string;
mypermissions_all?: string;
mypermissions_summary?: string;
}
5 changes: 5 additions & 0 deletions src/Frontend/src/router/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ const config: RouteItem[] = [
path: routeLinks.configuration.endpointConnection.template,
component: () => import("@/components/configuration/EndpointConnection.vue"),
},
{
title: "User Permissions",
path: routeLinks.configuration.userPermissions.template,
component: () => import("@/components/configuration/UserPermissions.vue"),
},
{
title: "Usage Setup",
path: routeLinks.throughput.setup.root,
Expand Down
1 change: 1 addition & 0 deletions src/Frontend/src/router/routeLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const configurationLinks = (root: string) => {
retryRedirects: createLink("retry-redirects"),
connections: createLink("connections"),
endpointConnection: createLink("endpoint-connection"),
userPermissions: createLink("user-permissions"),
};
};

Expand Down
6 changes: 6 additions & 0 deletions src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion
is_compatible_with_sc: true,
sp_version: window.defaultConfig && window.defaultConfig.version ? window.defaultConfig.version : "1.2.0",
supportsArchiveGroups: false,
supportsUserPermissions: false,
mypermissions_all_url: "",
mypermissions_summary_url: "",
endpoints_error_url: "",
known_endpoints_url: "",
endpoints_message_search_url: "",
Expand Down Expand Up @@ -56,6 +59,9 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion
const [products, scVer] = await Promise.all([productsResult, scResult, mResult]);
if (scVer) {
environment.supportsArchiveGroups = !!scVer.archived_groups_url;
environment.supportsUserPermissions = !!scVer.mypermissions_all && !!scVer.mypermissions_summary;
environment.mypermissions_all_url = scVer.mypermissions_all ?? "";
environment.mypermissions_summary_url = scVer.mypermissions_summary ?? "";
environment.is_compatible_with_sc = isSupported(environment.sc_version, environment.minimum_supported_sc_version);
environment.endpoints_error_url = scVer && scVer.endpoints_error_url;
environment.known_endpoints_url = scVer && scVer.known_endpoints_url;
Expand Down
Loading