feat: add durable security activity center (Closes #493)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 15:37:37 +00:00
1 parent 4749d15d85
commit 6565165295
10 files changed
+678 -3

No files matched your search

+123 -3
View File
@@ -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:
+138
View File
@@ -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,
)