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 @@