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.
|
||||
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.
|
||||
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
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
|
|
|
|||
|
|
@ -105,8 +105,12 @@
|
|||
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(' · ');
|
||||
const context = [
|
||||
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 : ''}`;
|
||||
row.append(title, details);
|
||||
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,
|
||||
"target": event.target,
|
||||
"created_at": event.created_at,
|
||||
"status": event.status,
|
||||
}
|
||||
for event in page.events
|
||||
],
|
||||
|
|
@ -1508,25 +1509,38 @@ 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:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"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,
|
||||
|
|
@ -1592,30 +1606,44 @@ 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"},
|
||||
)
|
||||
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")
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return {"revoked": True, "current_session": target.current}
|
||||
|
||||
|
||||
|
|
@ -1633,26 +1661,32 @@ async def sign_out_all_devices(
|
|||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
await asyncio.to_thread(_passkey_store().revoke_all)
|
||||
await dashboard_auth.revoke_all_sessions()
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"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,
|
||||
|
|
@ -3309,21 +3343,20 @@ async def close_assigned_issue(
|
|||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def close_issue():
|
||||
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)
|
||||
|
||||
target = f"{repository}#{number}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
if not assigned:
|
||||
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=f"{repository}#{number}",
|
||||
target=target,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
|
|
@ -3333,6 +3366,30 @@ 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(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Bounded, privacy-preserving journal of operator security activity."""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -18,6 +19,7 @@ class SecurityEvent:
|
|||
device_label: str | None
|
||||
target: str | None
|
||||
created_at: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -61,20 +63,48 @@ class SecurityEventStore:
|
|||
method TEXT,
|
||||
device_label 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(
|
||||
"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,
|
||||
|
|
@ -87,12 +117,8 @@ class SecurityEventStore:
|
|||
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 (?, ?, ?, ?, ?)",
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
|
|
@ -101,10 +127,65 @@ 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(
|
||||
"DELETE FROM security_events WHERE id NOT IN "
|
||||
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
||||
(self.max_events,),
|
||||
"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,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
|
|
@ -122,7 +203,7 @@ class SecurityEventStore:
|
|||
try:
|
||||
with self._connect() as connection:
|
||||
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 ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import httpx
|
|||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.security_event_store import SecurityEventStore
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -50,6 +50,7 @@ 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,
|
||||
|
|
@ -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
|
||||
async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
security_access, monkeypatch
|
||||
|
|
@ -185,6 +234,104 @@ 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)
|
||||
|
|
@ -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
|
||||
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ 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"}
|
||||
assert columns == {
|
||||
"id", "kind", "method", "device_label", "target", "created_at",
|
||||
"status", "operation_id",
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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 == []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user