Add a durable security activity center #494
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>'
|
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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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.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
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_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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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