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.
103 lines
4.8 KiB
TypeScript
103 lines
4.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { adminLogin, requireAdmin, AdminError } from '../db/admin-access';
|
|
import { ADMIN_ATTEMPT_LIMIT, readAdminAttempts } from '../db/admin-attempts';
|
|
import { adminStore } from './admin-access-fixture';
|
|
|
|
const now = 10_000;
|
|
const owner = async () => {
|
|
const store = adminStore();
|
|
const account = store.provision('owner');
|
|
const login = await adminLogin(store.database, account.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
return { store, account, login, identity };
|
|
};
|
|
const reasons = (page: Awaited<ReturnType<typeof readAdminAttempts>>) =>
|
|
page.recent.map((row) => row.reason);
|
|
const rejects = async (promise: Promise<unknown>, status: number) =>
|
|
await assert.rejects(
|
|
promise,
|
|
(error: unknown) => error instanceof AdminError && error.status === status,
|
|
);
|
|
|
|
void test('rejected administrator requests are recorded by reason without storing any credential', async () => {
|
|
const { store, account, login, identity } = await owner();
|
|
try {
|
|
await rejects(adminLogin(store.database, 42, now), 401);
|
|
await rejects(adminLogin(store.database, 'not-a-key', now), 401);
|
|
await rejects(
|
|
adminLogin(store.database, `dune-admin.${account.id}.${'a'.repeat(64)}`, now),
|
|
401,
|
|
);
|
|
await rejects(requireAdmin(store.database, 'short', ['owner'], now), 401);
|
|
await rejects(requireAdmin(store.database, 'b'.repeat(64), ['owner'], now), 401);
|
|
const spare = store.provision('owner', 'Disabled owner');
|
|
store.sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(spare.id);
|
|
await rejects(adminLogin(store.database, spare.key, now), 401);
|
|
const viewer = store.provision('viewer');
|
|
const viewerLogin = await adminLogin(store.database, viewer.key, now);
|
|
await rejects(requireAdmin(store.database, viewerLogin.token, ['owner'], now), 403);
|
|
const page = await readAdminAttempts(store.database, identity, now);
|
|
assert.equal(page.observedAt, now);
|
|
assert.equal(page.windowMs, 7 * 24 * 60 * 60 * 1000);
|
|
assert.equal(page.total, 7);
|
|
assert.deepEqual(page.byReason, [
|
|
{ reason: 'invalid_key_format', count: 2 },
|
|
{ reason: 'missing_session', count: 1 },
|
|
{ reason: 'role_denied', count: 1 },
|
|
{ reason: 'unknown_or_disabled_key', count: 2 },
|
|
{ reason: 'unknown_or_expired_session', count: 1 },
|
|
]);
|
|
assert.deepEqual(reasons(page), [
|
|
'role_denied',
|
|
'unknown_or_disabled_key',
|
|
'unknown_or_expired_session',
|
|
'missing_session',
|
|
'unknown_or_disabled_key',
|
|
'invalid_key_format',
|
|
'invalid_key_format',
|
|
]);
|
|
const denial = page.recent[0];
|
|
assert.equal(denial.kind, 'role');
|
|
assert.equal(denial.role, 'viewer');
|
|
assert.equal(denial.accountId, viewer.id);
|
|
assert.equal(denial.createdAt, now);
|
|
const serialized = JSON.stringify(page);
|
|
for (const secret of [account.key, viewer.key, login.token, viewerLogin.token, 'dune-admin'])
|
|
assert.equal(serialized.includes(secret), false, 'attempts never store a credential');
|
|
// A successful login and privileged read record nothing further.
|
|
await adminLogin(store.database, viewer.key, now);
|
|
await requireAdmin(store.database, viewerLogin.token, undefined, now);
|
|
assert.equal((await readAdminAttempts(store.database, identity, now)).total, 7);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('attempt reading stays owner-only, bounded and best-effort when recording fails', async () => {
|
|
const { store, identity } = await owner();
|
|
try {
|
|
for (let index = 0; index < ADMIN_ATTEMPT_LIMIT + 5; index++)
|
|
await rejects(adminLogin(store.database, `bad-${index}`, now), 401);
|
|
const page = await readAdminAttempts(store.database, identity, now);
|
|
assert.equal(page.total, ADMIN_ATTEMPT_LIMIT + 5);
|
|
assert.equal(page.recent.length, ADMIN_ATTEMPT_LIMIT);
|
|
assert.equal(page.recent[0].createdAt, now);
|
|
// Only the reporting window's counters are returned.
|
|
store.sqlite
|
|
.prepare('UPDATE admin_attempts SET created_at=?')
|
|
.run(now - 8 * 24 * 60 * 60 * 1000);
|
|
assert.equal((await readAdminAttempts(store.database, identity, now)).total, 0);
|
|
store.provision('owner', 'Remaining owner');
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(identity.id);
|
|
await rejects(readAdminAttempts(store.database, identity, now), 403);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('a missing attempts table never changes the rejected request answer', async () => {
|
|
const { store } = await owner();
|
|
try {
|
|
store.sqlite.exec('DROP TABLE admin_attempts');
|
|
await rejects(adminLogin(store.database, 'not-a-key', now), 401);
|
|
await rejects(requireAdmin(store.database, 'short', undefined, now), 401);
|
|
} finally { store.sqlite.close(); }
|
|
});
|