From 9992105b495720cceaf021935bfe7bf4355a6747 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 1 Mar 2026 14:13:43 +0800 Subject: [PATCH] feat: RBAC multi-user role-based access control - 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 --- dashboard/backend/auth.py | 14 ++- dashboard/backend/main.py | 26 +++-- dashboard/backend/rbac.py | 94 +++++++++++++++ dashboard/backend/routers/auth.py | 110 +++++++++++++----- dashboard/backend/routers/terminal.py | 13 ++- dashboard/docker-compose.yml | 1 + dashboard/frontend/src/App.svelte | 47 +++++--- .../frontend/src/components/Sidebar.svelte | 14 ++- dashboard/frontend/src/lib/api.js | 7 ++ tmp_remote/Caddyfile_nas | 81 +++++++++++-- 10 files changed, 342 insertions(+), 65 deletions(-) create mode 100644 dashboard/backend/rbac.py diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index 2cb9354..359a5c1 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -31,6 +31,7 @@ def create_refresh_token(data: dict): return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) async def get_current_user(token: str = Depends(oauth2_scheme)): + from rbac import User, get_pages credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -41,13 +42,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): if payload.get("type") != "access": raise credentials_exception username: str = payload.get("sub") - if username is None or username != config.ADMIN_USER: + role: str = payload.get("role", "admin") + if username is None: raise credentials_exception except jwt.PyJWTError: raise credentials_exception - return username + pages = get_pages(username, role) + return User(username=username, role=role, pages=pages) async def get_current_user_ws(token: str): + from rbac import User, get_pages credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -57,11 +61,13 @@ async def get_current_user_ws(token: str): if payload.get("type") != "access": raise credentials_exception username: str = payload.get("sub") - if username is None or username != config.ADMIN_USER: + role: str = payload.get("role", "admin") + if username is None: raise credentials_exception except jwt.PyJWTError: raise credentials_exception - return username + pages = get_pages(username, role) + return User(username=username, role=role, pages=pages) # TOTP Persistence AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 3470df5..8c7e4e5 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -7,6 +7,7 @@ from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security import auth as auth_module +from rbac import require_page, require_write import asyncio import httpx @@ -139,16 +140,27 @@ def _router_reachable(): except Exception: return False +# Dependency to store user in request.state for rbac dependencies +async def _inject_user(request: Request, user = Depends(auth_module.get_current_user)): + request.state.user = user + return user + # Public auth endpoints app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) -# Protected endpoints -app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)]) -app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)]) -app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)]) -app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)]) -app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(auth_module.get_current_user)]) -app.include_router(security.router, prefix="/api/security", dependencies=[Depends(auth_module.get_current_user)]) +# Protected endpoints with page-level RBAC +app.include_router(docker_router.router, prefix="/api/docker", + dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())]) +app.include_router(gitea.router, prefix="/api/gitea", + dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]) +app.include_router(files.router, prefix="/api/files", + dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())]) +app.include_router(system.router, prefix="/api/system", + dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) +app.include_router(chat_summary.router, prefix="/api/chat-summary", + dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))]) +app.include_router(security.router, prefix="/api/security", + dependencies=[Depends(_inject_user), Depends(require_page("security"))]) # WebSocket endpoint (auth handled inside handler via Query param) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) diff --git a/dashboard/backend/rbac.py b/dashboard/backend/rbac.py new file mode 100644 index 0000000..20da62e --- /dev/null +++ b/dashboard/backend/rbac.py @@ -0,0 +1,94 @@ +import json +import os +from typing import Optional +from pydantic import BaseModel +from fastapi import HTTPException, Request, status + +import config + +RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json") + +ROLE_GROUP_MAP = { + "dashboard_admin": "admin", + "dashboard_member": "member", + "dashboard_viewer": "viewer", +} + +DEFAULT_RBAC = { + "role_defaults": { + "admin": {"pages": "*"}, + "member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"]}, + "viewer": {"pages": ["dashboard"]}, + }, + "user_overrides": {}, +} + + +class User(BaseModel): + username: str + role: str + pages: list | str # "*" or list of page ids + + +def load_rbac() -> dict: + try: + if os.path.exists(RBAC_FILE): + with open(RBAC_FILE) as f: + return json.load(f) + except Exception: + pass + return DEFAULT_RBAC.copy() + + +def save_rbac(data: dict): + os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True) + with open(RBAC_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def resolve_role(groups: list[str]) -> Optional[str]: + for group in groups: + if group in ROLE_GROUP_MAP: + return ROLE_GROUP_MAP[group] + return None + + +def get_pages(username: str, role: str) -> list | str: + rbac = load_rbac() + overrides = rbac.get("user_overrides", {}) + if username in overrides: + return overrides[username].get("pages", rbac["role_defaults"].get(role, {}).get("pages", [])) + return rbac.get("role_defaults", {}).get(role, {}).get("pages", []) + + +def has_page_access(user: User, page: str) -> bool: + if user.pages == "*": + return True + return page in user.pages + + +def require_page(page: str): + """FastAPI dependency factory — 403 if user lacks page access.""" + async def checker(request: Request): + user: User = request.state.user + if not has_page_access(user, page): + raise HTTPException(status_code=403, detail="Access denied") + return checker + + +def require_admin(): + """FastAPI dependency — 403 if user is not admin.""" + async def checker(request: Request): + user: User = request.state.user + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + return checker + + +def require_write(): + """FastAPI dependency — 403 if viewer tries non-GET method.""" + async def checker(request: Request): + user: User = request.state.user + if user.role == "viewer" and request.method != "GET": + raise HTTPException(status_code=403, detail="Read-only access") + return checker diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 16e088f..9246002 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -32,6 +32,7 @@ class Token(BaseModel): token_type: str user: str has_2fa: bool + role: str = "admin" class RefreshRequest(BaseModel): refresh_token: str @@ -96,25 +97,27 @@ async def login(creds: LoginRequest, request: Request): headers={"WWW-Authenticate": "Bearer"}, ) + # Direct login is admin-only fallback access_token = auth.create_access_token( - data={"sub": creds.username}, + data={"sub": creds.username, "role": "admin"}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) - refresh_token = auth.create_refresh_token(data={"sub": creds.username}) + refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"}) return { "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": creds.username, - "has_2fa": bool(totp_secret) + "has_2fa": bool(totp_secret), + "role": "admin", } @router.get("/proxy") async def proxy_auth(request: Request): """Auto-login when Authelia has already authenticated the user via forward-auth. - Caddy sets Remote-User header after successful Authelia 2FA verification.""" - # Prevent IP spoofing: ensure proxy traffic is from trusted proxy network + Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" import config + from rbac import resolve_role if not config.is_trusted_proxy(request.client.host): logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}") raise HTTPException(status_code=403, detail="Unauthorized proxy source") @@ -122,27 +125,39 @@ async def proxy_auth(request: Request): remote_user = request.headers.get("Remote-User") if not remote_user: raise HTTPException(status_code=401, detail="No proxy auth header") - # Map the LDAP user to the dashboard admin user - # Only allow known users (the admin) - if remote_user != config.ADMIN_USER: + + # Parse groups from Remote-Groups header (comma-separated) + remote_groups_raw = request.headers.get("Remote-Groups", "") + groups = [g.strip() for g in remote_groups_raw.split(",") if g.strip()] + + role = resolve_role(groups) + if role is None: + logger.warning(f"User {remote_user} has no dashboard role (groups: {groups})") raise HTTPException(status_code=403, detail="User not authorized for dashboard") + access_token = auth.create_access_token( - data={"sub": remote_user}, + data={"sub": remote_user, "role": role}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) - refresh_token = auth.create_refresh_token(data={"sub": remote_user}) + refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role}) return { "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": remote_user, "has_2fa": True, + "role": role, } @router.get("/me") -async def read_users_me(current_user: str = Depends(auth.get_current_user)): +async def read_users_me(current_user = Depends(auth.get_current_user)): totp_secret = auth.load_totp_secret() - return {"username": current_user, "has_2fa": bool(totp_secret)} + return { + "username": current_user.username, + "role": current_user.role, + "pages": current_user.pages, + "has_2fa": bool(totp_secret), + } @router.post("/refresh") @limiter.limit("10/minute") @@ -152,7 +167,8 @@ async def refresh_token(req: RefreshRequest, request: Request): if payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="Invalid token type") username = payload.get("sub") - if username != config.ADMIN_USER: + role = payload.get("role", "admin") + if username is None: raise HTTPException(status_code=401, detail="Invalid user") if not auth.verify_token_version(payload.get("tv", -1)): raise HTTPException(status_code=401, detail="Token revoked") @@ -160,20 +176,20 @@ async def refresh_token(req: RefreshRequest, request: Request): raise HTTPException(status_code=401, detail="Invalid refresh token") auth.increment_token_version() access_token = auth.create_access_token( - data={"sub": username}, + data={"sub": username, "role": role}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) - refresh_token = auth.create_refresh_token(data={"sub": username}) + refresh_token = auth.create_refresh_token(data={"sub": username, "role": role}) return {"access_token": access_token, "refresh_token": refresh_token} @router.post("/setup-2fa/generate", response_model=Setup2FAResponse) -async def generate_2fa(current_user: str = Depends(auth.get_current_user)): +async def generate_2fa(current_user = Depends(auth.get_current_user)): secret = pyotp.random_base32() - uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user, issuer_name="NAS Dashboard") + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard") return {"secret": secret, "uri": uri} @router.post("/setup-2fa/verify") -async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depends(auth.get_current_user)): +async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)): totp = pyotp.TOTP(request.secret) if not totp.verify(request.code): raise HTTPException(status_code=400, detail="Invalid code") @@ -183,7 +199,7 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depend return {"message": "2FA enabled successfully"} @router.post("/setup-2fa/disable") -async def disable_2fa(current_user: str = Depends(auth.get_current_user)): +async def disable_2fa(current_user = Depends(auth.get_current_user)): auth.save_totp_secret("") return {"message": "2FA disabled"} @@ -193,7 +209,7 @@ class ChangePasswordRequest(BaseModel): @router.post("/change-password") @limiter.limit("5/minute") -async def change_password(req: ChangePasswordRequest, request: Request, current_user: str = Depends(auth.get_current_user)): +async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)): if not auth.verify_password(req.current_password, auth.load_password_hash()): raise HTTPException(status_code=400, detail="Current password is incorrect") if len(req.new_password) < 8: @@ -226,22 +242,22 @@ def _get_challenge(client_data_b64: str) -> bytes: return chal_bytes @router.post("/passkey/register/options") -async def passkey_register_options(current_user: str = Depends(auth.get_current_user)): +async def passkey_register_options(current_user = Depends(auth.get_current_user)): existing = auth.load_passkey_credentials() exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing] options = generate_registration_options( rp_id=config.WEBAUTHN_RP_ID, rp_name="NAS Dashboard", - user_id=config.ADMIN_USER.encode(), - user_name=config.ADMIN_USER, - user_display_name=config.ADMIN_USER, + user_id=current_user.username.encode(), + user_name=current_user.username, + user_display_name=current_user.username, exclude_credentials=exclude, ) _store_challenge(options.challenge) return JSONResponse(content=json.loads(options_to_json(options))) @router.post("/passkey/register/verify") -async def passkey_register_verify(request: Request, current_user: str = Depends(auth.get_current_user)): +async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)): body = await request.json() challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) try: @@ -304,19 +320,19 @@ async def passkey_login_verify(request: Request): auth._save_auth_data(data) username = config.ADMIN_USER access_token = auth.create_access_token( - data={"sub": username}, + data={"sub": username, "role": "admin"}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) - refresh_token = auth.create_refresh_token(data={"sub": username}) - return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username} + refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username, "role": "admin"} @router.get("/passkey/list") -async def passkey_list(current_user: str = Depends(auth.get_current_user)): +async def passkey_list(current_user = Depends(auth.get_current_user)): creds = auth.load_passkey_credentials() return {"passkeys": [{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")} for c in creds]} @router.post("/passkey/delete") -async def passkey_delete(request: Request, current_user: str = Depends(auth.get_current_user)): +async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)): body = await request.json() cred_id = body.get("id") data = auth._load_auth_data() @@ -326,6 +342,38 @@ async def passkey_delete(request: Request, current_user: str = Depends(auth.get_ return {"ok": True} @router.post("/passkey/clear") -async def passkey_clear(current_user: str = Depends(auth.get_current_user)): +async def passkey_clear(current_user = Depends(auth.get_current_user)): auth.clear_passkey_credentials() return {"ok": True} + +# RBAC admin endpoints +@router.get("/rbac/config") +async def rbac_config(current_user = Depends(auth.get_current_user)): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + from rbac import load_rbac + return load_rbac() + +@router.put("/rbac/overrides/{username}") +async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + from rbac import load_rbac, save_rbac + body = await request.json() + pages = body.get("pages") + if pages is None: + raise HTTPException(status_code=400, detail="Missing pages field") + rbac = load_rbac() + rbac.setdefault("user_overrides", {})[username] = {"pages": pages} + save_rbac(rbac) + return {"ok": True} + +@router.delete("/rbac/overrides/{username}") +async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + from rbac import load_rbac, save_rbac + rbac = load_rbac() + rbac.get("user_overrides", {}).pop(username, None) + save_rbac(rbac) + return {"ok": True} diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 9148cbe..04b5249 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -38,9 +38,20 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): try: first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5) payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - if payload.get("type") != "access" or payload.get("sub") != config.ADMIN_USER: + if payload.get("type") != "access": await websocket.close(code=1008, reason="Unauthorized") return + username = payload.get("sub") + role = payload.get("role", "admin") + if username is None: + await websocket.close(code=1008, reason="Unauthorized") + return + # RBAC: check terminal page access + from rbac import get_pages + pages = get_pages(username, role) + if pages != "*" and "terminal" not in pages: + await websocket.close(code=1008, reason="Access denied") + return except Exception: await websocket.close(code=1008, reason="Unauthorized") return diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index b87159a..ca93f8e 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -49,6 +49,7 @@ services: - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro - /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro + - /volume1/docker/nas-dashboard/rbac.json:/volume1/docker/nas-dashboard/rbac.json healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"] interval: 30s diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 76cea7a..eeb0f2b 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -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 @@ {:else}
- +
- {#if page === "dashboard"} + {#if page === "dashboard" && hasPageAccess("dashboard")} - {:else if page === "docker"} + {:else if page === "docker" && hasPageAccess("docker")} - {:else if page === "gitea"} + {:else if page === "gitea" && hasPageAccess("gitea")} - {:else if page === "files"} + {:else if page === "files" && hasPageAccess("files")} - {:else if page === "terminal"} + {:else if page === "terminal" && hasPageAccess("terminal")} - {:else if page === "openclaw"} + {:else if page === "openclaw" && hasPageAccess("openclaw")} - {:else if page === "chat-digest"} + {:else if page === "chat-digest" && hasPageAccess("chat-digest")} - {:else if page === "settings"} + {:else if page === "settings" && userRole === "admin"} - {:else if page === "security"} + {:else if page === "security" && hasPageAccess("security")} + {:else} + {/if}
diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index e25c230..2a4d968 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -1,5 +1,11 @@