ADR 0009 — Activity audit log and no-lockout login backoff
Status: accepted.
Decision
- The panel writes an append-only activity audit log as plain NDJSON files,
one file per period: default daily
<YYYY-MM-DD>.log(monthly optional), kept under<stacksRoot>/.compose-panel/audit/and overridable withCOMPOSE_PANEL_AUDIT_DIR. The most recentCOMPOSE_PANEL_AUDIT_RETAIN_FILES(default 31) files are kept; older ones are pruned on rollover. - Recorded events are auth and actions only: login success/failure/refusal,
logout, file-save outcomes, and operation start/refusal/final outcome. Lines
never contain passwords, password hashes,
.envvalues or Compose output. The typed username is never written either (people type their password there by mistake): only whether it matched the admin account. Fields are single-line and length-capped. - Growth is bounded: each period file has a size cap
(
COMPOSE_PANEL_AUDIT_MAX_FILE_BYTES, default 10 MiB) after which onesuspendedmarker closes the period, and sign-in refusals/failures, which any client can trigger, are capped per minute and summarized in onesuppressedline. - Behind a trusted proxy the actor is the last
X-Forwarded-Forentry, the one our proxy appended; earlier entries are client-controlled and would let an attacker write someone else's address into the log (and get it banned by a host-level watcher). - The log is consulted through an authenticated, read-only
/auditpage (parsed table, period selector, manual refresh). Reads are bounded to the tail of one period file; filenames are derived from a validated period, never from user input. - Login throttling no longer hard-blocks a client on failed attempts. A key over
its limit is served with a bounded, exponentially growing delay (from
250 ms up to
COMPOSE_PANEL_LOGIN_BACKOFF_MAX_MS, default 2 s). A key may have only one attempt in progress: a parallel attempt from the same key is refused, so a source cannot bypass its own delay. The global in-flight verification cap is taken only after the delay, so delayed attempts never hold one of its slots; it remains a transient refusal under load.
Why
The product is a single-admin, loopback-first tool. A hard per-client lockout keyed by source means anyone who can send failed logins can deny the real administrator access — a self-inflicted denial of service. Because the panel cannot tell the admin from an attacker before verifying the password, the honest control is to keep the admin able to authenticate while collapsing an attacker's throughput: Argon2id plus a bounded concurrency cap plus a per-key delay. A real source-IP ban belongs at the host layer (fail2ban/CrowdSec), which the audit log now makes possible to filter on.
Audit files, not a database, follow ADR 0002 (no operation store in V1): plain files on the host are inspectable, greppable and directly consumable by a host-level log watcher, and removing the panel leaves them intact.
Consequences
- A writable directory is added under the panel's own metadata area
(
<stacksRoot>/.compose-panel/); writes are best-effort and never fail the request that produced them. A write failure is reported to the server log. - There is no in-memory history and no database: the files are the whole record. Retention bounds growth; reads are bounded.
- Brute-force resistance comes from Argon2id cost, the per-key delay and the
one-attempt-per-key rule rather than from a failure counter. A single source
cannot lock the admin out: it can only slow down attempts that share its key
(the same address). Many distinct sources can still saturate the global
verification cap and cause transient refusals; that is a distributed attack,
handled at the host layer. This is accepted and stated in
docs/security.md. - Audit files are sensitive (activity, source keys): they are served only behind
auth,
no-store, HTML-escaped, and never published. COMPOSE_PANEL_LOGIN_BLOCK_MSis replaced byCOMPOSE_PANEL_LOGIN_BACKOFF_MAX_MS; the old hard-lockout semantics are gone.