From 65651652958d51c12e915f4f08fb2be4dafd1acb Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 15:37:37 +0000 Subject: [PATCH] feat: add durable security activity center (Closes #493) --- README.md | 10 + frontend/dashboard.css | 6 + frontend/index.html | 9 + frontend/session.js | 62 ++++++ src/main.py | 126 +++++++++++- src/security_event_store.py | 138 +++++++++++++ tests/test_dashboard_auth.py | 10 + tests/test_dashboard_session_frontend.py | 21 ++ tests/test_security_activity.py | 245 +++++++++++++++++++++++ tests/test_security_event_store.py | 54 +++++ 10 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 src/security_event_store.py create mode 100644 tests/test_security_activity.py create mode 100644 tests/test_security_event_store.py diff --git a/README.md b/README.md index b94777a..51af481 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='' export STACKCHAIN_DASHBOARD_SESSION_SECRET='' # Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3. export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' +# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3. +export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3' # Recommended behind a proxy; WebAuthn assertions must match these public values. export STACKCHAIN_PASSKEY_RP_ID='forge.example.com' export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com' @@ -205,6 +207,14 @@ session hashes, CSRF proofs, or source addresses. The registry also stores each session's last explicit activity. Existing two-column registries are migrated in place, their live sessions remain valid, and their idle clock starts at migration. +The same sheet includes **Security activity**, a reverse-chronological journal of +successful token/passkey sign-ins, sign-outs, remote device revocations, issue +closures, and pull-request merges. The separate SQLite journal retains at most +10,000 events for 90 days and stores only bounded device labels and action targets. +It never stores access tokens, cookies, session/CSRF values, credential IDs, raw +network addresses, request bodies, or comment content. Keep its database on the +same class of persistent, writable storage as the session registry. + After token bootstrap, **Active devices → Add a passkey for this device** enrolls a WebAuthn credential with required user verification. That device can then sign in and authorize high-impact actions with its biometric/PIN gesture. The access token diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a1bcd9a..5612081 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -21,6 +21,12 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; } .active-device strong, .active-device span { display:block; overflow-wrap:anywhere; } .active-device-current { color:#55d6be; font-weight:700; } +.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; } +.security-activity-header h3, .security-activity-header p { margin:0 0 6px; } +.security-activity-list { display:grid; gap:8px; margin:12px 0; } +.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; } +.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; } +#load-more-security-activity { width:100%; min-height:44px; } button:hover { filter: brightness(1.15); } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } .panel > summary { cursor: pointer; list-style-position: inside; } diff --git a/frontend/index.html b/frontend/index.html index e3f6023..b448e54 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,6 +38,15 @@
+
+
+

Security activity

+

Recent sign-ins, device changes, and protected actions.

+
+
+
+ +
diff --git a/frontend/session.js b/frontend/session.js index d1005f8..a478017 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -40,6 +40,10 @@ const devicesStatus = root.document.getElementById('active-devices-status'); const closeDevices = root.document.getElementById('close-active-devices'); const enrollPasskey = root.document.getElementById('enroll-passkey'); + const activityList = root.document.getElementById('security-activity-list'); + const activityStatus = root.document.getElementById('security-activity-status'); + const loadMoreActivity = root.document.getElementById('load-more-security-activity'); + let activityCursor = null; const renderDevices = async () => { devicesStatus.textContent = 'Loading active devices…'; devicesList.replaceChildren(); @@ -76,10 +80,55 @@ devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; } }; + const renderSecurityActivity = async (append = false) => { + if (!activityList || !activityStatus || !loadMoreActivity) return; + activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…'; + loadMoreActivity.hidden = true; + if (!append) { + activityCursor = null; + activityList.replaceChildren(); + } + try { + const page = await boundary.listSecurityEvents(append ? activityCursor : null); + const labels = { + sign_in: 'Signed in', + sign_out: 'Signed out', + device_revoked: 'Device access revoked', + all_sessions_revoked: 'All device access revoked', + issue_closed: 'Issue closed', + pull_merged: 'Pull request merged', + }; + page.events.forEach(event => { + const row = root.document.createElement('article'); + row.className = 'security-event'; + const title = root.document.createElement('strong'); + title.textContent = labels[event.kind] || 'Security event'; + const details = root.document.createElement('span'); + details.className = 'small muted'; + const context = [event.device_label, event.method, event.target] + .filter(value => typeof value === 'string' && value).join(' · '); + details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`; + row.append(title, details); + activityList.append(row); + }); + activityCursor = page.next_cursor; + activityStatus.textContent = activityList.children.length + ? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}` + : 'No security activity yet.'; + loadMoreActivity.textContent = 'Load older activity'; + loadMoreActivity.hidden = !activityCursor; + } catch (_error) { + activityStatus.textContent = 'Security activity could not be loaded.'; + loadMoreActivity.textContent = 'Retry activity'; + loadMoreActivity.hidden = false; + } + }; + loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor))); if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => { devicesSheet.hidden = false; closeDevices?.focus(); renderDevices(); + renderSecurityActivity(); }); if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { devicesSheet.hidden = true; @@ -494,6 +543,18 @@ return Array.isArray(payload.devices) ? payload.devices : []; } + async function listSecurityEvents(cursor = null) { + const query = new URLSearchParams({ limit: '25' }); + if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor)); + const response = await sessionFetch(base + 'api/v1/security-events?' + query); + if (!response.ok) throw new Error('Could not load security activity'); + const payload = await response.json(); + return { + events: Array.isArray(payload.events) ? payload.events : [], + next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null, + }; + } + async function revokeActiveDevice(device) { if (!device?.management_id || device.current) return false; const confirmed = confirmAction?.(`Sign out ${device.device_label}?`); @@ -537,6 +598,7 @@ signOut, signOutAllDevices, listActiveDevices, + listSecurityEvents, enrollPasskey, revokeActiveDevice, clearPrivateDeviceData, diff --git a/src/main.py b/src/main.py index 2baa82d..f57b66d 100644 --- a/src/main.py +++ b/src/main.py @@ -43,6 +43,7 @@ from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, Refres from src.models import Issue, Milestone, PullRequest, Repo, User from src.passkey_store import PasskeyStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit +from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute from src.later_store import LaterStore from src.today_store import TodayPlanFull, TodayStore @@ -243,6 +244,15 @@ def _passkey_store() -> PasskeyStore: return PasskeyStore(database, clock=time.time) +def _security_event_store() -> SecurityEventStore: + state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") + database = os.getenv( + "STACKCHAIN_SECURITY_EVENT_DB", + os.path.join(state_dir, "security-events.sqlite3"), + ) + return SecurityEventStore(database, clock=time.time) + + def _passkey_relying_party(request: Request) -> tuple[str, str]: rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "") origin = os.getenv( @@ -821,7 +831,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later"} or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -948,6 +958,21 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response status_code=503, headers={"Cache-Control": "no-store"}, ) + try: + await asyncio.to_thread( + _security_event_store().record, + "sign_in", + method="token", + device_label=payload.device_label, + target="dashboard", + ) + except SecurityEventStoreError: + await asyncio.to_thread(dashboard_auth.revoke_session, session) + return JSONResponse( + {"detail": "Security activity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) path = dashboard_auth.cookie_path(request) max_age = max(1, session.expires_at - int(time.time())) response.set_cookie( @@ -972,6 +997,38 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response return {"authenticated": True} +@app.get("/api/v1/security-events") +async def list_security_events( + limit: int = Query(default=25, ge=1, le=100), + cursor: int | None = Query(default=None, ge=1), +): + try: + page = await asyncio.to_thread( + _security_event_store().list, limit=limit, cursor=cursor + ) + except SecurityEventStoreError: + raise HTTPException( + status_code=503, detail="Security activity is temporarily unavailable" + ) + return JSONResponse( + { + "events": [ + { + "id": event.id, + "kind": event.kind, + "method": event.method, + "device_label": event.device_label, + "target": event.target, + "created_at": event.created_at, + } + for event in page.events + ], + "next_cursor": page.next_cursor, + }, + headers={"Cache-Control": "no-store"}, + ) + + @app.post("/api/v1/fresh-authorization", status_code=201) async def fresh_authorization(payload: FreshAuthorization, request: Request): peer_host = request.client.host if request.client is not None else "unknown" @@ -1260,6 +1317,19 @@ async def verify_passkey_authentication( raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") except Exception as exc: raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc + try: + await asyncio.to_thread( + _security_event_store().record, + "sign_in", + method="passkey", + device_label=stored.device_label, + target="dashboard", + ) + except SecurityEventStoreError: + await asyncio.to_thread(dashboard_auth.revoke_session, session) + raise HTTPException( + status_code=503, detail="Security activity is temporarily unavailable" + ) path = dashboard_auth.cookie_path(request) max_age = max(1, session.expires_at - int(time.time())) response.set_cookie( @@ -1440,12 +1510,23 @@ async def sign_out(request: Request, response: Response): session = request.state.dashboard_session try: await asyncio.to_thread(dashboard_auth.revoke_session, session) + await asyncio.to_thread( + _security_event_store().record, + "sign_out", + target="current_device", + ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + except SecurityEventStoreError: + return JSONResponse( + {"detail": "Security activity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, @@ -1511,14 +1592,28 @@ async def revoke_active_device( target = next( (device for device in devices if device.management_id == management_id), None ) + await asyncio.to_thread(_security_event_store().list, limit=1) await asyncio.to_thread(_passkey_store().revoke_management_id, management_id) revoked = await dashboard_auth.revoke_managed_session(management_id) + if target is not None and revoked: + await asyncio.to_thread( + _security_event_store().record, + "device_revoked", + device_label=target.device_label, + target="device", + ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + except SecurityEventStoreError: + return JSONResponse( + {"detail": "Security activity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) if target is None or not revoked: raise HTTPException(status_code=404, detail="Active device not found") return {"revoked": True, "current_session": target.current} @@ -1541,12 +1636,23 @@ async def sign_out_all_devices( try: await asyncio.to_thread(_passkey_store().revoke_all) await dashboard_auth.revoke_all_sessions() + await asyncio.to_thread( + _security_event_store().record, + "all_sessions_revoked", + target="all_devices", + ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + except SecurityEventStoreError: + return JSONResponse( + {"detail": "Security activity is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, @@ -3209,7 +3315,15 @@ async def close_assigned_issue( return await gitea_proxy.close_issue(repository, number) try: - return await asyncio.wait_for(close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) + result = await asyncio.wait_for( + close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS + ) + await asyncio.to_thread( + _security_event_store().record, + "issue_closed", + target=f"{repository}#{number}", + ) + return result except HTTPException: raise except Exception: @@ -3458,9 +3572,15 @@ async def merge_assigned_pull( ) try: - return await asyncio.wait_for( + result = await asyncio.wait_for( merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) + await asyncio.to_thread( + _security_event_store().record, + "pull_merged", + target=f"{repository}#{number}", + ) + return result except HTTPException: raise except gitea_proxy.StalePullError: diff --git a/src/security_event_store.py b/src/security_event_store.py new file mode 100644 index 0000000..a6f7d5b --- /dev/null +++ b/src/security_event_store.py @@ -0,0 +1,138 @@ +"""Bounded, privacy-preserving journal of operator security activity.""" + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +class SecurityEventStoreError(RuntimeError): + """Raised when security activity cannot be persisted or read safely.""" + + +@dataclass(frozen=True) +class SecurityEvent: + id: int + kind: str + method: str | None + device_label: str | None + target: str | None + created_at: int + + +@dataclass(frozen=True) +class SecurityEventPage: + events: list[SecurityEvent] + next_cursor: int | None + + +class SecurityEventStore: + def __init__( + self, + path: str | Path, + *, + clock: Callable[[], float], + max_events: int = 10_000, + retention_seconds: int = 90 * 24 * 60 * 60, + lock_timeout_seconds: float = 0.1, + ) -> None: + self.path = Path(path) + self.clock = clock + self.max_events = max(1, max_events) + self.retention_seconds = max(1, retention_seconds) + self.lock_timeout_seconds = lock_timeout_seconds + + @staticmethod + def _bounded(value: str | None, limit: int) -> str | None: + if value is None: + return None + normalized = " ".join(str(value).split())[:limit] + return normalized or None + + def _connect(self) -> sqlite3.Connection: + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS security_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + method TEXT, + device_label TEXT, + target TEXT, + created_at INTEGER NOT NULL + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS security_events_created " + "ON security_events(created_at DESC, id DESC)" + ) + return connection + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + + def record( + self, + kind: str, + *, + method: str | None = None, + device_label: str | None = None, + target: str | None = None, + ) -> None: + now = int(self.clock()) + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM security_events WHERE created_at < ?", + (now - self.retention_seconds,), + ) + connection.execute( + "INSERT INTO security_events(kind, method, device_label, target, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + self._bounded(kind, 48) or "security_event", + self._bounded(method, 32), + self._bounded(device_label, 64), + self._bounded(target, 255), + now, + ), + ) + connection.execute( + "DELETE FROM security_events WHERE id NOT IN " + "(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)", + (self.max_events,), + ) + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + + def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage: + bounded_limit = min(100, max(1, limit)) + parameters: list[int] = [] + where = "" + if cursor is not None: + where = "WHERE id < ?" + parameters.append(cursor) + parameters.append(bounded_limit + 1) + try: + with self._connect() as connection: + rows = connection.execute( + "SELECT id, kind, method, device_label, target, created_at " + f"FROM security_events {where} ORDER BY id DESC LIMIT ?", + parameters, + ).fetchall() + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + has_more = len(rows) > bounded_limit + visible = rows[:bounded_limit] + return SecurityEventPage( + events=[SecurityEvent(*row) for row in visible], + next_cursor=visible[-1][0] if has_more else None, + ) diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index c3fc802..1cee68a 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -17,6 +17,7 @@ def access_control(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) + monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security-events.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3") monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60") @@ -136,6 +137,7 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token( "target": "dashboard", }, ) + activity = await returning.get("/api/v1/security-events") assert enrolled.status_code == 201 assert enrolled.json() == {"enrolled": True} @@ -143,6 +145,14 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token( assert sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA" assert signed_in.status_code == 200 assert signed_in.json() == {"authenticated": True, "method": "passkey"} + assert activity.json()["events"][0] == { + "id": activity.json()["events"][0]["id"], + "kind": "sign_in", + "method": "passkey", + "device_label": "Phone", + "target": "dashboard", + "created_at": activity.json()["events"][0]["created_at"], + } assert "stackchain_session=" in signed_in.headers["set-cookie"] assert "correct horse battery staple" not in signed_in.text diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 153ecb2..566c2a3 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -596,6 +596,24 @@ process.stdout.write(JSON.stringify(state)); assert result["confirmations"] == ["Sign out Pixel