Skip to content

Commit c84f1d9

Browse files
committed
feat: add RBAC, atomic audit trail with case notes, SSE live updates, and filter presets
1 parent 6188e4b commit c84f1d9

9 files changed

Lines changed: 865 additions & 10 deletions

File tree

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,81 @@ flowchart LR
157157
python -m pytest tests/ -v
158158
```
159159

160+
## Skills Demonstrated
161+
162+
| Skill | Details |
163+
|---|---|
164+
| SOC Workflow | RBAC (viewer/analyst/admin), atomic audit trail with encrypted case notes, Server-Sent Events for live queue updates, quick filter presets |
165+
166+
## Roles & Permissions
167+
168+
SOC Dashboard has three roles, enforced server-side on every protected route:
169+
170+
| Role | Permissions |
171+
|---|---|
172+
| **viewer** | Read-only: dashboard, alert queue, charts, KPIs. Cannot triage, escalate, or add notes. |
173+
| **analyst** | Everything viewer can do, plus: triage alerts (TP/FP/escalate), add case notes. |
174+
| **admin** | Everything analyst can do, plus: view and search the audit log, manage users. |
175+
176+
Create accounts from the CLI:
177+
178+
```bash
179+
python manage.py create-user alice 'passphrase' --role analyst
180+
python manage.py create-user bob 'passphrase' --role viewer
181+
python manage.py create-user carol 'passphrase' --role admin
182+
```
183+
184+
Existing analyst and admin accounts continue to work identically — no migration required. Apply the new schema (which adds `audit_log`) with:
185+
186+
```bash
187+
psql soc_dashboard -f schema.sql
188+
```
189+
190+
## Audit Trail
191+
192+
Every status change (triage, escalate, reclassify) and case note is recorded in the `audit_log` table **atomically** with the alert update — if the alert write fails, the audit row is rolled back too.
193+
194+
**Case notes:**
195+
```bash
196+
curl -X POST http://localhost:8000/api/alerts/42/notes \
197+
-H "Content-Type: application/json" \
198+
-b "session=..." \
199+
-d '{"note": "Confirmed C2 callback — escalating to IR."}'
200+
```
201+
202+
**Audit history for an alert:**
203+
```
204+
GET /api/alerts/<id>/audit → JSON array of audit entries
205+
```
206+
207+
**Full audit log** (admin only):
208+
```
209+
GET /audit → searchable, paginated HTML page
210+
```
211+
212+
Note text is encrypted at rest with the same Fernet key as other PII fields (`DB_ENCRYPTION_KEY`).
213+
214+
## Real-Time Updates
215+
216+
The dashboard connects to a Server-Sent Events (SSE) stream at `GET /api/stream`. When a new alert is ingested or an alert's status changes, the queue table and KPI cards update live without a page refresh.
217+
218+
The existing 30-second polling loop remains active as a fallback — SSE is the primary path; if the `EventSource` connection fails, polling keeps the queue current.
219+
220+
**Limitation:** the in-process pub/sub only works within a single Gunicorn worker. For multi-worker production deployments, replace `_sse_publish`/`_sse_subscribe` with a Redis pub/sub adapter. The current implementation is correct and sufficient for single-worker or development deployments.
221+
222+
## Saved Filter Views
223+
224+
Quick-filter preset buttons above the alert queue let an analyst jump to common views in one click:
225+
226+
| Preset | Shows |
227+
|---|---|
228+
| **My Queue** | Open alerts assigned to the current analyst (uses localStorage name) |
229+
| **Critical Today** | Open CRITICAL alerts created today (uses `created_after` param) |
230+
| **Escalated** | All alerts with status = escalated |
231+
| **All Open** | The default open queue |
232+
233+
These presets combine with the existing severity/source/assignee filters. The underlying `/api/alerts` endpoint now accepts a `created_after` ISO datetime parameter alongside the existing filter params.
234+
160235
## ⚖️ Legal Notice & Responsible Use
161236

162237
This project is **free and open-source software**, released under the **MIT License** as a

app.py

Lines changed: 246 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
"""SOC Analyst Dashboard — Flask + psycopg2 (no ORM)."""
22
import hmac
33
import os
4+
import queue
5+
import threading
46
from datetime import date, datetime, timezone
7+
from functools import wraps
58

69
import bcrypt
710
import psycopg2
811
import psycopg2.extras
912
from dotenv import load_dotenv
1013
from flask import (
1114
Flask,
15+
Response,
1216
abort,
1317
flash,
1418
jsonify,
@@ -20,6 +24,7 @@
2024
from flask_login import (
2125
LoginManager,
2226
UserMixin,
27+
current_user,
2328
login_required,
2429
login_user,
2530
logout_user,
@@ -72,6 +77,56 @@
7277
else "[*] Field encryption DISABLED — set DB_ENCRYPTION_KEY to encrypt PII at rest"
7378
)
7479

80+
# --------------------------------------------------------------------------- #
81+
# Role-based access control
82+
# --------------------------------------------------------------------------- #
83+
def require_role(*roles):
84+
"""Decorator: require current_user.role to be in roles, or abort 403."""
85+
def decorator(f):
86+
@wraps(f)
87+
def wrapped(*args, **kwargs):
88+
if not current_user.is_authenticated:
89+
abort(401)
90+
if current_user.role not in roles:
91+
abort(403, description="insufficient role")
92+
return f(*args, **kwargs)
93+
return wrapped
94+
return decorator
95+
96+
97+
# --------------------------------------------------------------------------- #
98+
# In-process pub/sub for Server-Sent Events
99+
# --------------------------------------------------------------------------- #
100+
_sse_subscribers: list = []
101+
_sse_lock = threading.Lock()
102+
103+
104+
def _sse_publish(event: dict) -> None:
105+
"""Broadcast an event dict to all SSE subscribers."""
106+
with _sse_lock:
107+
dead = []
108+
for q in _sse_subscribers:
109+
try:
110+
q.put_nowait(event)
111+
except queue.Full:
112+
dead.append(q)
113+
for q in dead:
114+
_sse_subscribers.remove(q)
115+
116+
117+
def _sse_subscribe():
118+
q = queue.Queue(maxsize=100)
119+
with _sse_lock:
120+
_sse_subscribers.append(q)
121+
return q
122+
123+
124+
def _sse_unsubscribe(q) -> None:
125+
with _sse_lock:
126+
if q in _sse_subscribers:
127+
_sse_subscribers.remove(q)
128+
129+
75130
# --------------------------------------------------------------------------- #
76131
# Authentication (Flask-Login)
77132
# --------------------------------------------------------------------------- #
@@ -211,6 +266,11 @@ def alert_filters(extra=None):
211266
if value:
212267
where_sql.append(f"{column} = %s")
213268
params.append(value)
269+
# created_after: ISO datetime string (additive param, no existing filter affected)
270+
created_after = (request.args.get("created_after") or "").strip()
271+
if created_after:
272+
where_sql.append("created_at >= %s")
273+
params.append(created_after)
214274
sql = (" WHERE " + " AND ".join(where_sql)) if where_sql else ""
215275
return sql, params
216276

@@ -396,11 +456,19 @@ def api_ingest_alert():
396456
)
397457
created = cur.fetchone()
398458

399-
return jsonify(serialize(decrypt_alert(created))), 201
459+
row = serialize(decrypt_alert(dict(created)))
460+
_sse_publish({
461+
"type": "new_alert",
462+
"alert_id": row["id"],
463+
"severity": row["severity"],
464+
"status": row["status"],
465+
})
466+
return jsonify(row), 201
400467

401468

402469
@app.route("/api/alerts/<int:alert_id>/classify", methods=["POST"])
403470
@login_required
471+
@require_role("analyst", "admin")
404472
def api_classify(alert_id):
405473
"""Classify an alert: update status, record the analyst action + MTTR."""
406474
body = request.get_json(silent=True) or {}
@@ -445,8 +513,31 @@ def api_classify(alert_id):
445513
""",
446514
(alert_id, analyst_name, action, alert["created_at"]),
447515
)
516+
# Audit trail — same transaction as the alert update (atomic).
517+
cur.execute(
518+
"""
519+
INSERT INTO audit_log
520+
(alert_id, user_id, username, action, from_status, to_status)
521+
VALUES (%s, %s, %s, %s, %s, %s)
522+
""",
523+
(
524+
alert_id,
525+
int(current_user.id),
526+
current_user.username,
527+
action,
528+
alert["status"],
529+
new_status,
530+
),
531+
)
448532

449-
return jsonify(serialize(updated))
533+
result = serialize(updated)
534+
_sse_publish({
535+
"type": "status_change",
536+
"alert_id": alert_id,
537+
"severity": updated.get("severity"),
538+
"status": new_status,
539+
})
540+
return jsonify(result)
450541

451542

452543
@app.route("/api/stats")
@@ -556,6 +647,159 @@ def api_stats():
556647
)
557648

558649

650+
# --------------------------------------------------------------------------- #
651+
# Audit trail routes
652+
# --------------------------------------------------------------------------- #
653+
@app.route("/api/alerts/<int:alert_id>/audit")
654+
@login_required
655+
def api_alert_audit(alert_id):
656+
"""Return the audit history for a single alert as JSON."""
657+
with get_conn() as conn, conn.cursor() as cur:
658+
cur.execute(
659+
"""
660+
SELECT id, alert_id, user_id, username, action,
661+
from_status, to_status, note, created_at
662+
FROM audit_log
663+
WHERE alert_id = %s
664+
ORDER BY created_at ASC
665+
""",
666+
(alert_id,),
667+
)
668+
rows = cur.fetchall()
669+
result = []
670+
for r in rows:
671+
row = dict(r)
672+
row["note"] = decrypt_field(FERNET, row["note"])
673+
result.append(serialize(row))
674+
return jsonify(result)
675+
676+
677+
@app.route("/audit")
678+
@login_required
679+
@require_role("admin")
680+
def audit_log_page():
681+
"""Paginated audit log view, admin-only."""
682+
page = max(1, int(request.args.get("page", 1)))
683+
per_page = 50
684+
offset = (page - 1) * per_page
685+
search = (request.args.get("q") or "").strip()
686+
687+
with get_conn() as conn, conn.cursor() as cur:
688+
if search:
689+
cur.execute(
690+
"""
691+
SELECT al.id, al.alert_id, al.username, al.action,
692+
al.from_status, al.to_status, al.created_at, al.note
693+
FROM audit_log al
694+
WHERE al.username ILIKE %s OR al.action ILIKE %s
695+
ORDER BY al.created_at DESC
696+
LIMIT %s OFFSET %s
697+
""",
698+
(f"%{search}%", f"%{search}%", per_page, offset),
699+
)
700+
else:
701+
cur.execute(
702+
"""
703+
SELECT al.id, al.alert_id, al.username, al.action,
704+
al.from_status, al.to_status, al.created_at, al.note
705+
FROM audit_log al
706+
ORDER BY al.created_at DESC
707+
LIMIT %s OFFSET %s
708+
""",
709+
(per_page, offset),
710+
)
711+
rows = cur.fetchall()
712+
cur.execute("SELECT count(*) AS c FROM audit_log")
713+
total = cur.fetchone()["c"]
714+
715+
entries = []
716+
for r in rows:
717+
row = dict(r)
718+
row["note"] = decrypt_field(FERNET, row["note"])
719+
entries.append(serialize(row))
720+
721+
return render_template(
722+
"audit.html",
723+
entries=entries,
724+
page=page,
725+
per_page=per_page,
726+
total=total,
727+
search=search,
728+
)
729+
730+
731+
@app.route("/api/alerts/<int:alert_id>/notes", methods=["POST"])
732+
@login_required
733+
@require_role("analyst", "admin")
734+
def api_add_note(alert_id):
735+
"""Add a case note to an alert. Stored encrypted, logged in audit_log."""
736+
body = request.get_json(silent=True) or {}
737+
note = (body.get("note") or "").strip()
738+
if not note:
739+
abort(400, description="note is required")
740+
741+
with get_conn() as conn, conn.cursor() as cur:
742+
cur.execute("SELECT status FROM alerts WHERE id = %s", (alert_id,))
743+
alert = cur.fetchone()
744+
if alert is None:
745+
abort(404, description="alert not found")
746+
747+
cur.execute(
748+
"""
749+
INSERT INTO audit_log
750+
(alert_id, user_id, username, action, from_status, to_status, note)
751+
VALUES (%s, %s, %s, 'note_added', %s, %s, %s)
752+
RETURNING id, created_at
753+
""",
754+
(
755+
alert_id,
756+
int(current_user.id),
757+
current_user.username,
758+
alert["status"],
759+
alert["status"],
760+
encrypt_field(FERNET, note),
761+
),
762+
)
763+
row = cur.fetchone()
764+
765+
return jsonify({"id": row["id"], "created_at": row["created_at"].isoformat()}), 201
766+
767+
768+
# --------------------------------------------------------------------------- #
769+
# Server-Sent Events
770+
# --------------------------------------------------------------------------- #
771+
@app.route("/api/stream")
772+
@login_required
773+
def api_stream():
774+
"""SSE endpoint for live queue updates.
775+
776+
Publishes new_alert and status_change events. Uses an in-process Queue;
777+
only works within a single worker process. For multi-worker deployments,
778+
replace with Redis pub/sub.
779+
"""
780+
import json as _json
781+
782+
def generate():
783+
q = _sse_subscribe()
784+
try:
785+
while True:
786+
try:
787+
event = q.get(timeout=30)
788+
yield f"data: {_json.dumps(event)}\n\n"
789+
except queue.Empty:
790+
yield ": keepalive\n\n"
791+
except GeneratorExit:
792+
pass
793+
finally:
794+
_sse_unsubscribe(q)
795+
796+
return Response(
797+
generate(),
798+
mimetype="text/event-stream",
799+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
800+
)
801+
802+
559803
# Enforce retention once at startup (covers both `python app.py` and gunicorn
560804
# import). Guarded so an unreachable DB at boot never crashes the app.
561805
if ALERT_RETENTION_DAYS > 0:

0 commit comments

Comments
 (0)