feat: add durable security activity center (Closes #493)
This commit is contained in:
1 parent
4749d15d85
commit
6565165295
10 files changed
+678
-3
No files matched your search
+123
-3
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user