feat(rbac): hide unauthorized menu sections and enforce route guards

This commit is contained in:
2026-03-06 10:50:28 +03:00
parent a5086f3eef
commit e7cb5237d3
43 changed files with 4071 additions and 245 deletions

View File

@@ -28,7 +28,6 @@
* @TEST_INVARIANT navigation -> verifies: [idle_state]
*/
import { onMount } from "svelte";
import { page } from "$app/stores";
import {
sidebarStore,
@@ -37,65 +36,12 @@
closeMobile,
} from "$lib/stores/sidebar.js";
import { t } from "$lib/i18n";
import { auth } from "$lib/auth/store.js";
import { buildSidebarCategories } from "$lib/components/layout/sidebarNavigation.js";
import { browser } from "$app/environment";
import Icon from "$lib/ui/Icon.svelte";
function buildCategories() {
return [
{
id: "dashboards",
label: $t.nav?.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
subItems: [{ label: $t.nav?.overview, path: "/dashboards" }],
},
{
id: "datasets",
label: $t.nav?.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
subItems: [{ label: $t.nav?.all_datasets, path: "/datasets" }],
},
{
id: "storage",
label: $t.nav?.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
subItems: [
{ label: $t.nav?.backups, path: "/storage/backups" },
{
label: $t.nav?.repositories,
path: "/storage/repos",
},
],
},
{
id: "reports",
label: $t.nav?.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
subItems: [{ label: $t.nav?.reports, path: "/reports" }],
},
{
id: "admin",
label: $t.nav?.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
{ label: $t.nav?.admin_users, path: "/admin/users" },
{ label: $t.nav?.admin_roles, path: "/admin/roles" },
{ label: $t.nav?.settings, path: "/settings" },
],
},
];
}
let categories = buildCategories();
let categories = [];
let isExpanded = true;
let activeCategory = "dashboards";
@@ -111,14 +57,34 @@
isMobileOpen = $sidebarStore.isMobileOpen;
}
// Reactive categories to update translations
$: categories = buildCategories();
// Reactive categories to update translations and apply RBAC visibility.
$: categories = buildSidebarCategories($t, $auth?.user || null);
// Keep active category valid after RBAC filtering.
$: if (
categories.length > 0 &&
!categories.some((category) => category.id === activeCategory)
) {
const fallbackCategory = categories[0];
const fallbackPath =
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
setActiveItem(fallbackCategory.id, fallbackPath);
}
// Keep active category expanded after route/user change.
$: if (activeCategory && !expandedCategories.has(activeCategory)) {
expandedCategories.add(activeCategory);
expandedCategories = expandedCategories;
}
// Update active item when page changes
$: if ($page && $page.url.pathname !== activeItem) {
const matched = categories.find((cat) =>
$page.url.pathname.startsWith(cat.path),
);
const matched = categories.find((cat) => {
if ($page.url.pathname.startsWith(cat.path)) return true;
return (cat.subItems || []).some((item) =>
$page.url.pathname.startsWith(item.path),
);
});
if (matched) {
activeCategory = matched.id;
activeItem = $page.url.pathname;
@@ -255,7 +221,7 @@
<Icon
name="chevronDown"
size={16}
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
className="text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'

View File

@@ -44,6 +44,7 @@
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js";
import { t } from "$lib/i18n";
import { auth } from "$lib/auth/store.js";
import { hasPermission } from "$lib/auth/permissions.js";
import { toggleAssistantChat } from "$lib/stores/assistantChat.js";
import Icon from "$lib/ui/Icon.svelte";
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
@@ -72,6 +73,7 @@
$: activeCount = $activityStore?.activeCount || 0;
$: recentTasks = $activityStore?.recentTasks || [];
$: user = $auth?.user || null;
$: canOpenSettings = hasPermission(user, "admin:settings", "READ");
$: globalEnvironments = $environmentContextStore?.environments || [];
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
$: globalSelectedEnv = $selectedEnvironmentStore;
@@ -316,8 +318,8 @@
}
}
onMount(async () => {
await initializeEnvironmentContext();
onMount(() => {
void initializeEnvironmentContext();
if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick);
}
@@ -508,19 +510,21 @@
<strong>{user?.username || $t.common?.user}</strong>
</div>
<div class="border-t border-gray-200 my-1"></div>
<div
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => {
window.location.href = "/settings";
}}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(window.location.href = "/settings")}
role="button"
tabindex="0"
>
{$t.nav?.settings}
</div>
{#if canOpenSettings}
<div
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => {
window.location.href = "/settings";
}}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(window.location.href = "/settings")}
role="button"
tabindex="0"
>
{$t.nav?.settings}
</div>
{/if}
<div
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
on:click={handleLogout}

View File

@@ -0,0 +1,99 @@
// [DEF:frontend.src.lib.components.layout.__tests__.sidebarNavigation:Module]
// @TIER: STANDARD
// @SEMANTICS: tests, sidebar, navigation, rbac, permissions
// @PURPOSE: Verifies RBAC-based sidebar category and subitem visibility.
// @LAYER: UI (Tests)
// @RELATION: TESTS -> frontend/src/lib/components/layout/sidebarNavigation.js
import { describe, it, expect } from "vitest";
import { buildSidebarCategories } from "../sidebarNavigation.js";
const i18nState = {
nav: {
dashboards: "Dashboards",
overview: "Overview",
datasets: "Datasets",
all_datasets: "All datasets",
storage: "Storage",
backups: "Backups",
repositories: "Repositories",
reports: "Reports",
profile: "Profile",
admin: "Admin",
admin_users: "User management",
admin_roles: "Role management",
settings: "Settings",
},
};
function makeUser(roles) {
return { roles };
}
describe("sidebarNavigation", () => {
it("shows only categories available to a non-admin user", () => {
const user = makeUser([
{
name: "Operator",
permissions: [
{ resource: "plugin:migration", action: "READ" },
{ resource: "tasks", action: "READ" },
],
},
]);
const categories = buildSidebarCategories(i18nState, user);
const categoryIds = categories.map((category) => category.id);
expect(categoryIds).toEqual(["dashboards", "datasets", "reports", "profile"]);
});
it("hides admin category when user has no admin permissions", () => {
const user = makeUser([
{
name: "Viewer",
permissions: [{ resource: "plugin:migration", action: "READ" }],
},
]);
const categories = buildSidebarCategories(i18nState, user);
const adminCategory = categories.find((category) => category.id === "admin");
expect(adminCategory).toBeUndefined();
});
it("shows full admin category for admin role", () => {
const user = makeUser([
{
name: "Admin",
permissions: [],
},
]);
const categories = buildSidebarCategories(i18nState, user);
const adminCategory = categories.find((category) => category.id === "admin");
expect(adminCategory).toBeDefined();
expect(adminCategory.subItems.map((item) => item.path)).toEqual([
"/admin/users",
"/admin/roles",
"/settings",
]);
});
it("keeps profile visible even without explicit plugin permissions", () => {
const user = makeUser([
{
name: "Basic",
permissions: [],
},
]);
const categories = buildSidebarCategories(i18nState, user);
const categoryIds = categories.map((category) => category.id);
expect(categoryIds).toEqual(["profile"]);
});
});
// [/DEF:frontend.src.lib.components.layout.__tests__.sidebarNavigation:Module]

View File

@@ -0,0 +1,166 @@
// [DEF:frontend.src.lib.components.layout.sidebarNavigation:Module]
// @TIER: STANDARD
// @SEMANTICS: navigation, sidebar, rbac, menu, filtering
// @PURPOSE: Build sidebar navigation categories filtered by current user permissions.
// @LAYER: UI
// @RELATION: DEPENDS_ON -> frontend.src.lib.auth.permissions.hasPermission
// @RELATION: USED_BY -> frontend.src.lib.components.layout.Sidebar
// @INVARIANT: Admin role can access all categories and subitems through permission utility.
import { hasPermission } from "$lib/auth/permissions.js";
// [DEF:isItemAllowed:Function]
// @PURPOSE: Check whether a single menu node can be shown for a given user.
// @PRE: item can contain optional requiredPermission/requiredAction.
// @POST: Returns true when no permission is required or permission check passes.
function isItemAllowed(user, item) {
if (!item?.requiredPermission) return true;
return hasPermission(
user,
item.requiredPermission,
item.requiredAction || "READ",
);
}
// [/DEF:isItemAllowed:Function]
// [DEF:buildSidebarCategories:Function]
// @PURPOSE: Build translated sidebar categories and filter them by RBAC permissions.
// @PRE: i18nState provides nav labels; user can be null.
// @POST: Returns only categories/subitems available for provided user.
export function buildSidebarCategories(i18nState, user) {
const nav = i18nState?.nav || {};
const categories = [
{
id: "dashboards",
label: nav.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{
label: nav.overview,
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
},
],
},
{
id: "datasets",
label: nav.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{
label: nav.all_datasets,
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
},
],
},
{
id: "storage",
label: nav.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
requiredPermission: "plugin:storage",
requiredAction: "READ",
subItems: [
{
label: nav.backups,
path: "/storage/backups",
requiredPermission: "plugin:storage",
requiredAction: "READ",
},
{
label: nav.repositories,
path: "/storage/repos",
requiredPermission: "plugin:storage",
requiredAction: "READ",
},
],
},
{
id: "reports",
label: nav.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
subItems: [
{
label: nav.reports,
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
},
],
},
{
id: "profile",
label: nav.profile,
icon: "admin",
tone: "from-indigo-100 to-indigo-200 text-indigo-700 ring-indigo-200",
path: "/profile",
subItems: [{ label: nav.profile, path: "/profile" }],
},
{
id: "admin",
label: nav.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
{
label: nav.admin_users,
path: "/admin/users",
requiredPermission: "admin:users",
requiredAction: "READ",
},
{
label: nav.admin_roles,
path: "/admin/roles",
requiredPermission: "admin:roles",
requiredAction: "READ",
},
{
label: nav.settings,
path: "/settings",
requiredPermission: "admin:settings",
requiredAction: "READ",
},
],
},
];
return categories
.map((category) => {
const visibleSubItems = (category.subItems || []).filter((subItem) =>
isItemAllowed(user, subItem),
);
return {
...category,
subItems: visibleSubItems,
};
})
.filter((category) => {
const categoryVisible = isItemAllowed(user, category);
if (!categoryVisible) return false;
const hasVisibleSubItems =
Array.isArray(category.subItems) && category.subItems.length > 0;
return hasVisibleSubItems;
});
}
// [/DEF:buildSidebarCategories:Function]
// [/DEF:frontend.src.lib.components.layout.sidebarNavigation:Module]