feat: make security-journaled actions outcome truthful (Closes #497)
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 16:30:31 +00:00
1 parent 814c5726ec
commit 78d044f1af
8 files changed
+494 -57

No files matched your search

+1
View File
@@ -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
+7
View File
@@ -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(
"""
+252 -1
View File
@@ -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
+33 -1
View File
@@ -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 == []