summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/audit.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 23:11:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 23:11:36 +0200
commit35130e5528a52161630fd1c93572e1b2b7cd911b (patch)
tree50954e2da56e186e2651ad838093eb6c09c259a8 /packages/meshbay-node/src/meshbay_node/audit.py
parentc66ee41d8476461939c5f4e7fdc71c5d7fb4a85c (diff)
downloadmeshbay-35130e5528a52161630fd1c93572e1b2b7cd911b.tar.gz
feat(node): audit logging + local admin UI rewrite
Add SQLite audit store for legal compliance (LCEN/DSA): logs user IP, actions (handshake, file download/upload/delete, stream, chat), and timestamps. Retention: 1 year, with cleanup method. WebRTC transport now logs all user actions to the audit store with remote IP extraction from the ICE transport. Local web UI rewritten as a proper admin dashboard: - Stats cards (groups, files, peers) - Connected peers table with IP, username, group, state - Group cards with file listings and shared directory info - Audit log page with event/user filtering - Dark theme, responsive, auto-refresh - JSON API: /api/status, /api/groups, /api/peers, /api/audit, /api/config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/audit.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/audit.py148
1 files changed, 148 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/audit.py b/packages/meshbay-node/src/meshbay_node/audit.py
new file mode 100644
index 0000000..6346f16
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/audit.py
@@ -0,0 +1,148 @@
+"""
+MeshBay Node — SQLite audit log for legal compliance.
+
+Logs user actions with IP address, timestamp, and details.
+Required by LCEN (France), EU e-Commerce Directive, and DSA
+for hosting service operators.
+
+Retention: 1 year minimum. Cleanup is caller's responsibility.
+"""
+
+import logging
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import aiosqlite
+
+log = logging.getLogger(__name__)
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp REAL NOT NULL,
+ user_id TEXT NOT NULL,
+ username TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ event TEXT NOT NULL,
+ group_id TEXT NOT NULL DEFAULT '',
+ detail TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
+CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
+CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log(event);
+"""
+
+EVENTS = {
+ "connect",
+ "disconnect",
+ "handshake",
+ "file_download",
+ "file_upload",
+ "file_delete",
+ "stream_video",
+ "chat_message",
+ "chat_history",
+ "index_sync",
+ "auth_failed",
+}
+
+RETENTION_DAYS = 365
+
+
+@dataclass
+class AuditEntry:
+ id: int
+ timestamp: float
+ user_id: str
+ username: str
+ ip: str
+ event: str
+ group_id: str
+ detail: str
+
+
+class AuditStore:
+ """Async SQLite audit log."""
+
+ def __init__(self, db_path: Path):
+ self._db_path = db_path
+ self._db: aiosqlite.Connection | None = None
+
+ async def open(self) -> None:
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._db = await aiosqlite.connect(str(self._db_path))
+ await self._db.executescript(_SCHEMA)
+ await self._db.commit()
+
+ async def close(self) -> None:
+ if self._db:
+ await self._db.close()
+ self._db = None
+
+ async def log_event(
+ self,
+ user_id: str,
+ event: str,
+ ip: str = "",
+ username: str = "",
+ group_id: str = "",
+ detail: str = "",
+ ) -> None:
+ if not self._db:
+ return
+ await self._db.execute(
+ "INSERT INTO audit_log (timestamp, user_id, username, ip, event, group_id, detail) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (time.time(), user_id, username, ip, event, group_id, detail),
+ )
+ await self._db.commit()
+
+ async def get_entries(
+ self,
+ since: float = 0,
+ limit: int = 200,
+ user_id: str | None = None,
+ event: str | None = None,
+ ) -> list[AuditEntry]:
+ conditions = ["timestamp > ?"]
+ params: list = [since]
+ if user_id:
+ conditions.append("user_id = ?")
+ params.append(user_id)
+ if event:
+ conditions.append("event = ?")
+ params.append(event)
+ params.append(limit)
+
+ where = " AND ".join(conditions)
+ cursor = await self._db.execute(
+ f"SELECT id, timestamp, user_id, username, ip, event, group_id, detail "
+ f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ?",
+ params,
+ )
+ rows = await cursor.fetchall()
+ return [
+ AuditEntry(
+ id=r[0], timestamp=r[1], user_id=r[2], username=r[3],
+ ip=r[4], event=r[5], group_id=r[6], detail=r[7],
+ )
+ for r in rows
+ ]
+
+ async def entry_count(self) -> int:
+ if not self._db:
+ return 0
+ cursor = await self._db.execute("SELECT COUNT(*) FROM audit_log")
+ row = await cursor.fetchone()
+ return row[0]
+
+ async def cleanup(self, retention_days: int = RETENTION_DAYS) -> int:
+ """Delete entries older than retention_days. Returns count deleted."""
+ if not self._db:
+ return 0
+ cutoff = time.time() - (retention_days * 86400)
+ cursor = await self._db.execute(
+ "DELETE FROM audit_log WHERE timestamp < ?", (cutoff,))
+ await self._db.commit()
+ return cursor.rowcount