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.
This commit is contained in:
1 parent
3bebc68cd5
commit
8bea4c1a0c
15 files changed
+3090
-21
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import type { AdminAttempt, AdminAttemptReason } from '@/db/admin-attempts';
|
||||
import type { AdminIntegrity, AdminOperations, AdminStalledDecision } from '@/db/admin-operations';
|
||||
import '../admin.css';
|
||||
import './operations.css';
|
||||
@@ -10,6 +11,20 @@ import './operations.css';
|
||||
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Snapshot = AdminOperations & { revision: string };
|
||||
type Stalled = { observedAt: number; stallMs: number; stalled: AdminStalledDecision[] };
|
||||
type Attempts = {
|
||||
observedAt: number;
|
||||
windowMs: number;
|
||||
total: number;
|
||||
byReason: { reason: AdminAttemptReason; count: number }[];
|
||||
recent: AdminAttempt[];
|
||||
};
|
||||
const ATTEMPT_LABELS: Record<AdminAttemptReason, string> = {
|
||||
invalid_key_format: 'Malformed access key',
|
||||
unknown_or_disabled_key: 'Unknown or disabled access key',
|
||||
missing_session: 'Missing or malformed session',
|
||||
unknown_or_expired_session: 'Unknown, expired or revoked session',
|
||||
role_denied: 'Role does not permit the action',
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.';
|
||||
const idle = (idleMs: number) => `${Math.floor(idleMs / 3_600_000)}h ${Math.floor(idleMs % 3_600_000 / 60_000)}m`;
|
||||
|
||||
@@ -26,6 +41,10 @@ export default function OperationsPage() {
|
||||
const [stalledNotice, setStalledNotice] = useState('');
|
||||
const [loadingStalled, setLoadingStalled] = useState(false);
|
||||
const stalledEpoch = useRef(0);
|
||||
const [attempts, setAttempts] = useState<Attempts | null>(null);
|
||||
const [attemptsNotice, setAttemptsNotice] = useState('');
|
||||
const [loadingAttempts, setLoadingAttempts] = useState(false);
|
||||
const attemptsEpoch = useRef(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const epoch = integrityEpoch;
|
||||
@@ -89,11 +108,34 @@ export default function OperationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAttempts() {
|
||||
const epoch = ++attemptsEpoch.current;
|
||||
setLoadingAttempts(true);
|
||||
setAttempts(null);
|
||||
setAttemptsNotice('');
|
||||
try {
|
||||
const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<Attempts>('/api/admin/operations?attempts=1', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (epoch === attemptsEpoch.current) setAttempts(result);
|
||||
} catch (error) {
|
||||
if (epoch === attemptsEpoch.current)
|
||||
setAttemptsNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
} finally {
|
||||
if (epoch === attemptsEpoch.current) setLoadingAttempts(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => {
|
||||
integrityEpoch.current++;
|
||||
stalledEpoch.current++;
|
||||
attemptsEpoch.current++;
|
||||
setAttempts(null); setAttemptsNotice(''); setLoadingAttempts(false);
|
||||
setIntegrity(null); setIntegrityNotice(''); setChecking(false);
|
||||
setStalled(null); setStalledNotice(''); setLoadingStalled(false);
|
||||
setSnapshot(null); setLoading(true); setRefresh(value => value + 1);
|
||||
@@ -139,6 +181,28 @@ export default function OperationsPage() {
|
||||
</table>}
|
||||
<p>This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.</p>
|
||||
</section>}
|
||||
<Button variant="outline" disabled={loadingAttempts} onClick={() => void checkAttempts()}>List failed attempts</Button>
|
||||
<p>Read-only counters and the twenty most recent rejected administrator requests. Only a fixed reason, the attempted role and an already-known account id are stored: never a key, session token, request body or address.</p>
|
||||
{loadingAttempts && <output>Sampling rejected requests…</output>}
|
||||
{attemptsNotice && <p role="alert" className="admin-notice">{attemptsNotice}</p>}
|
||||
{attempts && <section aria-label="Failed administrator attempts">
|
||||
<p>Sampled {new Date(attempts.observedAt).toLocaleString()}; counters cover the last {Math.round(attempts.windowMs / 86_400_000)} days ({attempts.total} rejected requests).</p>
|
||||
{attempts.byReason.length === 0 ? <p>No rejected administrator request was recorded in the window.</p>
|
||||
: <ul>{attempts.byReason.map(row => <li key={row.reason}>{ATTEMPT_LABELS[row.reason]}: <strong>{row.count}</strong></li>)}</ul>}
|
||||
{attempts.recent.length === 0 ? null
|
||||
: <table className="admin-stalled-table">
|
||||
<caption>Most recent rejected requests, newest first</caption>
|
||||
<thead><tr><th scope="col">When</th><th scope="col">Kind</th><th scope="col">Reason</th><th scope="col">Role</th><th scope="col">Account</th></tr></thead>
|
||||
<tbody>{attempts.recent.map((row, index) => <tr key={`${row.createdAt}-${index}`}>
|
||||
<td>{new Date(row.createdAt).toLocaleString()}</td>
|
||||
<td>{row.kind}</td>
|
||||
<td>{ATTEMPT_LABELS[row.reason]}</td>
|
||||
<td>{row.role ?? '—'}</td>
|
||||
<td>{row.accountId ? `${row.accountId.slice(0, 8)}…` : '—'}</td>
|
||||
</tr>)}</tbody>
|
||||
</table>}
|
||||
<p>This sample changes nothing and identifies no credential. Use it to notice repeated failures, not to lock out a seat or account automatically.</p>
|
||||
</section>}
|
||||
<Button variant="outline" disabled={checking} onClick={() => void checkIntegrity()}>Run integrity check</Button>
|
||||
<p>This manual, read-only SQLite quick check and foreign-key check may take time. It reports pass/fail only: not disk capacity, backup recoverability or game-rule validity.</p>
|
||||
{checking && <output>Checking database structure and references…</output>}
|
||||
|
||||
Reference in new issue
Block a user