Add a durable security activity center #494

Merged
timmy merged 1 commits from timmy/493-security-activity-center into main 2026-08-10 15:39:50 +00:00
10 changed files with 678 additions and 3 deletions

View File

@ -160,6 +160,8 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>' export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3. # Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/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. # Recommended behind a proxy; WebAuthn assertions must match these public values.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com' export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://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 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. 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 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 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 and authorize high-impact actions with its biometric/PIN gesture. The access token

View File

@ -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 { 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 strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; } .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); } button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; } .panel > summary { cursor: pointer; list-style-position: inside; }

View File

@ -38,6 +38,15 @@
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div> <div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button> <button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div> <div id="active-devices-list" class="active-devices-list"></div>
<section class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
</div>
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
<div id="security-activity-list" class="security-activity-list"></div>
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
</section>
</section> </section>
</div> </div>

View File

@ -40,6 +40,10 @@
const devicesStatus = root.document.getElementById('active-devices-status'); const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices'); const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey'); 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 () => { const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…'; devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren(); devicesList.replaceChildren();
@ -76,10 +80,55 @@
devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; 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', () => { if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
devicesSheet.hidden = false; devicesSheet.hidden = false;
closeDevices?.focus(); closeDevices?.focus();
renderDevices(); renderDevices();
renderSecurityActivity();
}); });
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true; devicesSheet.hidden = true;
@ -494,6 +543,18 @@
return Array.isArray(payload.devices) ? payload.devices : []; 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) { async function revokeActiveDevice(device) {
if (!device?.management_id || device.current) return false; if (!device?.management_id || device.current) return false;
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`); const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
@ -537,6 +598,7 @@
signOut, signOut,
signOutAllDevices, signOutAllDevices,
listActiveDevices, listActiveDevices,
listSecurityEvents,
enrollPasskey, enrollPasskey,
revokeActiveDevice, revokeActiveDevice,
clearPrivateDeviceData, clearPrivateDeviceData,

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.models import Issue, Milestone, PullRequest, Repo, User
from src.passkey_store import PasskeyStore from src.passkey_store import PasskeyStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit 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.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore from src.today_store import TodayPlanFull, TodayStore
@ -243,6 +244,15 @@ def _passkey_store() -> PasskeyStore:
return PasskeyStore(database, clock=time.time) 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]: def _passkey_relying_party(request: Request) -> tuple[str, str]:
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "") rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
origin = os.getenv( 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): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) response = await call_next(request)
path = dashboard_auth.application_path(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/") path.startswith("/api/v1/repos/")
and path.endswith("/review") and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or ( ) or path.startswith("/api/v1/notifications") or (
@ -948,6 +958,21 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
status_code=503, status_code=503,
headers={"Cache-Control": "no-store"}, 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) path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time())) max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie( response.set_cookie(
@ -972,6 +997,38 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
return {"authenticated": True} 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) @app.post("/api/v1/fresh-authorization", status_code=201)
async def fresh_authorization(payload: FreshAuthorization, request: Request): async def fresh_authorization(payload: FreshAuthorization, request: Request):
peer_host = request.client.host if request.client is not None else "unknown" 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") raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=401, detail="Passkey sign-in failed") from 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) path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time())) max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie( response.set_cookie(
@ -1440,12 +1510,23 @@ async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session session = request.state.dashboard_session
try: try:
await asyncio.to_thread(dashboard_auth.revoke_session, session) 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: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(
{"detail": "Session registry is temporarily unavailable"}, {"detail": "Session registry is temporarily unavailable"},
status_code=503, status_code=503,
headers={"Cache-Control": "no-store"}, 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) path = dashboard_auth.cookie_path(request)
response.delete_cookie( response.delete_cookie(
dashboard_auth.SESSION_COOKIE, dashboard_auth.SESSION_COOKIE,
@ -1511,14 +1592,28 @@ async def revoke_active_device(
target = next( target = next(
(device for device in devices if device.management_id == management_id), None (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) await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
revoked = await dashboard_auth.revoke_managed_session(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: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(
{"detail": "Session registry is temporarily unavailable"}, {"detail": "Session registry is temporarily unavailable"},
status_code=503, status_code=503,
headers={"Cache-Control": "no-store"}, 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: if target is None or not revoked:
raise HTTPException(status_code=404, detail="Active device not found") raise HTTPException(status_code=404, detail="Active device not found")
return {"revoked": True, "current_session": target.current} return {"revoked": True, "current_session": target.current}
@ -1541,12 +1636,23 @@ async def sign_out_all_devices(
try: try:
await asyncio.to_thread(_passkey_store().revoke_all) await asyncio.to_thread(_passkey_store().revoke_all)
await dashboard_auth.revoke_all_sessions() await dashboard_auth.revoke_all_sessions()
await asyncio.to_thread(
_security_event_store().record,
"all_sessions_revoked",
target="all_devices",
)
except dashboard_auth.SessionStoreError: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(
{"detail": "Session registry is temporarily unavailable"}, {"detail": "Session registry is temporarily unavailable"},
status_code=503, status_code=503,
headers={"Cache-Control": "no-store"}, 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) path = dashboard_auth.cookie_path(request)
response.delete_cookie( response.delete_cookie(
dashboard_auth.SESSION_COOKIE, dashboard_auth.SESSION_COOKIE,
@ -3209,7 +3315,15 @@ async def close_assigned_issue(
return await gitea_proxy.close_issue(repository, number) return await gitea_proxy.close_issue(repository, number)
try: 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: except HTTPException:
raise raise
except Exception: except Exception:
@ -3458,9 +3572,15 @@ async def merge_assigned_pull(
) )
try: try:
return await asyncio.wait_for( result = await asyncio.wait_for(
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS 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: except HTTPException:
raise raise
except gitea_proxy.StalePullError: except gitea_proxy.StalePullError:

138
src/security_event_store.py Normal file
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,
)

View File

@ -17,6 +17,7 @@ def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") 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_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) 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_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3") monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60") 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", "target": "dashboard",
}, },
) )
activity = await returning.get("/api/v1/security-events")
assert enrolled.status_code == 201 assert enrolled.status_code == 201
assert enrolled.json() == {"enrolled": True} 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 sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA"
assert signed_in.status_code == 200 assert signed_in.status_code == 200
assert signed_in.json() == {"authenticated": True, "method": "passkey"} 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 "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text assert "correct horse battery staple" not in signed_in.text

View File

@ -596,6 +596,24 @@ process.stdout.write(JSON.stringify(state));
assert result["confirmations"] == ["Sign out Pixel <script>?"] assert result["confirmations"] == ["Sign out Pixel <script>?"]
def test_security_activity_pages_are_loaded_with_stable_cursors():
result = run_session_scenario(
"""
state.responsePayload = {events:[{id:7,kind:'sign_in',method:'passkey',device_label:'Phone',target:'dashboard',created_at:1000}],next_cursor:7};
const first = await boundary.listSecurityEvents();
const second = await boundary.listSecurityEvents(first.next_cursor);
state.first = first;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["first"]["events"][0]["method"] == "passkey"
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/security-events?limit=25",
"/dashboard/api/v1/security-events?limit=25&cursor=7",
]
def test_authenticated_dashboard_load_requests_queued_delivery_resume(): def test_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario( result = run_session_scenario(
""" """
@ -618,4 +636,7 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert '<button id="active-devices" type="button">Active devices</button>' in html assert '<button id="active-devices" type="button">Active devices</button>' in html
assert 'id="active-devices-sheet"' in html assert 'id="active-devices-sheet"' in html
assert 'aria-label="Active devices"' in html assert 'aria-label="Active devices"' in html
assert 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html
assert '#sign-out-all { min-height:44px; }' in html assert '#sign-out-all { min-height:44px; }' in html

View File

@ -0,0 +1,245 @@
import sqlite3
import httpx
import pytest
from src import main
from src.security_event_store import SecurityEventStore
@pytest.fixture
def security_access(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
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_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
return tmp_path
@pytest.mark.anyio
async def test_authenticated_security_activity_lists_private_sign_in_history(security_access):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as anonymous:
rejected = await anonymous.get("/api/v1/security-events")
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Timmy's phone",
},
)
activity = await client.get("/api/v1/security-events", params={"limit": 25})
assert rejected.status_code == 401
assert signed_in.status_code == 200
assert activity.status_code == 200
assert activity.headers["cache-control"] == "no-store"
assert activity.json() == {
"events": [
{
"id": 1,
"kind": "sign_in",
"method": "token",
"device_label": "Timmy's phone",
"target": "dashboard",
"created_at": activity.json()["events"][0]["created_at"],
}
],
"next_cursor": None,
}
persisted = (security_access / "security.sqlite3").read_bytes()
assert b"correct horse battery staple" not in persisted
assert b"stackchain_session" not in persisted
@pytest.mark.anyio
async def test_security_activity_limit_is_validated(security_access):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
too_large = await client.get("/api/v1/security-events", params={"limit": 101})
invalid_cursor = await client.get("/api/v1/security-events", params={"cursor": 0})
assert too_large.status_code == 422
assert invalid_cursor.status_code == 422
@pytest.mark.anyio
async def test_revoked_device_activity_survives_live_session_removal(security_access):
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
phone_device = next(device for device in devices if device["device_label"] == "Phone")
csrf = laptop.cookies["stackchain_csrf"]
grant = await laptop.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "revoke_device",
"target": phone_device["management_id"],
},
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
revoked = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": csrf,
"X-Step-Up-Grant": grant.json()["grant"],
},
)
active = (await laptop.get("/api/v1/sessions")).json()["devices"]
history = (await laptop.get("/api/v1/security-events")).json()["events"]
assert revoked.status_code == 200
assert [device["device_label"] for device in active] == ["Laptop"]
assert [(event["kind"], event["device_label"]) for event in history] == [
("device_revoked", "Phone"),
("sign_in", "Laptop"),
("sign_in", "Phone"),
]
@pytest.mark.anyio
async def test_successful_protected_actions_record_only_bounded_targets(
security_access, monkeypatch
):
async def yes(*_args):
return True
async def close(*_args):
return {"number": 7, "state": "closed"}
async def merge(*_args):
return {"number": 9, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", yes)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
csrf_headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
async def grant(action, target):
response = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": action,
"target": target,
},
headers=csrf_headers,
)
return response.json()["grant"]
issue_grant = await grant("close_issue", "stackchain/api#7")
closed = await client.patch(
"/api/v1/repos/stackchain/api/issues/7/close",
headers={**csrf_headers, "X-Step-Up-Grant": issue_grant},
)
pull_grant = await grant("merge_pull", "stackchain/app#9")
merged = await client.post(
"/api/v1/repos/stackchain/app/pulls/9/merge",
json={"expected_head_sha": "abc123"},
headers={**csrf_headers, "X-Step-Up-Grant": pull_grant},
)
events = (await client.get("/api/v1/security-events")).json()["events"]
assert closed.status_code == 200
assert merged.status_code == 200
assert [(event["kind"], event["target"]) for event in events[:2]] == [
("pull_merged", "stackchain/app#9"),
("issue_closed", "stackchain/api#7"),
]
persisted = (security_access / "security.sqlite3").read_bytes()
assert b"abc123" not in persisted
@pytest.mark.anyio
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
signed_out = await client.delete(
"/api/v1/session",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
events = SecurityEventStore(
security_access / "security.sqlite3", clock=lambda: 0
).list(limit=10).events
assert signed_out.status_code == 200
assert [(event.kind, event.device_label) for event in events[:2]] == [
("sign_out", None),
("sign_in", "Phone"),
]
@pytest.mark.anyio
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
csrf_headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "revoke_all_sessions",
"target": "all",
},
headers=csrf_headers,
)
response = await client.delete(
"/api/v1/sessions",
headers={**csrf_headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
events = SecurityEventStore(
security_access / "security.sqlite3", clock=lambda: 0
).list(limit=10).events
assert response.status_code == 200
assert events[0].kind == "all_sessions_revoked"
assert events[0].target == "all_devices"

View File

@ -0,0 +1,54 @@
import sqlite3
from src.security_event_store import SecurityEventStore
def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path):
now = [1_000]
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: now[0])
store.record(
"sign_in",
method="token",
device_label=" Timmy Phone " + "x" * 80,
target="dashboard",
)
now[0] += 1
store.record("device_revoked", device_label="Old phone", target="device")
page = store.list(limit=1)
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
("device_revoked", "Old phone", "device")
]
assert page.next_cursor is not None
older = store.list(limit=10, cursor=page.next_cursor)
assert older.events[0].kind == "sign_in"
assert older.events[0].method == "token"
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
columns = {
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
}
assert columns == {"id", "kind", "method", "device_label", "target", "created_at"}
def test_security_event_retention_prunes_age_and_count(tmp_path):
now = [0]
store = SecurityEventStore(
tmp_path / "security.sqlite3",
clock=lambda: now[0],
max_events=3,
retention_seconds=10,
)
for index in range(4):
now[0] = index
store.record("sign_in", method="token", device_label=f"Device {index}")
assert [event.device_label for event in store.list(limit=10).events] == [
"Device 3", "Device 2", "Device 1"
]
now[0] = 20
store.record("sign_out", device_label="Current")
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]