Compare commits

..

No commits in common. "7e5b966f7744bccdc1e284c02d43e15264135ce6" and "814c5726ecc73078082a86f40484e348ddd72ee5" have entirely different histories.

8 changed files with 59 additions and 496 deletions

View File

@ -213,11 +213,7 @@ 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. 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**.
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

View File

@ -105,12 +105,8 @@
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,
event.status === 'pending' ? 'Outcome confirmation pending' : null,
].filter(value => typeof value === 'string' && value).join(' · ');
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);

View File

@ -1020,7 +1020,6 @@ async def list_security_events(
"device_label": event.device_label,
"target": event.target,
"created_at": event.created_at,
"status": event.status,
}
for event in page.events
],
@ -1509,38 +1508,25 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
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"},
)
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)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
@ -1606,44 +1592,30 @@ 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"},
)
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:
return JSONResponse(
{"detail": "Security activity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
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:
if target is None or not revoked:
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}
@ -1661,32 +1633,26 @@ async def sign_out_all_devices(
action="revoke_all_sessions",
target="all",
)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
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"},
)
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)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
@ -3343,20 +3309,21 @@ async def close_assigned_issue(
target=f"{repository}#{number}",
)
target = f"{repository}#{number}"
try:
assigned = await asyncio.wait_for(
gitea_proxy.is_assigned_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
if not assigned:
async def close_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
journal = _security_event_store()
operation_id = await asyncio.to_thread(
journal.reserve,
"issue_closed",
target=target,
return await gitea_proxy.close_issue(repository, number)
try:
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:
@ -3366,30 +3333,6 @@ async def close_assigned_issue(
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")
async def assigned_issue_label_options(

View File

@ -1,6 +1,5 @@
"""Bounded, privacy-preserving journal of operator security activity."""
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
@ -19,7 +18,6 @@ class SecurityEvent:
device_label: str | None
target: str | None
created_at: int
status: str
@dataclass(frozen=True)
@ -63,48 +61,20 @@ class SecurityEventStore:
method TEXT,
device_label TEXT,
target TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
created_at INTEGER NOT NULL
)
"""
)
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(
"CREATE INDEX IF NOT EXISTS security_events_created "
"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
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) 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(
self,
kind: str,
@ -117,8 +87,12 @@ class SecurityEventStore:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
"VALUES (?, ?, ?, ?, ?, 'completed')",
"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),
@ -127,65 +101,10 @@ class SecurityEventStore:
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(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
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,),
"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(
@ -203,7 +122,7 @@ class SecurityEventStore:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status "
"SELECT id, kind, method, device_label, target, created_at "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()

View File

@ -152,7 +152,6 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
"device_label": "Phone",
"target": "dashboard",
"created_at": activity.json()["events"][0]["created_at"],
"status": "completed",
}
assert "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text

View File

@ -614,13 +614,6 @@ 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():
result = run_session_scenario(
"""

View File

@ -4,7 +4,7 @@ import httpx
import pytest
from src import main
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.security_event_store import SecurityEventStore
@pytest.fixture
@ -50,7 +50,6 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
"device_label": "Timmy's phone",
"target": "dashboard",
"created_at": activity.json()["events"][0]["created_at"],
"status": "completed",
}
],
"next_cursor": None,
@ -122,54 +121,6 @@ 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
async def test_successful_protected_actions_record_only_bounded_targets(
security_access, monkeypatch
@ -234,104 +185,6 @@ async def test_successful_protected_actions_record_only_bounded_targets(
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
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
transport = httpx.ASGITransport(app=main.app)
@ -358,71 +211,6 @@ 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
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
transport = httpx.ASGITransport(app=main.app)
@ -455,42 +243,3 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc
assert response.status_code == 200
assert events[0].kind == "all_sessions_revoked"
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

View File

@ -30,10 +30,7 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
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",
"status", "operation_id",
}
assert columns == {"id", "kind", "method", "device_label", "target", "created_at"}
def test_security_event_retention_prunes_age_and_count(tmp_path):
@ -55,32 +52,3 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
now[0] = 20
store.record("sign_out", device_label="Current")
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 == []