Files
mqtt-ingester/webui/main.py
T
2026-08-30 11:30:09 +02:00

572 lines
20 KiB
Python

"""Web UI for access control: users, groups, topic access rules.
Serves the login page, the admin pages (users, groups), and a telemetry
browser so the effect of access rules can be verified directly. Sessions
are shared with the API via the mqtt_session cookie, so a web login also
authenticates API requests from the same browser.
User/group management is admin-only. Access-rule semantics (globs, deny
wins over allow, user rules override group rules) live in shared/access.py.
"""
import hmac
import os
from contextlib import asynccontextmanager
from typing import Any, Optional
import psycopg
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psycopg import sql
from psycopg.rows import dict_row
import auth
from shared.access import (
COOKIE_NAME,
Identity,
escape_like,
hash_token,
load_identity,
topic_conditions,
)
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
_DictConnection = psycopg.Connection[dict[str, Any]]
pool: Optional[_DictConnection] = None
def _connect_kwargs() -> dict[str, Any]:
return {
"host": os.getenv("DB_HOST", "timescaledb"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "telemetry"),
"user": os.getenv("DB_USER", "ingester"),
"password": os.getenv("DB_PASSWORD", "changeme"),
"row_factory": dict_row,
}
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
# Single long-lived autocommit connection; the web UI's load is tiny
# compared to the API's, so a pool would be overkill.
conn = psycopg.connect(**_connect_kwargs())
pool = conn
try:
# First run: create the initial admin (ENV or default admin/admin).
auth.bootstrap_admin(conn)
yield
finally:
pool = None
conn.close()
app = FastAPI(title="MQTT Ingester Web UI", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
def get_conn() -> _DictConnection:
if pool is None:
raise RuntimeError("Database connection not initialized")
return pool
def current_identity(request: Request) -> Optional[Identity]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
return load_identity(get_conn(), hash_token(token))
def require_login(request: Request) -> Identity:
identity = current_identity(request)
if identity is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
# A default/admin-set password must be changed before anything else.
if identity.must_change_password and request.url.path not in (
"/password",
"/logout",
):
raise HTTPException(status_code=303, headers={"Location": "/password"})
return identity
def require_admin(request: Request) -> Identity:
identity = require_login(request)
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return identity
async def require_csrf(request: Request, identity: Identity) -> None:
"""Validate the CSRF token for a mutating (form) request."""
form = await request.form()
submitted = form.get("csrf_token", "")
if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, identity.csrf_token
):
raise HTTPException(status_code=400, detail="Invalid CSRF token")
def _telemetry_rows(identity: Identity, topic: Optional[str]) -> list[dict[str, Any]]:
"""Recent telemetry filtered by the identity's access rules in SQL."""
conditions, param_groups = topic_conditions(identity)
params: list[Any] = []
if topic:
conditions.append(sql.SQL("topic LIKE %s"))
params.append(escape_like(topic))
params.extend(p for group in param_groups for p in group)
query = sql.SQL("SELECT time, topic, payload FROM telemetry")
if conditions:
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
query += sql.SQL(" ORDER BY time DESC LIMIT 100")
with get_conn().cursor() as cur:
cur.execute(query, params)
return list(cur.fetchall())
# -- Login / logout ---------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
if current_identity(request) is None:
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/data", status_code=303)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
if current_identity(request) is not None:
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": error})
@app.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
conn = get_conn()
ip = request.client.host if request.client else "unknown"
allowed, attempts = auth.check_throttle(conn, ip)
if not allowed:
return templates.TemplateResponse(
request,
"login.html",
{"error": "Too many failed attempts; try again in a few minutes."},
status_code=429,
)
with conn.cursor() as cur:
row = cur.execute(
"SELECT id, password_hash FROM users"
" WHERE username = %s AND disabled_at IS NULL",
(username,),
).fetchone()
if row is None or not auth.verify_password(password, row[1]):
auth.record_failed_login(conn, ip)
return templates.TemplateResponse(
request, "login.html", {"error": "Invalid username or password"}, status_code=401
)
auth.clear_failed_logins(conn, ip)
cookie_token, _csrf = auth.create_session(conn, row[0])
resp = RedirectResponse("/data", status_code=303)
auth.set_cookie(resp, cookie_token)
return resp
@app.post("/logout")
async def logout(request: Request):
identity = current_identity(request)
if identity is not None:
await require_csrf(request, identity)
token = request.cookies.get(COOKIE_NAME, "")
if token:
auth.delete_session(get_conn(), token)
resp = RedirectResponse("/login", status_code=303)
auth.clear_cookie(resp)
return resp
# -- Account (self-service) --------------------------------------------------
@app.get("/password", response_class=HTMLResponse)
async def password_page(request: Request, error: Optional[str] = None):
identity = require_login(request)
return templates.TemplateResponse(
request, "password.html", {"identity": identity, "error": error}
)
@app.post("/password")
async def password_change(
request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
identity = require_login(request)
await require_csrf(request, identity)
conn = get_conn()
def fail(message: str, status: int):
return templates.TemplateResponse(
request,
"password.html",
{"identity": identity, "error": message},
status_code=status,
)
with conn.cursor() as cur:
row = cur.execute(
"SELECT password_hash FROM users WHERE id = %s", (identity.user_id,)
).fetchone()
if row is None or not auth.verify_password(current_password, row[0]):
return fail("Current password is incorrect", 401)
if new_password != confirm_password:
return fail("New passwords do not match", 400)
if len(new_password) < 8:
return fail("New password must be at least 8 characters", 400)
with conn.cursor() as cur:
cur.execute(
"UPDATE users SET password_hash = %s, must_change_password = FALSE"
" WHERE id = %s",
(auth.hash_password(new_password), identity.user_id),
)
return RedirectResponse("/data", status_code=303)
# -- Users (admin) -----------------------------------------------------------
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
identity = require_admin(request)
with get_conn().cursor() as cur:
users = cur.execute(
"""
SELECT u.id, u.username, u.role, u.disabled_at, u.must_change_password,
coalesce(
(SELECT array_agg(g.name ORDER BY g.name)
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id), '{}') AS groups
FROM users u ORDER BY u.username
"""
).fetchall()
groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
return templates.TemplateResponse(
request, "users.html", {"identity": identity, "users": users, "groups": groups}
)
@app.post("/users")
async def create_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form("user"),
group_ids: list[str] = Form(default=[]),
):
identity = require_admin(request)
await require_csrf(request, identity)
conn = get_conn()
username = username.strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="Invalid role")
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
with conn.cursor() as cur:
try:
cur.execute(
"INSERT INTO users (username, password_hash, role) VALUES (%s, %s, %s)",
(username, auth.hash_password(password), role),
)
except psycopg.errors.UniqueViolation:
raise HTTPException(status_code=409, detail="Username already exists")
if group_ids:
user_id = cur.execute(
"SELECT id FROM users WHERE username = %s", (username,)
).fetchone()[0]
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(user_id, int(gid)) for gid in group_ids],
)
return RedirectResponse("/users", status_code=303)
@app.get("/users/{user_id}", response_class=HTMLResponse)
async def user_detail(request: Request, user_id: int):
identity = require_admin(request)
with get_conn().cursor() as cur:
user = cur.execute(
"SELECT id, username, role, disabled_at, must_change_password"
" FROM users WHERE id = %s",
(user_id,),
).fetchone()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
rules = cur.execute(
"SELECT id, kind, pattern FROM access_rules WHERE user_id = %s"
" ORDER BY kind, pattern",
(user_id,),
).fetchall()
member_of = cur.execute(
"SELECT g.id, g.name FROM groups g"
" JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = %s",
(user_id,),
).fetchall()
all_groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
return templates.TemplateResponse(
request,
"user_detail.html",
{
"identity": identity,
"user": user,
"rules": rules,
"member_of": member_of,
"all_groups": all_groups,
},
)
@app.post("/users/{user_id}/password")
async def set_user_password(request: Request, user_id: int, password: str = Form(...)):
identity = require_admin(request)
await require_csrf(request, identity)
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
with get_conn().cursor() as cur:
cur.execute(
"UPDATE users SET password_hash = %s, must_change_password = TRUE"
" WHERE id = %s",
(auth.hash_password(password), user_id),
)
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/groups")
async def set_user_groups(
request: Request, user_id: int, group_ids: list[str] = Form(default=[])
):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
if group_ids:
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(user_id, int(gid)) for gid in group_ids],
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/disable")
async def disable_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"UPDATE users SET disabled_at = now() WHERE id = %s AND disabled_at IS NULL",
(user_id,),
)
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/enable")
async def enable_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("UPDATE users SET disabled_at = NULL WHERE id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/delete")
async def delete_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
if user_id == identity.user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
with get_conn().cursor() as cur:
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/rules")
async def add_user_rule(
request: Request, user_id: int, kind: str = Form(...), pattern: str = Form(...)
):
identity = require_admin(request)
await require_csrf(request, identity)
if kind not in ("allow", "deny"):
raise HTTPException(status_code=400, detail="Invalid kind")
pattern = pattern.strip()
if not pattern:
raise HTTPException(status_code=400, detail="Pattern is required")
with get_conn().cursor() as cur:
cur.execute(
"INSERT INTO access_rules (user_id, kind, pattern) VALUES (%s, %s, %s)",
(user_id, kind, pattern),
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/rules/{rule_id}/delete")
async def delete_user_rule(request: Request, user_id: int, rule_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"DELETE FROM access_rules WHERE id = %s AND user_id = %s", (rule_id, user_id)
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
# -- Groups (admin) -----------------------------------------------------------
@app.get("/groups", response_class=HTMLResponse)
async def groups_page(request: Request):
identity = require_admin(request)
with get_conn().cursor() as cur:
groups = cur.execute(
"""
SELECT g.id, g.name,
(SELECT count(*) FROM user_groups ug WHERE ug.group_id = g.id)
AS member_count,
(SELECT count(*) FROM access_rules ar WHERE ar.group_id = g.id)
AS rule_count
FROM groups g ORDER BY g.name
"""
).fetchall()
return templates.TemplateResponse(
request, "groups.html", {"identity": identity, "groups": groups}
)
@app.post("/groups")
async def create_group(request: Request, name: str = Form(...)):
identity = require_admin(request)
await require_csrf(request, identity)
name = name.strip()
if not name:
raise HTTPException(status_code=400, detail="Group name is required")
with get_conn().cursor() as cur:
try:
cur.execute("INSERT INTO groups (name) VALUES (%s)", (name,))
except psycopg.errors.UniqueViolation:
raise HTTPException(status_code=409, detail="Group name already exists")
return RedirectResponse("/groups", status_code=303)
@app.get("/groups/{group_id}", response_class=HTMLResponse)
async def group_detail(request: Request, group_id: int):
identity = require_admin(request)
with get_conn().cursor() as cur:
group = cur.execute(
"SELECT id, name FROM groups WHERE id = %s", (group_id,)
).fetchone()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
rules = cur.execute(
"SELECT id, kind, pattern FROM access_rules WHERE group_id = %s"
" ORDER BY kind, pattern",
(group_id,),
).fetchall()
members = cur.execute(
"SELECT u.id, u.username FROM users u"
" JOIN user_groups ug ON ug.user_id = u.id WHERE ug.group_id = %s",
(group_id,),
).fetchall()
all_users = cur.execute(
"SELECT id, username FROM users ORDER BY username"
).fetchall()
return templates.TemplateResponse(
request,
"group_detail.html",
{
"identity": identity,
"group": group,
"rules": rules,
"members": members,
"all_users": all_users,
},
)
@app.post("/groups/{group_id}/rules")
async def add_group_rule(
request: Request, group_id: int, kind: str = Form(...), pattern: str = Form(...)
):
identity = require_admin(request)
await require_csrf(request, identity)
if kind not in ("allow", "deny"):
raise HTTPException(status_code=400, detail="Invalid kind")
pattern = pattern.strip()
if not pattern:
raise HTTPException(status_code=400, detail="Pattern is required")
with get_conn().cursor() as cur:
cur.execute(
"INSERT INTO access_rules (group_id, kind, pattern) VALUES (%s, %s, %s)",
(group_id, kind, pattern),
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/rules/{rule_id}/delete")
async def delete_group_rule(request: Request, group_id: int, rule_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"DELETE FROM access_rules WHERE id = %s AND group_id = %s",
(rule_id, group_id),
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/members")
async def set_group_members(
request: Request, group_id: int, user_ids: list[str] = Form(default=[])
):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM user_groups WHERE group_id = %s", (group_id,))
if user_ids:
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(int(uid), group_id) for uid in user_ids],
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/delete")
async def delete_group(request: Request, group_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM groups WHERE id = %s", (group_id,))
return RedirectResponse("/groups", status_code=303)
# -- Data browser -------------------------------------------------------------
@app.get("/data", response_class=HTMLResponse)
async def data_page(request: Request, topic: Optional[str] = None):
identity = require_login(request)
rows = _telemetry_rows(identity, topic)
return templates.TemplateResponse(
request,
"data.html",
{"identity": identity, "rows": rows, "topic": topic or ""},
)