Files
Dune/docs/ADMIN_ATTEMPTS.md
Quintine 8bea4c1a0c Record administrator failed attempts without storing credentials
Rejected administrator authentication and authorization attempts are now
recorded durably in a new additive admin_attempts table with a fixed reason
(invalid_key_format, unknown_or_disabled_key, missing_session,
unknown_or_expired_session, role_denied), the attempt kind, the attempted role
and an already-known account id. No key, key hash, session token, session hash,
request body, origin, address or user agent is stored, and the
unknown/disabled distinction is deliberately collapsed so the lookup cannot
become an account-enumeration oracle.

GET /api/admin/operations?attempts=1 returns seven-day per-reason counters plus
the twenty most recent attempts, newest first, to a live owner only, with
no-store and the same query guard as the other reads. The operations page adds
"List failed attempts" with the counters and the bounded sample, truncating each
account id to eight characters. Recording is best-effort inside the rejection
path, so a rejected request keeps its own answer even when the table is
unavailable, and the history never locks out, disables or revokes anything by
itself.

Evidence: tests/admin-attempts.test.ts asserts every recorded reason, the
kind/role/account fields, that successful sign-ins and privileged reads record
nothing, that no credential text appears anywhere in the page, the reporting
window, the twenty-row bound and the owner-only recheck after demotion, and that
a dropped attempts table leaves the original rejection answer unchanged. The
anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the
real browser against a genuine recorded rejection. The shared AdminError moved
to db/admin-error.ts so the recorder and the access module no longer form a
value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No
game, seat, saved state or deployment changes.
2026-10-07 19:11:45 +11:00

2.7 KiB

Administrator failed-attempt history

Added 7 October 2026 as owner-only, read-only administration scope. It records rejected administrator authentication and authorization attempts in a durable, secret-free form so an operator can notice repeated failures without learning anything private about the person who made them.

What is recorded

Kind Reason When
login invalid_key_format The presented key is not a string or does not match the printed key shape.
login unknown_or_disabled_key No enabled account matches the key hash, or the credential changed between the read and the session write.
session missing_session No session cookie, or a token that is not 64 hex characters.
session unknown_or_expired_session The session is unknown, revoked, expired, generation-stale or belongs to a disabled account.
role role_denied A live session whose role is not in the requested set.

The unknown/disabled distinction is deliberately collapsed into one reason: the lookup cannot separate them without becoming an account-enumeration oracle.

Only the reason, the kind, the attempted role and an already-known account id are stored. No key, key hash, session token, session hash, request body, origin, address or user agent is written. A successful sign-in and a successful privileged request record nothing.

Reading it

GET /api/admin/operations?attempts=1 requires a live owner session, checks the X-Dune-Admin-Id header against that session, and answers no-store. Any other query combination is rejected with 400, and an anonymous or non-owner request is rejected with 401/403 before any row is read. The same request without a query returns the aggregate sample, ?integrity=1 runs the manual SQLite check and ?stalled=1 samples stalled rooms.

The response carries the sample time, the seven-day reporting window, the total inside that window, per-reason counters and at most ADMIN_ATTEMPT_LIMIT (twenty) of the most recent attempts, newest first. The operations page renders both, truncating each account id to eight characters.

Privacy and safety

Recording is best-effort: recordAdminAttempt swallows its own failure, so a rejected request keeps its original answer even when the attempts table is unavailable. The write happens inside the same rejection path that already decided the outcome, so it cannot turn a rejection into an approval, and the read path repeats live owner authority inside its own batch.

The history is diagnostic only. Nothing in it locks out an account, disables a key, revokes a session or repairs a room automatically; those stay explicit owner actions with their own audit. It also does not rate-limit or block requests — it reports them.