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:
co-authored by
Claude Opus 5
parent
4a1122a7c9
commit
eb36b81dc9
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user