Superadmin impersonation, hardened auth/upload paths, dependency updates

- Add superadmin impersonation: sessions.impersonated_by (migration 0003) is
  stamped onto audit rows so impersonated actions are attributable, with a
  persistent ImpersonationBanner in the app shell.
- Harden auth and upload handling across routes (safe redirect targets,
  filename sanitization, checkout grant handling).
- Update dependencies: Sentry 8 -> 10, @fastify/static 8 -> 10,
  react-router-dom 6.30.6; add find-my-way / fast-uri overrides.
- Add tests for safe-next, checkout-grant, and upload-filename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-08-26 10:25:39 -04:00
co-authored by Claude Opus 5
parent 4a1122a7c9
commit eb36b81dc9
28 changed files with 7936 additions and 5763 deletions
+24 -18
View File
@@ -14,17 +14,27 @@ import {
sessions,
} from '@lawdesk/db';
import { verifyPassword } from '../auth/password';
import { logAudit } from '../lib/audit';
import { logAuditFromRequest } from '../lib/audit';
import { sendEmail, accountDeletedEmail } from '../lib/email';
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
// Everything under /api/account acts on the whole firm, not just the caller's own rows, so it is
// owner-only. Without this an 'attorney'/'paralegal'/'staff' member could export every client
// file the firm holds, or delete the firm outright.
const OWNER_ONLY = ['owner'] as const;
const EXPORT_URL_TTL_SECONDS = 15 * 60;
export async function accountRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireAuth);
// GDPR data export — full JSON dump of everything tied to the user's firm.
app.get(
'/api/account/export',
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
{
config: { rateLimit: { max: 5, timeWindow: '1 hour' } },
preHandler: app.requireRole(...OWNER_ONLY),
},
async (req, reply) => {
const userId = req.user!.id;
const firmId = req.user!.firmId;
@@ -65,14 +75,18 @@ export async function accountRoutes(app: FastifyInstance) {
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
// GDPR portability covers the files themselves, not just their metadata — attach a
// time-limited presigned download URL per document (valid 24h; re-export for fresh links).
// time-limited presigned download URL per document. These are unauthenticated bearer URLs
// to privileged client material sitting inside a file the user may forward or archive, so
// the window is deliberately short (15 minutes): long enough to run the downloads straight
// after exporting, short enough that a leaked export is not a document breach. Re-export
// for fresh links.
const docsWithUrls = await Promise.all(
docs.map(async (d) => {
try {
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, 24 * 60 * 60);
return { ...d, downloadUrl, downloadUrlExpiresInHours: 24 };
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, EXPORT_URL_TTL_SECONDS);
return { ...d, downloadUrl, downloadUrlExpiresInMinutes: EXPORT_URL_TTL_SECONDS / 60 };
} catch {
return { ...d, downloadUrl: null, downloadUrlExpiresInHours: null };
return { ...d, downloadUrl: null, downloadUrlExpiresInMinutes: null };
}
}),
);
@@ -88,11 +102,9 @@ export async function accountRoutes(app: FastifyInstance) {
dump.documents = docsWithUrls;
}
await logAudit({
userId,
await logAuditFromRequest(req, 'account.export', {
firmId,
action: 'account.export',
ip: req.ip,
meta: { documentUrlsIssued: dump.documents ? (dump.documents as unknown[]).length : 0 },
});
reply
@@ -106,7 +118,7 @@ export async function accountRoutes(app: FastifyInstance) {
// GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must
// transfer ownership first (we'll add a transfer endpoint when we add team management).
app.post('/api/account/delete', async (req, reply) => {
app.post('/api/account/delete', { preHandler: app.requireRole(...OWNER_ONLY) }, async (req, reply) => {
const userId = req.user!.id;
const firmId = req.user!.firmId;
const body = z.object({ password: z.string().min(1) }).parse(req.body);
@@ -132,13 +144,7 @@ export async function accountRoutes(app: FastifyInstance) {
}
}
await logAudit({
userId,
firmId,
action: 'account.delete',
meta: { email: me.email },
ip: req.ip,
});
await logAuditFromRequest(req, 'account.delete', { firmId, meta: { email: me.email } });
await db.transaction(async (tx) => {
await tx.delete(sessions).where(eq(sessions.userId, userId));