feat: RBAC multi-user role-based access control
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m14s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m14s
- Add rbac.py with User model, LDAP group-to-role mapping, page/write dependencies - Proxy auth reads Remote-Groups header to resolve role from LDAP groups - JWT tokens carry role claim, /me returns role + allowed pages - Per-router page access control and viewer write protection - Terminal WebSocket RBAC check - Frontend filters sidebar links and routes by allowed pages - Admin-only Settings page and RBAC override endpoints - Mount rbac.json in docker-compose for page config persistence
This commit is contained in:
@@ -11,13 +11,31 @@
|
||||
import Security from "./routes/Security.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
||||
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser } from "./lib/api.js";
|
||||
|
||||
let page = $state("dashboard");
|
||||
let dark = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
let authorized = $state(false);
|
||||
let loading = $state(true);
|
||||
let userRole = $state("");
|
||||
let allowedPages = $state([]);
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const data = await checkAuth();
|
||||
setCurrentUser(data);
|
||||
userRole = data.role || "admin";
|
||||
allowedPages = data.pages || "*";
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch user info:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPageAccess(pageId) {
|
||||
if (allowedPages === "*") return true;
|
||||
return Array.isArray(allowedPages) && allowedPages.includes(pageId);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Theme init
|
||||
@@ -37,6 +55,7 @@
|
||||
setToken(data.access_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
authorized = true;
|
||||
await fetchUserInfo();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
@@ -51,9 +70,9 @@
|
||||
authorized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await checkAuth();
|
||||
await fetchUserInfo();
|
||||
authorized = true;
|
||||
} catch (e) {
|
||||
console.error("Auth check failed:", e);
|
||||
@@ -90,31 +109,33 @@
|
||||
<Login />
|
||||
{:else}
|
||||
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
|
||||
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen />
|
||||
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} />
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="md:hidden mb-4 p-2 rounded-lg hover:bg-surface-200 {dark ? 'hover:bg-surface-700 text-surface-300' : 'text-surface-600'}" onclick={() => sidebarOpen = true} aria-label="Open menu">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
{#if page === "dashboard"}
|
||||
{#if page === "dashboard" && hasPageAccess("dashboard")}
|
||||
<Dashboard />
|
||||
{:else if page === "docker"}
|
||||
{:else if page === "docker" && hasPageAccess("docker")}
|
||||
<Docker />
|
||||
{:else if page === "gitea"}
|
||||
{:else if page === "gitea" && hasPageAccess("gitea")}
|
||||
<Gitea />
|
||||
{:else if page === "files"}
|
||||
{:else if page === "files" && hasPageAccess("files")}
|
||||
<Files />
|
||||
{:else if page === "terminal"}
|
||||
{:else if page === "terminal" && hasPageAccess("terminal")}
|
||||
<Terminal />
|
||||
{:else if page === "openclaw"}
|
||||
{:else if page === "openclaw" && hasPageAccess("openclaw")}
|
||||
<OpenClaw />
|
||||
{:else if page === "chat-digest"}
|
||||
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
||||
<ChatSummary />
|
||||
{:else if page === "settings"}
|
||||
{:else if page === "settings" && userRole === "admin"}
|
||||
<Settings />
|
||||
{:else if page === "security"}
|
||||
{:else if page === "security" && hasPageAccess("security")}
|
||||
<Security />
|
||||
{:else}
|
||||
<Dashboard />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script>
|
||||
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false) } = $props();
|
||||
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin" } = $props();
|
||||
|
||||
function canAccess(id) {
|
||||
if (!id) return true; // external links always shown
|
||||
if (allowedPages === "*") return true;
|
||||
return Array.isArray(allowedPages) && allowedPages.includes(id);
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||
@@ -75,6 +81,7 @@
|
||||
<nav class="flex-1 px-3 mt-2 overflow-y-auto">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p>
|
||||
{#each links as link}
|
||||
{#if canAccess(link.id)}
|
||||
<button
|
||||
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150
|
||||
{page === link.id
|
||||
@@ -98,6 +105,7 @@
|
||||
</span>
|
||||
{link.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
||||
@@ -128,6 +136,7 @@
|
||||
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p>
|
||||
{#each tools as item}
|
||||
{#if canAccess(item.id)}
|
||||
{#if item.external || item.port}
|
||||
<a
|
||||
href={extHref(item)}
|
||||
@@ -178,11 +187,13 @@
|
||||
{item.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Bottom actions -->
|
||||
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}">
|
||||
{#if userRole === "admin"}
|
||||
<button
|
||||
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''} {page === 'settings' ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') : ''}"
|
||||
onclick={() => navigate('settings')}
|
||||
@@ -190,6 +201,7 @@
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
Settings
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const BASE = "/api";
|
||||
let token = localStorage.getItem("token") || "";
|
||||
|
||||
// Current user info populated after auth
|
||||
export let currentUser = { username: "", role: "", pages: [] };
|
||||
|
||||
export function setCurrentUser(u) {
|
||||
currentUser = u;
|
||||
}
|
||||
|
||||
export function setToken(t) {
|
||||
token = t;
|
||||
if (t) localStorage.setItem("token", t);
|
||||
|
||||
Reference in New Issue
Block a user