Merge pull request 'Add a durable security activity center' (#494) from timmy/493-security-activity-center into main
This commit is contained in:
commit
37aa2dd090
10
README.md
10
README.md
|
|
@ -160,6 +160,8 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
|||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@
|
|||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
126
src/main.py
126
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:
|
||||
|
|
|
|||
138
src/security_event_store.py
Normal file
138
src/security_event_store.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -596,6 +596,24 @@ process.stdout.write(JSON.stringify(state));
|
|||
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():
|
||||
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 'id="active-devices-sheet"' 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
|
||||
|
|
|
|||
245
tests/test_security_activity.py
Normal file
245
tests/test_security_activity.py
Normal 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"
|
||||
54
tests/test_security_event_store.py
Normal file
54
tests/test_security_event_store.py
Normal 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"]
|
||||
Loading…
Reference in New Issue
Block a user