Add RBAC: admin user management, audit log, runtime settings, delete PC

This commit is contained in:
2026-07-16 17:28:04 +07:00
parent c2148f334d
commit d1128f4aa3
16 changed files with 690 additions and 24 deletions
+69 -4
View File
@@ -10,10 +10,20 @@ from typing import List, Optional
from config import get_settings
from database import get_db
from models import Computer, Heartbeat, Notification
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut
from auth import get_current_user
from models import Computer, Heartbeat, Notification, AuditLog
from schemas import (
HeartbeatPayload,
ComputerOut,
ComputerDetailOut,
HeartbeatOut,
NotificationOut,
AuditLogOut,
SettingOut,
SettingsUpdate,
)
from auth import get_current_user, require_admin, log_action
from notifications import check_offline_computers, mark_notification_read
from settings_store import get_setting, set_setting, get_all_settings
router = APIRouter(prefix="/api", tags=["api"])
settings = get_settings()
@@ -33,7 +43,8 @@ def heartbeat(
db: Session = Depends(get_db),
):
token = get_agent_token(request)
if token != settings.AGENT_TOKEN:
expected = get_setting(db, "AGENT_TOKEN") or settings.AGENT_TOKEN
if token != expected:
raise HTTPException(status_code=401, detail="Invalid agent token")
now = datetime.now(timezone.utc)
@@ -140,6 +151,23 @@ def get_computer(
return computer
@router.delete("/computers/{computer_id}")
def delete_computer(
computer_id: int,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
computer = db.query(Computer).filter(Computer.id == computer_id).first()
if not computer:
raise HTTPException(status_code=404, detail="Computer not found")
hostname = computer.hostname
db.delete(computer)
db.commit()
log_action(db, admin, "computer_deleted", f"Deleted computer {hostname} (id={computer_id})")
return {"ok": True}
@router.get("/computers/export/csv")
def export_csv(
status: Optional[str] = None,
@@ -230,3 +258,40 @@ def read_all_notifications(
)
db.commit()
return {"ok": True}
# Admin: audit log
@router.get("/audit", response_model=List[AuditLogOut])
def list_audit(
limit: int = 200,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
return db.query(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit).all()
# Admin: settings
@router.get("/settings")
def list_settings(
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
return get_all_settings(db)
@router.post("/settings")
def update_settings(
payload: SettingsUpdate,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
changed = []
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
set_setting(db, key, value)
changed.append(key)
if changed:
log_action(db, admin, "settings_updated", f"Updated: {', '.join(changed)}")
return get_all_settings(db)
+88 -3
View File
@@ -1,8 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from typing import Optional
from typing import Optional, List
from database import get_db
from auth import (
@@ -12,10 +11,12 @@ from auth import (
create_refresh_token,
verify_token,
get_current_user,
require_admin,
create_default_admin,
log_action,
)
from models import User
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh, UserUpdate, UserListOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
def logout(response: Response):
response.delete_cookie("access_token")
return {"ok": True}
# Admin user management
@router.get("/users", response_model=List[UserListOut])
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(User).order_by(User.username).all()
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
user = User(
username=payload.username,
password_hash=get_password_hash(payload.password),
role="viewer",
)
db.add(user)
db.commit()
db.refresh(user)
log_action(db, admin, "user_created", f"Created user {user.username} (id={user.id})")
return user
@router.patch("/users/{user_id}", response_model=UserOut)
def update_user(
user_id: int,
payload: UserUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id and payload.role and payload.role != "admin":
raise HTTPException(status_code=400, detail="Cannot downgrade yourself")
if payload.role:
if payload.role not in ("admin", "viewer"):
raise HTTPException(status_code=400, detail="Invalid role")
user.role = payload.role
if payload.password:
user.password_hash = get_password_hash(payload.password)
db.commit()
db.refresh(user)
log_action(db, admin, "user_updated", f"Updated user {user.username} (id={user.id}), role={user.role}, password_changed={bool(payload.password)}")
return user
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# prevent deleting last admin
if user.role == "admin":
admin_count = db.query(User).filter(User.role == "admin").count()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
username = user.username
db.delete(user)
db.commit()
log_action(db, admin, "user_deleted", f"Deleted user {username} (id={user_id})")
return {"ok": True}
+72 -4
View File
@@ -7,9 +7,10 @@ from datetime import datetime, timezone, timedelta
from typing import Optional
from database import get_db
from auth import verify_token
from models import Computer, Heartbeat, User, Notification
from auth import verify_token, require_admin
from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
from notifications import check_offline_computers, get_unread_count
from settings_store import get_all_settings
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@@ -25,6 +26,10 @@ def user_or_redirect(request: Request, db: Session):
return db.query(User).filter(User.username == payload["sub"]).first()
def has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == "admin").count() > 0
@router.get("/", response_class=HTMLResponse)
def index(
request: Request,
@@ -85,8 +90,10 @@ def index(
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "error": None})
def login_page(request: Request, db: Session = Depends(get_db)):
# Allow public registration only while no admin exists
registration_open = not has_admin(db)
return templates.TemplateResponse("login.html", {"request": request, "error": None, "registration_open": registration_open})
@router.get("/computers/{computer_id}", response_class=HTMLResponse)
@@ -153,3 +160,64 @@ def notifications_page(request: Request, db: Session = Depends(get_db)):
"unread_count": get_unread_count(db),
},
)
# Admin pages
@router.get("/admin/users", response_class=HTMLResponse)
def admin_users_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
users = db.query(User).order_by(User.username).all()
return templates.TemplateResponse(
"admin_users.html",
{
"request": request,
"user": current_user,
"users": users,
"unread_count": get_unread_count(db),
},
)
@router.get("/admin/audit", response_class=HTMLResponse)
def admin_audit_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
audit = (
db.query(AuditLog)
.order_by(desc(AuditLog.timestamp))
.limit(200)
.all()
)
return templates.TemplateResponse(
"admin_audit.html",
{
"request": request,
"user": current_user,
"audit": audit,
"unread_count": get_unread_count(db),
},
)
@router.get("/admin/settings", response_class=HTMLResponse)
def admin_settings_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
settings = get_all_settings(db)
return templates.TemplateResponse(
"admin_settings.html",
{
"request": request,
"user": current_user,
"settings": settings,
"unread_count": get_unread_count(db),
},
)