Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.

`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.

Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.

#2939 §5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:19:41 -04:00
co-authored by Claude Opus 5.5
parent 043c87a8dc
commit 39b1ebae96
14 changed files with 509 additions and 21 deletions
+88 -4
View File
@@ -26,10 +26,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, func, select, text, update
from inkwell import mailer, ratelimit
from inkwell import audit, mailer, ratelimit
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.audit_event import AuditEvent
from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset
from inkwell.models.settings import Setting
@@ -51,7 +52,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, audit_events, users"
@pytest_asyncio.fixture
@@ -481,6 +482,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
"register_window_minutes",
"reset_emails_per_account",
"client_downloads_per_hour",
"audit_retention_days",
}
# The UI renders a number input from these, and it cannot offer a safe range it
# was never told about.
@@ -1274,10 +1276,24 @@ async def test_an_admin_and_a_zero_setting_have_no_storage_limit(app_client, db)
admin_use = await (await app_client.get("/api/auth/storage")).get_json()
assert admin_use["limit_bytes"] is None
await _set_for_the_test("storage_quota_gb", 0)
try:
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
finally:
await _back_to_default("storage_quota_gb")
async def _set_for_the_test(key: str, value) -> None:
"""Settings outlive the per-test truncate: pair this with `_back_to_default`."""
async with session_scope() as fresh:
await set_settings(fresh, {"storage_quota_gb": 0})
await set_settings(fresh, {key: value})
await fresh.commit()
async def _back_to_default(key: str) -> None:
async with session_scope() as fresh:
await fresh.execute(delete(Setting).where(Setting.key == key))
await fresh.commit()
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
@@ -1375,6 +1391,74 @@ async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_clie
assert resp.status_code == 400
# --- The audit log (#2939 §5) --------------------------------------------------
async def _activity(client) -> list[dict]:
resp = await client.get("/api/accounts/activity")
assert resp.status_code == 200, await resp.get_data(as_text=True)
return (await resp.get_json())["events"]
async def test_the_activity_list_keeps_what_happened_to_accounts(app_client, db):
other, bearer = await _elsewhere(app_client)
wrong = await create_app().test_client().post(
"/api/auth/login", json={"email": "owner@example.test", "password": "not-the-password"}
)
assert wrong.status_code == 401
await app_client.post(
"/api/auth/password", json={"current_password": _PASSWORD, "new_password": "a-brand-new-password"}
)
events = await _activity(app_client)
# Newest first.
assert [e["event"] for e in events] == [
audit.PASSWORD_CHANGED,
audit.SIGN_IN_FAILED,
audit.DEVICE_LINKED,
audit.SIGN_IN,
audit.REGISTERED,
]
by_name = {e["event"]: e for e in events}
assert by_name[audit.SIGN_IN_FAILED]["detail"] == "sign-in: bad password"
assert by_name[audit.DEVICE_LINKED]["detail"] == "Phone"
assert by_name[audit.PASSWORD_CHANGED]["detail"] == "1 device unlinked"
assert by_name[audit.REGISTERED]["detail"] == "first account, admin"
assert all(e["email"] == "owner@example.test" and e["address"] for e in events)
async def test_a_refused_registration_is_kept_though_its_transaction_rolled_back(app_client, db):
await _admin_with_invite(app_client)
refused = await _register("stranger@example.test", invite="not-a-real-invite")
assert refused.status_code == 403
refusals = [e for e in await _activity(app_client) if e["event"] == audit.REGISTRATION_REFUSED]
assert [(e["email"], e["detail"]) for e in refusals] == [("stranger@example.test", "invite didn't hold")]
async def test_only_an_admin_reads_the_activity_list(app_client, db):
guest, _ = await _admin_and_guest(app_client)
assert (await guest.get("/api/accounts/activity")).status_code == 403
async def test_activity_older_than_its_retention_is_swept(app_client, db):
await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
now = datetime.now(timezone.utc)
async with session_scope() as fresh:
fresh.add(AuditEvent(event=audit.SIGN_IN, email="old@example.test", at=now - timedelta(days=91)))
await fresh.commit()
await _set_for_the_test("audit_retention_days", 0)
try:
assert await audit.sweep_once() == 0, "0 keeps every event"
finally:
await _back_to_default("audit_retention_days")
assert await audit.sweep_once() == 1
emails = {e["email"] for e in await _activity(app_client)}
assert emails == {"owner@example.test"}
# --- Sharing a note (#5174) ---------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.