Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor

- FastAPI + htmx + Jinja2 web frontend, started with --web flag
- JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) +
  refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db
- RBAC: superadmin > admin > reader enforced per route
- Live SSE dashboard fed by tui/events broadcast queue
- Config editor: keyword groups and channel list saved to data/runtime_config.json
  and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed)
- config.py migrated to load groups/channels from runtime_config.json;
  falls back to hardcoded defaults when file absent
- tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed
- utils/scorer.py: import config as _config (fixes local binding); reload_from_config()
- utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains
- 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement,
  config round-trip); total 141 passing
This commit is contained in:
2026-04-02 11:41:46 -03:00
parent b28168c846
commit 4c104cddd2
32 changed files with 2093 additions and 47 deletions

0
web/routes/__init__.py Normal file
View File

106
web/routes/auth.py Normal file
View File

@@ -0,0 +1,106 @@
"""
web/routes/auth.py — Login, logout, token refresh.
POST /login — form submit; sets access_token + refresh_token cookies
POST /logout — revokes refresh token, clears cookies
POST /refresh — exchanges refresh_token cookie for a new access_token
"""
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from web import auth as auth_lib
from web import db
router = APIRouter()
def _set_auth_cookies(response: Response, access_token: str, refresh_token: str) -> None:
response.set_cookie(
"access_token", access_token,
httponly=True, samesite="strict", max_age=auth_lib.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
response.set_cookie(
"refresh_token", refresh_token,
httponly=True, samesite="strict", max_age=auth_lib.REFRESH_TOKEN_EXPIRE_DAYS * 86400,
)
def _clear_auth_cookies(response: Response) -> None:
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
def _templates(request: Request) -> Jinja2Templates:
return request.app.state.templates
@router.get("/login")
async def login_page(request: Request):
return _templates(request).TemplateResponse(request, "login.html")
@router.post("/login")
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
user = db.get_user_by_username(username)
if user is None or not auth_lib.verify_password(password, user["password_hash"]):
return _templates(request).TemplateResponse(
request, "login.html",
{"error": "Invalid username or password"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token = auth_lib.create_access_token(user["id"], user["role"])
refresh_token, jti, expires_at = auth_lib.create_refresh_token(user["id"])
db.store_refresh_token(jti, user["id"], expires_at)
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_303_SEE_OTHER)
_set_auth_cookies(response, access_token, refresh_token)
return response
@router.post("/logout")
async def logout(request: Request):
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
payload = auth_lib.decode_refresh_token(refresh_token)
if payload and payload.get("jti"):
db.revoke_refresh_token(payload["jti"])
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
_clear_auth_cookies(response)
return response
@router.post("/refresh")
async def refresh(request: Request):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token")
payload = auth_lib.decode_refresh_token(refresh_token)
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
jti = payload.get("jti")
if not jti or not db.is_refresh_token_valid(jti):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
user = db.get_user_by_id(payload["sub"])
if user is None or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
# Rotate: revoke old, issue new
db.revoke_refresh_token(jti)
new_access = auth_lib.create_access_token(user["id"], user["role"])
new_refresh, new_jti, expires_at = auth_lib.create_refresh_token(user["id"])
db.store_refresh_token(new_jti, user["id"], expires_at)
response = Response(status_code=status.HTTP_204_NO_CONTENT)
_set_auth_cookies(response, new_access, new_refresh)
return response

View File

@@ -0,0 +1,66 @@
"""
web/routes/config_routes.py — Keyword groups and channel list management.
GET /config/keywords → render groups editor
PUT /config/keywords → validate + save groups, reload scorer
GET /config/channels → render channel list
PUT /config/channels → save channels, signal bot to re-watch
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
import config
import utils.scorer as scorer
from tui import events as bus
from web.dependencies import require_role
from web.models import ChannelsPayload, KeywordGroupsPayload
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
@router.get("/config/keywords")
async def keywords_page(request: Request, user=Depends(require_role("admin"))):
return _templates(request).TemplateResponse(
request, "config.html",
{
"user": dict(user),
"groups": config.KEYWORD_GROUPS,
"channels": config.WATCHED_CHANNELS,
"active_tab": "keywords",
},
)
@router.put("/config/keywords")
async def update_keywords(payload: KeywordGroupsPayload, _user=Depends(require_role("admin"))):
groups = [g.model_dump() for g in payload.groups]
config.save_runtime_config(groups, config.WATCHED_CHANNELS)
scorer.reload_from_config()
return {"status": "ok", "groups": len(groups)}
@router.get("/config/channels")
async def channels_page(request: Request, user=Depends(require_role("admin"))):
return _templates(request).TemplateResponse(
request, "config.html",
{
"user": dict(user),
"groups": config.KEYWORD_GROUPS,
"channels": config.WATCHED_CHANNELS,
"active_tab": "channels",
},
)
@router.put("/config/channels")
async def update_channels(payload: ChannelsPayload, _user=Depends(require_role("admin"))):
config.save_runtime_config(config.KEYWORD_GROUPS, payload.channels)
bus.signal_channel_changed()
return {"status": "ok", "channels": len(payload.channels)}

108
web/routes/dashboard.py Normal file
View File

@@ -0,0 +1,108 @@
"""
web/routes/dashboard.py — Dashboard views and SSE live stream.
GET / → redirect to /dashboard
GET /dashboard → overview: all groups, stats, live hit feed
GET /dashboard/groups/{group_id} → per-group hits + filtered SSE
GET /api/stream → SSE event stream (one hit per event)
GET /api/stats → JSON severity counts (for hx-trigger refresh)
"""
import asyncio
import json
import queue
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
import config
from tui import events as bus
from utils import database as hitdb
from web.dependencies import require_role
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
def _hit_to_dict(hit) -> dict:
reasons_raw = hit["reasons"] or ""
reasons = [r.strip() for r in reasons_raw.split("|") if r.strip()]
return {
"severity": hit["severity"],
"score": hit["score"],
"raw": hit["raw"],
"source": hit["source"],
"filename": hit["filename"],
"reasons": reasons,
"timestamp": hit["timestamp"],
}
@router.get("/")
async def root():
return RedirectResponse(url="/dashboard")
@router.get("/dashboard")
async def dashboard(request: Request, user=Depends(require_role("reader"))):
hits = [_hit_to_dict(h) for h in hitdb.recent(limit=50)]
counts = hitdb.count_by_severity()
groups = config.KEYWORD_GROUPS
return _templates(request).TemplateResponse(
request, "dashboard.html",
{"user": dict(user), "hits": hits, "counts": counts, "groups": groups},
)
@router.get("/dashboard/groups/{group_id}")
async def group_detail(request: Request, group_id: str, user=Depends(require_role("reader"))):
groups = config.KEYWORD_GROUPS
group = next((g for g in groups if g["id"] == group_id), None)
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
patterns = [p["regex"] for p in group.get("patterns", [])]
hits = [_hit_to_dict(h) for h in hitdb.recent_for_domains(patterns, limit=100)]
counts = hitdb.count_by_severity_for_domains(patterns)
return _templates(request).TemplateResponse(
request, "group_detail.html",
{"user": dict(user), "group": group, "hits": hits, "counts": counts},
)
@router.get("/api/stream")
async def event_stream(request: Request, _user=Depends(require_role("reader"))):
"""Server-Sent Events: one data frame per EvHit."""
q = bus.subscribe()
async def generate():
try:
while True:
if await request.is_disconnected():
break
try:
ev = q.get_nowait()
if isinstance(ev, bus.EvHit):
payload = json.dumps({
"severity": ev.severity,
"raw": ev.raw,
"source": ev.source,
"filename": ev.filename,
"reasons": ev.reasons,
})
yield f"data: {payload}\n\n"
except queue.Empty:
await asyncio.sleep(0.1)
finally:
bus.unsubscribe(q)
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/api/stats")
async def stats(_user=Depends(require_role("reader"))):
return hitdb.count_by_severity()

82
web/routes/users.py Normal file
View File

@@ -0,0 +1,82 @@
"""
web/routes/users.py — User CRUD (superadmin only).
GET /users → list all users
POST /users → create a new user
PATCH /users/{id} → update role / password / active flag
DELETE /users/{id} → deactivate (cannot delete self or other superadmins)
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from web import db
from web.dependencies import require_role
from web.models import CreateUserRequest, UpdateUserRequest
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
@router.get("/users")
async def list_users(request: Request, user=Depends(require_role("superadmin"))):
users = db.list_users()
return _templates(request).TemplateResponse(
request, "users.html",
{"user": dict(user), "users": [dict(u) for u in users]},
)
@router.post("/users")
async def create_user(
payload: CreateUserRequest,
_user=Depends(require_role("superadmin")),
):
try:
user_id = db.create_user(payload.username, payload.password, payload.role)
except Exception as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return {"id": user_id}
@router.patch("/users/{user_id}")
async def update_user(
user_id: str,
payload: UpdateUserRequest,
acting_user=Depends(require_role("superadmin")),
):
target = db.get_user_by_id(user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
# Cannot demote another superadmin via role patch
if target["role"] == "superadmin" and payload.role and payload.role != "superadmin":
raise HTTPException(status_code=403, detail="Cannot demote another superadmin")
updates: dict = {}
if payload.password is not None:
updates["password"] = payload.password
if payload.role is not None:
updates["role"] = payload.role
if payload.is_active is not None:
updates["is_active"] = 1 if payload.is_active else 0
db.update_user(user_id, **updates)
return {"status": "ok"}
@router.delete("/users/{user_id}")
async def deactivate_user(
user_id: str,
acting_user=Depends(require_role("superadmin")),
):
if user_id == acting_user["id"]:
raise HTTPException(status_code=403, detail="Cannot deactivate yourself")
target = db.get_user_by_id(user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
db.deactivate_user(user_id)
return {"status": "ok"}