From d1128f4aa3bcb655063b2e713db76ca41ca99f3a Mon Sep 17 00:00:00 2001 From: Hidosi Date: Thu, 16 Jul 2026 17:28:04 +0700 Subject: [PATCH] Add RBAC: admin user management, audit log, runtime settings, delete PC --- server/auth.py | 14 +++ server/database.py | 14 +++ server/main.py | 3 +- server/models.py | 10 +++ server/notifications.py | 34 ++++++-- server/routes/api_routes.py | 73 +++++++++++++++- server/routes/auth_routes.py | 91 +++++++++++++++++++- server/routes/web_routes.py | 76 +++++++++++++++- server/schemas.py | 38 ++++++++ server/settings_store.py | 52 +++++++++++ server/templates/admin_audit.html | 35 ++++++++ server/templates/admin_settings.html | 69 +++++++++++++++ server/templates/admin_users.html | 124 +++++++++++++++++++++++++++ server/templates/base.html | 15 +++- server/templates/dashboard.html | 28 +++++- server/templates/login.html | 38 ++++++++ 16 files changed, 690 insertions(+), 24 deletions(-) create mode 100644 server/settings_store.py create mode 100644 server/templates/admin_audit.html create mode 100644 server/templates/admin_settings.html create mode 100644 server/templates/admin_users.html diff --git a/server/auth.py b/server/auth.py index d237baf..a56ebd9 100644 --- a/server/auth.py +++ b/server/auth.py @@ -74,6 +74,20 @@ def require_admin(user: models.User = Depends(get_current_user)): return user +def log_action(db: Session, user: Optional[models.User], action: str, details: Optional[str] = None): + try: + entry = models.AuditLog( + user_id=user.id if user else None, + username=user.username if user else None, + action=action, + details=details, + ) + db.add(entry) + db.commit() + except Exception: + db.rollback() + + def create_default_admin(db: Session) -> Optional[models.User]: if not settings.CREATE_ADMIN_ON_STARTUP: return None diff --git a/server/database.py b/server/database.py index 8b42bcd..8961bed 100644 --- a/server/database.py +++ b/server/database.py @@ -24,3 +24,17 @@ def get_db(): def create_tables(): Base.metadata.create_all(bind=engine) + + +def run_migrations(): + """Apply lightweight schema migrations for SQLite.""" + from sqlalchemy import inspect, text + insp = inspect(engine) + + # AuditLog: add username column if missing + if "audit_log" in insp.get_table_names(): + audit_cols = {c["name"] for c in insp.get_columns("audit_log")} + if "username" not in audit_cols: + with engine.connect() as conn: + conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT")) + conn.commit() diff --git a/server/main.py b/server/main.py index ee61bf5..74050ac 100644 --- a/server/main.py +++ b/server/main.py @@ -3,7 +3,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager -from database import create_tables, SessionLocal +from database import create_tables, run_migrations, SessionLocal from auth import create_default_admin from routes import auth_router, api_router, web_router @@ -11,6 +11,7 @@ from routes import auth_router, api_router, web_router @asynccontextmanager async def lifespan(app: FastAPI): create_tables() + run_migrations() db = SessionLocal() try: create_default_admin(db) diff --git a/server/models.py b/server/models.py index 7e077bc..8fa2305 100644 --- a/server/models.py +++ b/server/models.py @@ -54,6 +54,7 @@ class AuditLog(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + username = Column(String, nullable=True) action = Column(String, nullable=False) details = Column(Text, nullable=True) timestamp = Column(DateTime(timezone=True), server_default=func.now()) @@ -71,3 +72,12 @@ class Notification(Base): sent_at = Column(DateTime(timezone=True), server_default=func.now()) read_at = Column(DateTime(timezone=True), nullable=True) + +class Setting(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String, unique=True, nullable=False, index=True) + value = Column(Text, nullable=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + diff --git a/server/notifications.py b/server/notifications.py index 544a9e9..b5c126d 100644 --- a/server/notifications.py +++ b/server/notifications.py @@ -6,11 +6,19 @@ from sqlalchemy.orm import Session from config import get_settings from models import Computer, Notification +from settings_store import get_setting settings = get_settings() logger = logging.getLogger(__name__) +def _int_or_default(value, default): + try: + return int(value) + except (TypeError, ValueError): + return default + + def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification: n = Notification( computer_id=computer.id, @@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str, db.add(n) db.commit() db.refresh(n) - send_external_notifications(n, computer) + send_external_notifications(db, n, computer) return n def check_offline_computers(db: Session): """Mark online computers that missed the threshold as offline and create notifications.""" - threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.OFFLINE_THRESHOLD_MINUTES) + threshold_minutes = _int_or_default( + get_setting(db, "OFFLINE_THRESHOLD_MINUTES"), + settings.OFFLINE_THRESHOLD_MINUTES, + ) + threshold = datetime.now(timezone.utc) - timedelta(minutes=threshold_minutes) stale = ( db.query(Computer) .filter(Computer.status == "online", Computer.last_seen < threshold) @@ -49,7 +61,7 @@ def check_offline_computers(db: Session): if not existing: msg = ( f"ПК {computer.hostname} не присылал данные более " - f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. " + f"{threshold_minutes} минут. " f"Последний пользователь: {computer.current_user or '-'}, " f"IP: {computer.current_ip or '-'}" ) @@ -57,11 +69,15 @@ def check_offline_computers(db: Session): db.commit() -def send_external_notifications(notification: Notification, computer: Computer): - if settings.NOTIFICATION_WEBHOOK_URL: +def send_external_notifications(db: Session, notification: Notification, computer: Computer): + webhook_url = get_setting(db, "NOTIFICATION_WEBHOOK_URL") or settings.NOTIFICATION_WEBHOOK_URL + telegram_token = get_setting(db, "NOTIFICATION_TELEGRAM_BOT_TOKEN") or settings.NOTIFICATION_TELEGRAM_BOT_TOKEN + telegram_chat = get_setting(db, "NOTIFICATION_TELEGRAM_CHAT_ID") or settings.NOTIFICATION_TELEGRAM_CHAT_ID + + if webhook_url: try: requests.post( - settings.NOTIFICATION_WEBHOOK_URL, + webhook_url, json={ "type": notification.type, "title": notification.title, @@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer): except Exception as e: logger.error("Webhook notification failed: %s", e) - if settings.NOTIFICATION_TELEGRAM_BOT_TOKEN and settings.NOTIFICATION_TELEGRAM_CHAT_ID: + if telegram_token and telegram_chat: try: text = f"*{notification.title}*\n\n{notification.message}" requests.post( - f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage", + f"https://api.telegram.org/bot{telegram_token}/sendMessage", json={ - "chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID, + "chat_id": telegram_chat, "text": text, "parse_mode": "Markdown", }, diff --git a/server/routes/api_routes.py b/server/routes/api_routes.py index 9abc9cf..98a655f 100644 --- a/server/routes/api_routes.py +++ b/server/routes/api_routes.py @@ -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) diff --git a/server/routes/auth_routes.py b/server/routes/auth_routes.py index bbd727e..fb8e3af 100644 --- a/server/routes/auth_routes.py +++ b/server/routes/auth_routes.py @@ -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} diff --git a/server/routes/web_routes.py b/server/routes/web_routes.py index 6e985ea..ed2ca6c 100644 --- a/server/routes/web_routes.py +++ b/server/routes/web_routes.py @@ -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), + }, + ) diff --git a/server/schemas.py b/server/schemas.py index 42d7121..74f4603 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -17,6 +17,20 @@ class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) +class UserUpdate(BaseModel): + role: Optional[str] = None + password: Optional[str] = None + + +class UserListOut(BaseModel): + id: int + username: str + role: str + created_at: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) + + class Token(BaseModel): access_token: str refresh_token: str @@ -90,3 +104,27 @@ class NotificationOut(BaseModel): read_at: Optional[datetime] model_config = ConfigDict(from_attributes=True) + + +class AuditLogOut(BaseModel): + id: int + user_id: Optional[int] + username: Optional[str] + action: str + details: Optional[str] + timestamp: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) + + +class SettingOut(BaseModel): + key: str + value: Optional[str] + + +class SettingsUpdate(BaseModel): + AGENT_TOKEN: Optional[str] = None + OFFLINE_THRESHOLD_MINUTES: Optional[int] = None + NOTIFICATION_WEBHOOK_URL: Optional[str] = None + NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None + NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None diff --git a/server/settings_store.py b/server/settings_store.py new file mode 100644 index 0000000..2ed9112 --- /dev/null +++ b/server/settings_store.py @@ -0,0 +1,52 @@ +from typing import Optional +from sqlalchemy.orm import Session +from models import Setting +from config import get_settings + +_settings_cache = {} + + +def get_setting(db: Session, key: str) -> Optional[str]: + """Get setting from DB, fallback to .env config.""" + s = db.query(Setting).filter(Setting.key == key).first() + if s: + return s.value + + config = get_settings() + return getattr(config, key, None) + + +def set_setting(db: Session, key: str, value: Optional[str]) -> Setting: + s = db.query(Setting).filter(Setting.key == key).first() + if s: + s.value = value + else: + s = Setting(key=key, value=value) + db.add(s) + db.commit() + db.refresh(s) + _settings_cache[key] = value + return s + + +def get_all_settings(db: Session) -> dict: + """Return merged DB settings and .env defaults.""" + config = get_settings() + result = {} + keys = [ + "AGENT_TOKEN", + "OFFLINE_THRESHOLD_MINUTES", + "NOTIFICATION_WEBHOOK_URL", + "NOTIFICATION_TELEGRAM_BOT_TOKEN", + "NOTIFICATION_TELEGRAM_CHAT_ID", + ] + for key in keys: + db_val = db.query(Setting).filter(Setting.key == key).first() + value = db_val.value if db_val else getattr(config, key, None) + if key == "OFFLINE_THRESHOLD_MINUTES" and value is not None: + try: + value = int(value) + except (TypeError, ValueError): + value = getattr(config, key, None) + result[key] = value + return result diff --git a/server/templates/admin_audit.html b/server/templates/admin_audit.html new file mode 100644 index 0000000..f25895c --- /dev/null +++ b/server/templates/admin_audit.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Аудит-лог — InfoUser Monitor{% endblock %} + +{% block content %} +
+

Аудит-лог

+ ← Назад +
+ +
+
+ + + + + + + + + + + {% for a in audit %} + + + + + + + {% endfor %} + +
ВремяПользовательДействиеДетали
{{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }}{{ a.username or '-' }}{{ a.action }}{{ a.details or '-' }}
+
+
+{% endblock %} diff --git a/server/templates/admin_settings.html b/server/templates/admin_settings.html new file mode 100644 index 0000000..a153729 --- /dev/null +++ b/server/templates/admin_settings.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}Настройки — InfoUser Monitor{% endblock %} + +{% block content %} +
+

Настройки

+ ← Назад +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
Настройки сохранены
+
+
+ + +{% endblock %} diff --git a/server/templates/admin_users.html b/server/templates/admin_users.html new file mode 100644 index 0000000..ede199c --- /dev/null +++ b/server/templates/admin_users.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block title %}Управление пользователями — InfoUser Monitor{% endblock %} + +{% block content %} +
+

Пользователи

+ ← Назад +
+ +
+
+
Создать пользователя
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
IDЛогинРольСоздан
{{ u.id }}{{ u.username }} + {{ u.role }} + {{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }} + + + +
+ + +{% endblock %} diff --git a/server/templates/base.html b/server/templates/base.html index 5d17205..a71bfe3 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -14,8 +14,21 @@
InfoUser Monitor {% if user %} -