fix: a credential, an erased account and a stale level stay out of the log

Round 2 of the E22 review, on what round 1 changed.

`redact_query` decodes a parameter before the text rules see it, which is what lets `%40` be
recognised as an address — but `_SECRET_PARAM_RE` was written for the raw, encoded form, where a
value holds no space, `&` or quote. On decoded text its value class stops at the first one, so
`?state=a%20b` kept its tail and `?code=%20abc` was not matched at all. The parameter-NAME rule now
decides, on the key, before any text rule runs.

`forget_user` dropped the account's level elevation without recomputing the root level, so a process
whose only elevated account had just deleted itself kept building TRACE records to throw away, until
some later request happened to recompute it. Every other path that drops an elevation recomputes.

The sink's writer took the erasure lock and THEN waited for a pooled connection, while every request
path already holds a connection when it reaches that lock — so under pool pressure the writer held a
process-global lock against the very requests that could hand a connection back. Same order on both
sides now; the lock still spans the check, the insert and the commit.

The storage-budget notice said the rest of the hour was not kept, and then smaller batches kept
landing underneath it: the window slides and a refused batch is not charged to it. Says what it does.

Tests: the route's half of the erasure rule had no test — only the sink's did — and a source-level
contract test now covers the third insert nobody has written yet. `logs._erased` is process-global
with an hour's memory while the DB fixture hands serial ids back out from 1, so it is swept before
each test like the rate limiters are.
This commit is contained in:
2026-09-17 23:45:16 +02:00
parent 791ba5f507
commit b2eaa9e00b
6 changed files with 123 additions and 17 deletions
+42
View File
@@ -181,6 +181,20 @@ def test_a_batch_is_stored_as_the_sessions_web_events_and_redacted_again(elevate
assert (warning.sev, click.level, click.version) == (13, "DEBUG", "0.88.0")
def test_an_erased_accounts_batch_is_stored_without_the_account(elevated):
"""The sink's writer is not the only path into `log_events` — this route inserts too, and an
account erased while its browser was mid-flush must not get its rows back through it. Faked at
the `forget_user` seam the purge itself calls: once the User row is actually gone the session
no longer resolves, so the reachable window is exactly a request already past `current_user`.
(`logs._erased` is cleared before every test by conftest's `_reset_log_erasures`.)"""
user, client, rows = elevated("DEBUG")
logs.forget_user(user.id)
r = client.post("/api/diagnostics/events", json={"events": [_event(level="WARNING")]})
assert r.json() == {"ok": True, "stored": 1}
(stored,) = rows()
assert (stored.source, stored.user_id) == ("web", None)
def test_an_event_under_the_consented_level_is_dropped(elevated):
_user, client, rows = elevated("DEBUG")
r = client.post(
@@ -370,6 +384,18 @@ def test_an_address_typed_into_a_search_is_redacted_though_the_browser_encodes_i
assert line.attrs["query"] == "level=TRACE&q=[email]&new_password=[redacted]"
@pytest.mark.parametrize(
"raw",
["state=a%20b", "code=%20SECRET", "sig=a%22b", "key=a%26b", "reset=a+b"],
ids=["space", "leading-space", "quote", "ampersand", "plus"],
)
def test_a_credential_param_goes_whole_however_its_value_decodes(raw):
"""Decoding is what lets `_EMAIL_RE` see `%40`, but it also puts spaces and separators into a
value that `_SECRET_PARAM_RE`'s value class stops at — so the name rule decides, not the text."""
name = raw.split("=", 1)[0]
assert logs.redact_query(f"{raw}&q=lofi") == f"{name}={logs.REDACTED}&q=lofi"
def test_an_admin_level_of_trace_does_not_log_other_users_query_strings(gated):
logs.set_level("TRACE")
_app().get("/api/thing/7", params={"q": "something private"})
@@ -388,6 +414,22 @@ def test_the_admin_level_applies_to_the_running_process_when_set_and_when_reset(
assert logs.current_level() == settings.log_level
def test_erasing_the_only_elevated_account_puts_the_process_level_back(gated):
"""The root level is the lowest of the base and every unexpired elevation. `forget_user` drops
an elevation like any other path that drops one, so it owes the same recompute — or the process
keeps building every TRACE record for an account that no longer exists, to throw them away."""
until = datetime.now(timezone.utc) + timedelta(hours=1)
try:
logs.set_user(7, "TRACE", until)
assert logging.getLogger().level == logs.TRACE
logs.forget_user(7)
assert logging.getLogger().level == logging.INFO
finally:
# `set_user` writes the request scope, and outside a request that scope is this process's
# for good — a later test would store its INFO lines as an elevated session's.
logs.set_user(None)
def test_the_stored_admin_level_is_applied_at_boot(gated, db, monkeypatch):
import asyncio