Files
nas-tools/dashboard/backend/rbac.py
T
Gan, Jimmy 1b07246d0d
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 6m45s
Run Tests / Backend Tests (push) Failing after 10m24s
Run Tests / Frontend Tests (push) Has started running
Run Tests / Test Summary (push) Has been cancelled
fix: convert RBAC_FILE constant to getter function to fix CI test failures
2026-04-05 01:20:27 +08:00

144 lines
4.2 KiB
Python

import json
import os
import threading
import tempfile
from typing import Optional
from pydantic import BaseModel
from fastapi import HTTPException, Request, status, Depends
import config
def get_rbac_file():
return os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
_rbac_lock = threading.RLock()
ROLE_GROUP_MAP = {
"dashboard_admin": "admin",
"dashboard_member": "member",
"dashboard_viewer": "viewer",
"admins": "admin", # Authelia admin group
"users": "member", # Authelia user group
}
DEFAULT_RBAC = {
"role_defaults": {
"admin": {"pages": "*", "sidebar_links": "*"},
"member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest", "opc"], "sidebar_links": "*"},
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
},
"user_overrides": {},
}
class User(BaseModel):
username: str
role: str
pages: list | str # "*" or list of page ids
sidebar_links: list | str = "*" # "*" or list of sidebar link labels/ids
def load_rbac() -> dict:
try:
rbac_path = get_rbac_file()
if os.path.exists(rbac_path):
with open(rbac_path) as f:
return json.load(f)
except Exception:
pass
return DEFAULT_RBAC.copy()
def _atomic_json_write(path: str, data: dict):
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-rbac-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def update_rbac(mutator):
with _rbac_lock:
data = load_rbac()
result = mutator(data)
_atomic_json_write(get_rbac_file(), data)
return result
def save_rbac(data: dict):
_atomic_json_write(get_rbac_file(), data)
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 get_sidebar_links(username: str, role: str) -> list | str:
rbac = load_rbac()
overrides = rbac.get("user_overrides", {})
if username in overrides and "sidebar_links" in overrides[username]:
return overrides[username]["sidebar_links"]
return rbac.get("role_defaults", {}).get(role, {}).get("sidebar_links", "*")
def get_sidebar_order(username: str) -> dict | None:
rbac = load_rbac()
overrides = rbac.get("user_overrides", {})
return overrides.get(username, {}).get("sidebar_order")
def save_sidebar_order(username: str, order: dict):
def mutator(rbac):
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
update_rbac(mutator)
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