feat: make security-journaled actions outcome truthful (Closes #497)
This commit is contained in:
1 parent
814c5726ec
commit
78d044f1af
8 files changed
+494
-57
No files matched your search
@@ -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 == []
|
||||
Reference in new issue
Block a user