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
+34 -3
View File
@@ -182,9 +182,25 @@ _SECRET_KEY_PARTS = (
# Query/fragment parameters that carry a credential in a URL we log (OAuth `code`/`state`, our own
# `?token=` setup and share links, `#verify=` / `#reset=` fragments).
_SECRET_PARAM_NAMES = (
"access_token",
"refresh_token",
"id_token",
"token",
"code",
"state",
"key",
"secret",
"password",
"pwd",
"sig",
"signature",
"verify",
"reset",
"setup",
)
_SECRET_PARAM_RE = re.compile(
r"\b((?:access_token|refresh_token|id_token|token|code|state|key|secret|password|pwd|sig|"
r"signature|verify|reset|setup)=)([^&\s\"']+)",
r"\b((?:" + "|".join(_SECRET_PARAM_NAMES) + r")=)([^&\s\"']+)",
re.IGNORECASE,
)
@@ -210,7 +226,18 @@ def redact_query(query: str) -> str:
sends it percent-encoded — `someone%40example.com` is an address `_EMAIL_RE` cannot see. Each
parameter also goes through the key rules, so `?new_password=` goes as `redact` would drop it."""
pairs = parse_qsl(query, keep_blank_values=True)
return "&".join(redact_text(f"{key}={redact(value, key=key)}") for key, value in pairs)
return "&".join(f"{key}={_redact_param(key, value)}" for key, value in pairs)
def _redact_param(key: str, value: str) -> str:
"""One decoded `key=value` pair. The parameter-NAME rule is applied to the key here rather than
left to `_SECRET_PARAM_RE` over the rebuilt pair: that pattern was written for the raw,
percent-encoded query, where a value holds no literal space, `&` or quote, and its value class
stops at the first one. On decoded text `?state=a%20b` would keep its tail and `?code=%20abc`
would not match at all — the whole credential straight into the line."""
if key.lower() in _SECRET_PARAM_NAMES:
return REDACTED
return str(redact(value, key=key))
def redact(value: Any, *, key: str = "") -> Any:
@@ -509,6 +536,10 @@ def forget_user(user_id: int) -> None:
_erased[user_id] = now
with _elevations_lock:
_elevations.pop(user_id, None)
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
# and every unexpired one. Recomputed here rather than left to whichever later request happens
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
_apply_root_level()
def is_erased(user_id: int | None) -> bool: