feat(audit): wider coverage — sensitive views, cron, jobs, portal abuse

Builds on the audit infra split (severity/source) by emitting events
from every place a security or operations review would want to see:

Sensitive data views (severity=warning):
- GDPR export download URL issued
- Audit log page opened (watch-the-watchers; first page only)
- CSV export of expenses
- Webhook secret regenerated

Authentication abuse (severity=warning, source=auth):
- Portal sign-in: success + failed-credentials + portal-disabled
- Portal password reset: unknown email + portal-disabled + bad token
- Portal activation: bad/expired token

Inbound webhook hardening:
- Documenso webhook with invalid X-Documenso-Secret now writes
  webhook_failed instead of being silently logged

Background work (source=cron / job):
- New attachWorkerAudit() helper wires every BullMQ worker to emit
  job_failed (severity=error) on .on('failed') and cron_run on
  .on('completed') for any job whose name matches the recurring
  scheduler list. Applied across all 10 workers.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 20:44:38 +02:00
parent d2171ea79b
commit 9890d065f8
17 changed files with 261 additions and 0 deletions

View File

@@ -212,6 +212,16 @@ export async function signIn(args: {
: (await verifyPassword(args.password, dummyHash), false);
if (!user || !user.isActive || !user.passwordHash || !ok) {
void createAuditLog({
userId: null,
portId: user?.portId ?? null,
action: 'login',
entityType: 'portal_session',
entityId: user?.id ?? normalizedEmail,
metadata: { ok: false, attemptedEmail: normalizedEmail, reason: 'invalid_credentials' },
severity: 'warning',
source: 'auth',
});
throw new UnauthorizedError('Invalid email or password');
}
@@ -219,6 +229,16 @@ export async function signIn(args: {
// password on a disabled-port account still surfaces "invalid email or
// password" - we never leak which ports have the portal turned off.
if (!(await isPortalEnabledForPort(user.portId))) {
void createAuditLog({
userId: null,
portId: user.portId,
action: 'login',
entityType: 'portal_session',
entityId: user.id,
metadata: { ok: false, attemptedEmail: normalizedEmail, reason: 'portal_disabled' },
severity: 'warning',
source: 'auth',
});
throw new UnauthorizedError('Invalid email or password');
}
@@ -230,6 +250,17 @@ export async function signIn(args: {
await db.update(portalUsers).set({ lastLoginAt: new Date() }).where(eq(portalUsers.id, user.id));
void createAuditLog({
userId: null,
portId: user.portId,
action: 'login',
entityType: 'portal_session',
entityId: user.id,
metadata: { ok: true, email: user.email },
severity: 'info',
source: 'auth',
});
return { token, clientId: user.clientId, portId: user.portId, email: user.email };
}
@@ -246,6 +277,16 @@ export async function requestPasswordReset(email: string): Promise<void> {
// Silently no-op so unknown emails don't leak through timing or
// response shape. Caller surfaces "if the email matches an account…".
logger.debug({ email: normalizedEmail }, 'Password reset for unknown email');
void createAuditLog({
userId: null,
portId: null,
action: 'portal_password_reset_request',
entityType: 'portal_user',
entityId: 'unknown',
metadata: { email: normalizedEmail, reason: 'unknown_or_inactive' },
severity: 'warning',
source: 'auth',
});
return;
}
@@ -253,6 +294,16 @@ export async function requestPasswordReset(email: string): Promise<void> {
// disabled-state from leaking through the public reset endpoint.
if (!(await isPortalEnabledForPort(user.portId))) {
logger.debug({ portId: user.portId }, 'Password reset on disabled-portal port');
void createAuditLog({
userId: null,
portId: user.portId,
action: 'portal_password_reset_request',
entityType: 'portal_user',
entityId: user.id,
metadata: { email: normalizedEmail, reason: 'portal_disabled' },
severity: 'warning',
source: 'auth',
});
return;
}
@@ -342,6 +393,16 @@ async function consumeToken(
});
if (!row) {
void createAuditLog({
userId: null,
portId: null,
action: type === 'reset' ? 'portal_password_reset' : 'portal_activate',
entityType: 'portal_auth_token',
entityId: 'invalid',
metadata: { type, reason: 'invalid_or_expired_token' },
severity: 'warning',
source: 'auth',
});
throw new ValidationError('Invalid or expired token');
}

View File

@@ -218,6 +218,7 @@ export async function regenerateSecret(portId: string, webhookId: string, meta:
metadata: { type: 'secret_regenerated' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
severity: 'warning',
});
// Return new plaintext secret - shown ONCE