Make destructive actions truthful when security journaling fails #498
|
|
@ -213,7 +213,11 @@ 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.
|
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
|
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
|
||||||
network addresses, request bodies, or comment content. Keep its database on the
|
network addresses, request bodies, or comment content. Keep its database on the
|
||||||
same class of persistent, writable storage as the session registry.
|
same class of persistent, writable storage as the session registry. Before a
|
||||||
|
session revocation or issue closure, the journal durably reserves a pending event;
|
||||||
|
if that reservation fails, the destructive action does not begin. A successful
|
||||||
|
action remains truthfully reported even if its event cannot immediately be finalized,
|
||||||
|
and the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,12 @@
|
||||||
title.textContent = labels[event.kind] || 'Security event';
|
title.textContent = labels[event.kind] || 'Security event';
|
||||||
const details = root.document.createElement('span');
|
const details = root.document.createElement('span');
|
||||||
details.className = 'small muted';
|
details.className = 'small muted';
|
||||||
const context = [event.device_label, event.method, event.target]
|
const context = [
|
||||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
event.device_label,
|
||||||
|
event.method,
|
||||||
|
event.target,
|
||||||
|
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||||
|
].filter(value => typeof value === 'string' && value).join(' · ');
|
||||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||||
row.append(title, details);
|
row.append(title, details);
|
||||||
activityList.append(row);
|
activityList.append(row);
|
||||||
|
|
|
||||||
139
src/main.py
139
src/main.py
|
|
@ -1020,6 +1020,7 @@ async def list_security_events(
|
||||||
"device_label": event.device_label,
|
"device_label": event.device_label,
|
||||||
"target": event.target,
|
"target": event.target,
|
||||||
"created_at": event.created_at,
|
"created_at": event.created_at,
|
||||||
|
"status": event.status,
|
||||||
}
|
}
|
||||||
for event in page.events
|
for event in page.events
|
||||||
],
|
],
|
||||||
|
|
@ -1508,25 +1509,38 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||||
@app.delete("/api/v1/session")
|
@app.delete("/api/v1/session")
|
||||||
async def sign_out(request: Request, response: Response):
|
async def sign_out(request: Request, response: Response):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
journal = _security_event_store()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
operation_id = await asyncio.to_thread(
|
||||||
await asyncio.to_thread(
|
journal.reserve,
|
||||||
_security_event_store().record,
|
|
||||||
"sign_out",
|
"sign_out",
|
||||||
target="current_device",
|
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:
|
except SecurityEventStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"detail": "Security activity is temporarily unavailable"},
|
{"detail": "Security activity is temporarily unavailable"},
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
# The pending reservation is durable evidence; do not report a
|
||||||
|
# successful revocation as failed merely because completion could not
|
||||||
|
# be marked yet.
|
||||||
|
pass
|
||||||
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,
|
||||||
|
|
@ -1592,30 +1606,44 @@ 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)
|
|
||||||
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"},
|
||||||
)
|
)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Active device not found")
|
||||||
|
|
||||||
|
journal = _security_event_store()
|
||||||
|
try:
|
||||||
|
operation_id = await asyncio.to_thread(
|
||||||
|
journal.reserve,
|
||||||
|
"device_revoked",
|
||||||
|
device_label=target.device_label,
|
||||||
|
target="device",
|
||||||
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"detail": "Security activity is temporarily unavailable"},
|
{"detail": "Security activity is temporarily unavailable"},
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
if target is None or not revoked:
|
try:
|
||||||
|
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
|
||||||
|
revoked = await dashboard_auth.revoke_managed_session(management_id)
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
if not revoked:
|
||||||
raise HTTPException(status_code=404, detail="Active device not found")
|
raise HTTPException(status_code=404, detail="Active device not found")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return {"revoked": True, "current_session": target.current}
|
return {"revoked": True, "current_session": target.current}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1633,26 +1661,32 @@ async def sign_out_all_devices(
|
||||||
action="revoke_all_sessions",
|
action="revoke_all_sessions",
|
||||||
target="all",
|
target="all",
|
||||||
)
|
)
|
||||||
|
journal = _security_event_store()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_passkey_store().revoke_all)
|
operation_id = await asyncio.to_thread(
|
||||||
await dashboard_auth.revoke_all_sessions()
|
journal.reserve,
|
||||||
await asyncio.to_thread(
|
|
||||||
_security_event_store().record,
|
|
||||||
"all_sessions_revoked",
|
"all_sessions_revoked",
|
||||||
target="all_devices",
|
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:
|
except SecurityEventStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"detail": "Security activity is temporarily unavailable"},
|
{"detail": "Security activity is temporarily unavailable"},
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_passkey_store().revoke_all)
|
||||||
|
await dashboard_auth.revoke_all_sessions()
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
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,
|
||||||
|
|
@ -3309,21 +3343,20 @@ async def close_assigned_issue(
|
||||||
target=f"{repository}#{number}",
|
target=f"{repository}#{number}",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def close_issue():
|
target = f"{repository}#{number}"
|
||||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
|
||||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
|
||||||
return await gitea_proxy.close_issue(repository, number)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
assigned = await asyncio.wait_for(
|
||||||
close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
gitea_proxy.is_assigned_issue(repository, number),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(
|
if not assigned:
|
||||||
_security_event_store().record,
|
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||||
|
journal = _security_event_store()
|
||||||
|
operation_id = await asyncio.to_thread(
|
||||||
|
journal.reserve,
|
||||||
"issue_closed",
|
"issue_closed",
|
||||||
target=f"{repository}#{number}",
|
target=target,
|
||||||
)
|
)
|
||||||
return result
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -3333,6 +3366,30 @@ async def close_assigned_issue(
|
||||||
headers={"Retry-After": "1"},
|
headers={"Retry-After": "1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.close_issue(repository, number),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The issue could not be closed. It remains in My Work; please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
# The close result is authoritative; the pending reservation preserves
|
||||||
|
# evidence without telling the operator the issue remains open.
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
|
||||||
async def assigned_issue_label_options(
|
async def assigned_issue_label_options(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Bounded, privacy-preserving journal of operator security activity."""
|
"""Bounded, privacy-preserving journal of operator security activity."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -18,6 +19,7 @@ class SecurityEvent:
|
||||||
device_label: str | None
|
device_label: str | None
|
||||||
target: str | None
|
target: str | None
|
||||||
created_at: int
|
created_at: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -61,20 +63,48 @@ class SecurityEventStore:
|
||||||
method TEXT,
|
method TEXT,
|
||||||
device_label TEXT,
|
device_label TEXT,
|
||||||
target TEXT,
|
target TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
operation_id TEXT
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
columns = {
|
||||||
|
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
|
||||||
|
}
|
||||||
|
if "status" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
|
||||||
|
)
|
||||||
|
if "operation_id" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||||
"ON security_events(created_at DESC, id DESC)"
|
"ON security_events(created_at DESC, id DESC)"
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||||
|
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||||
|
)
|
||||||
return connection
|
return connection
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise SecurityEventStoreError(
|
raise SecurityEventStoreError(
|
||||||
"Security activity is temporarily unavailable"
|
"Security activity is temporarily unavailable"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM security_events WHERE created_at < ?",
|
||||||
|
(now - self.retention_seconds,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM security_events WHERE id NOT IN "
|
||||||
|
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
||||||
|
(self.max_events,),
|
||||||
|
)
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
|
@ -87,12 +117,8 @@ class SecurityEventStore:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"DELETE FROM security_events WHERE created_at < ?",
|
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||||
(now - self.retention_seconds,),
|
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO security_events(kind, method, device_label, target, created_at) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(
|
(
|
||||||
self._bounded(kind, 48) or "security_event",
|
self._bounded(kind, 48) or "security_event",
|
||||||
self._bounded(method, 32),
|
self._bounded(method, 32),
|
||||||
|
|
@ -101,10 +127,65 @@ class SecurityEventStore:
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self._prune(connection, now)
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SecurityEventStoreError(
|
||||||
|
"Security activity is temporarily unavailable"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def reserve(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
method: str | None = None,
|
||||||
|
device_label: str | None = None,
|
||||||
|
target: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
now = int(self.clock())
|
||||||
|
operation_id = secrets.token_urlsafe(24)
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"DELETE FROM security_events WHERE id NOT IN "
|
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||||
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||||
(self.max_events,),
|
(
|
||||||
|
self._bounded(kind, 48) or "security_event",
|
||||||
|
self._bounded(method, 32),
|
||||||
|
self._bounded(device_label, 64),
|
||||||
|
self._bounded(target, 255),
|
||||||
|
now,
|
||||||
|
operation_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._prune(connection, now)
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SecurityEventStoreError(
|
||||||
|
"Security activity is temporarily unavailable"
|
||||||
|
) from exc
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def finalize(self, operation_id: str) -> None:
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
|
||||||
|
(operation_id,),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise SecurityEventStoreError(
|
||||||
|
"Security activity reservation was not found"
|
||||||
|
)
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SecurityEventStoreError(
|
||||||
|
"Security activity is temporarily unavailable"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def discard(self, operation_id: str) -> None:
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
|
||||||
|
(operation_id,),
|
||||||
)
|
)
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise SecurityEventStoreError(
|
raise SecurityEventStoreError(
|
||||||
|
|
@ -122,7 +203,7 @@ class SecurityEventStore:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT id, kind, method, device_label, target, created_at "
|
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||||
parameters,
|
parameters,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||||
"device_label": "Phone",
|
"device_label": "Phone",
|
||||||
"target": "dashboard",
|
"target": "dashboard",
|
||||||
"created_at": activity.json()["events"][0]["created_at"],
|
"created_at": activity.json()["events"][0]["created_at"],
|
||||||
|
"status": "completed",
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,13 @@ process.stdout.write(JSON.stringify(state));
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_activity_explains_pending_outcome_confirmation():
|
||||||
|
source = SESSION_JS.read_text()
|
||||||
|
|
||||||
|
assert "Outcome confirmation pending" in source
|
||||||
|
assert "event.status === 'pending'" in source
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
from src.security_event_store import SecurityEventStore
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -50,6 +50,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||||
"device_label": "Timmy's phone",
|
"device_label": "Timmy's phone",
|
||||||
"target": "dashboard",
|
"target": "dashboard",
|
||||||
"created_at": activity.json()["events"][0]["created_at"],
|
"created_at": activity.json()["events"][0]["created_at"],
|
||||||
|
"status": "completed",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"next_cursor": None,
|
"next_cursor": None,
|
||||||
|
|
@ -121,6 +122,54 @@ async def test_revoked_device_activity_survives_live_session_removal(security_ac
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_remote_revocation_reservation_failure_preserves_the_device(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
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"]
|
||||||
|
target = next(device for device in devices if device["device_label"] == "Phone")
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
grant = await laptop.post(
|
||||||
|
"/api/v1/fresh-authorization",
|
||||||
|
json={
|
||||||
|
"access_token": "correct horse battery staple",
|
||||||
|
"action": "revoke_device",
|
||||||
|
"target": target["management_id"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
class UnavailableJournal:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||||
|
response = await laptop.delete(
|
||||||
|
f"/api/v1/sessions/{target['management_id']}",
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||||
|
)
|
||||||
|
remaining = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert {device["device_label"] for device in remaining} == {"Phone", "Laptop"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_successful_protected_actions_record_only_bounded_targets(
|
async def test_successful_protected_actions_record_only_bounded_targets(
|
||||||
security_access, monkeypatch
|
security_access, monkeypatch
|
||||||
|
|
@ -185,6 +234,104 @@ async def test_successful_protected_actions_record_only_bounded_targets(
|
||||||
assert b"abc123" not in persisted
|
assert b"abc123" not in persisted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
close_calls = []
|
||||||
|
|
||||||
|
async def yes(*_args):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(*args):
|
||||||
|
close_calls.append(args)
|
||||||
|
return {"number": 7, "state": "closed"}
|
||||||
|
|
||||||
|
class UnavailableJournal:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
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": "close_issue",
|
||||||
|
"target": "stackchain/api#7",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert close_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_close_finalization_failure_still_reports_the_closed_issue(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
async def yes(*_args):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(*_args):
|
||||||
|
return {"number": 7, "state": "closed"}
|
||||||
|
|
||||||
|
class FinalizationUnavailable:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
return "durable-operation"
|
||||||
|
|
||||||
|
def finalize(self, _operation_id):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
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": "close_issue",
|
||||||
|
"target": "stackchain/api#7",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||||
|
)
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"number": 7, "state": "closed"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
|
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
@ -211,6 +358,71 @@ async def test_sign_out_is_journaled_before_the_session_is_removed(security_acce
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sign_out_reservation_failure_preserves_the_active_session(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
class UnavailableJournal:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/session",
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
still_authenticated = await client.get("/api/v1/sessions")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert still_authenticated.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sign_out_finalization_failure_reports_success_and_clears_cookies(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FinalizationUnavailable:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
return "durable-operation"
|
||||||
|
|
||||||
|
def finalize(self, _operation_id):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||||
|
)
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/session",
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rejected = await client.get("/api/v1/sessions")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["authenticated"] is False
|
||||||
|
assert "stackchain_session=" in response.headers["set-cookie"]
|
||||||
|
assert rejected.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
@ -243,3 +455,42 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert events[0].kind == "all_sessions_revoked"
|
assert events[0].kind == "all_sessions_revoked"
|
||||||
assert events[0].target == "all_devices"
|
assert events[0].target == "all_devices"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sign_out_all_reservation_failure_preserves_the_active_session(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
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=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
class UnavailableJournal:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||||
|
)
|
||||||
|
still_authenticated = await client.get("/api/v1/sessions")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert still_authenticated.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
||||||
columns = {
|
columns = {
|
||||||
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
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"}
|
assert columns == {
|
||||||
|
"id", "kind", "method", "device_label", "target", "created_at",
|
||||||
|
"status", "operation_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||||
|
|
@ -52,3 +55,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||||
now[0] = 20
|
now[0] = 20
|
||||||
store.record("sign_out", device_label="Current")
|
store.record("sign_out", device_label="Current")
|
||||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||||
|
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||||
|
|
||||||
|
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||||
|
|
||||||
|
pending = SecurityEventStore(
|
||||||
|
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
||||||
|
).list(limit=10).events
|
||||||
|
assert [(event.kind, event.target, event.status) for event in pending] == [
|
||||||
|
("issue_closed", "stackchain/api#7", "pending")
|
||||||
|
]
|
||||||
|
|
||||||
|
store.finalize(operation_id)
|
||||||
|
|
||||||
|
completed = store.list(limit=10).events
|
||||||
|
assert [(event.kind, event.target, event.status) for event in completed] == [
|
||||||
|
("issue_closed", "stackchain/api#7", "completed")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
||||||
|
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||||
|
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||||
|
|
||||||
|
store.discard(operation_id)
|
||||||
|
|
||||||
|
assert store.list(limit=10).events == []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user