chore: move auditLog backend to proper DI

This commit is contained in:
jubnl
2026-07-27 22:09:08 +02:00
parent 1d56de3897
commit 6ff936c613
59 changed files with 1077 additions and 658 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ const HOST = readEnv().app.host;
const APP_VERSION: string = readEnv().app.appVersion || (require('../package.json') as { version: string }).version;
const onListen = () => {
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./nest/audit/audit-log.logger');
const env = readEnv();
const LOG_LVL = (env.app.logLevel || 'info').toLowerCase();
const tz = env.app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -116,7 +116,7 @@ bootstrap().catch((err) => {
// Graceful shutdown
function shutdown(signal: string): void {
const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog');
const { logInfo: sLogInfo, logError: sLogError } = require('./nest/audit/audit-log.logger');
const { closeMcpSessions } = require('./mcp');
sLogInfo(`${signal} received — shutting down gracefully...`);
scheduler.stop();
+1 -1
View File
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
import { readEnv } from '../app-config';
import { writeAudit, getClientIp } from '../services/auditLog';
import { writeAudit, getClientIp } from '../nest/audit/audit.bridge';
import { getMcpSafeUrl } from '../services/notifications';
export { revokeUserSessions, revokeUserSessionsForClient };
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from '../services/oauthService';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../services/notifications';
import { writeAudit } from '../services/auditLog';
import { writeAudit } from '../nest/audit/audit.bridge';
// ---------------------------------------------------------------------------
// DB row type (mirrors oauthService.ts)
+1 -1
View File
@@ -4,7 +4,7 @@ import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { readEnv, type AppEnv } from '../app-config';
import { logDebug, logWarn, logError } from '../services/auditLog';
import { logDebug, logWarn, logError } from '../nest/audit/audit-log.logger';
import { enforceGlobalMfaPolicy } from './mfaPolicy';
/**
+23 -20
View File
@@ -6,7 +6,9 @@ import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { writeAudit, getClientIp, logInfo } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { send as sendNotification } from '../../services/notificationService';
import type { User } from '../../types';
@@ -35,6 +37,7 @@ export class AdminController {
constructor(
private readonly admin: AdminService,
private readonly pluginRuntime: PluginRuntimeService,
private readonly audit: AuditService,
) {}
// ── Users ──
@@ -45,14 +48,14 @@ export class AdminController {
@HttpCode(201)
createUser(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.createUser(body));
writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
}
@@ -60,7 +63,7 @@ export class AdminController {
@Delete('users/:id')
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deleteUser(id, user.id));
writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
}
@@ -68,7 +71,7 @@ export class AdminController {
@Delete('users/:id/passkeys')
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserPasskeys(id));
writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
return { success: true, deleted: result.deleted };
}
@@ -85,7 +88,7 @@ export class AdminController {
throw new HttpException({ error: 'permissions object required' }, 400);
}
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
}
@@ -102,7 +105,7 @@ export class AdminController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } });
this.audit.writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } });
return { success: true };
}
@@ -113,7 +116,7 @@ export class AdminController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
@@ -148,14 +151,14 @@ export class AdminController {
@HttpCode(201)
createInvite(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = this.admin.createInvite(user.id, body);
writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
return { invite: result.invite };
}
@Delete('invites/:id')
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.deleteInvite(id));
writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -166,7 +169,7 @@ export class AdminController {
@Put('bag-tracking')
updateBagTracking(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
const result = this.admin.updateBagTracking(body.enabled);
writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -177,7 +180,7 @@ export class AdminController {
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesPhotos(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -188,7 +191,7 @@ export class AdminController {
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesAutocomplete(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -199,7 +202,7 @@ export class AdminController {
updatePlacesDetails(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesDetails(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -212,7 +215,7 @@ export class AdminController {
// Collab flags gate MCP registration, but a no-op save must not tear down
// every live MCP session (#1414).
if (changed) this.admin.invalidateMcpSessions();
writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
return features;
}
@@ -235,7 +238,7 @@ export class AdminController {
@Delete('packing-templates/:id')
deletePackingTemplate(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deletePackingTemplate(id));
writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } });
this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } });
return { success: true };
}
@@ -278,7 +281,7 @@ export class AdminController {
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.updateAddon(id, body));
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
// Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
// saves and photo-provider toggles used to kill every session (#1414).
@@ -308,7 +311,7 @@ export class AdminController {
@Delete('oauth-sessions/:id')
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.revokeOAuthSession(id));
writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -320,7 +323,7 @@ export class AdminController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
@@ -335,7 +338,7 @@ export class AdminController {
}
try {
this.admin.setAdminUserDefaults(body as unknown as Record<string, unknown>);
writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
return this.admin.getAdminUserDefaults();
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
+2 -1
View File
@@ -3,9 +3,10 @@ import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PluginsModule } from '../plugins/plugins.module';
import { SettingsModule } from '../settings/settings.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [PluginsModule, SettingsModule],
imports: [PluginsModule, SettingsModule, AuditModule],
controllers: [AdminController],
providers: [AdminService],
})
+2 -1
View File
@@ -43,6 +43,7 @@ import { OidcModule } from './oidc/oidc.module';
import { OauthModule } from './oauth/oauth.module';
import { AdminModule } from './admin/admin.module';
import { AddonsModule } from './addons/addons.module';
import { AuditModule } from './audit/audit.module';
import { PermissionsModule } from './permissions/permissions.module';
import { PluginsModule } from './plugins/plugins.module';
import { McpModule } from '@trek/nest-mcp';
@@ -57,7 +58,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PermissionsModule, PluginsModule, BookingImportModule],
imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
+85
View File
@@ -0,0 +1,85 @@
import { readEnv } from '../../app-config';
import fs from 'fs';
import path from 'path';
/**
* The server's rotating file logger — a plain, deliberately side-effectful
* module, NOT an injectable. This is a documented parity exception to the
* "importable without side effects" rule: index.ts lazy-requires it before any
* Nest container exists (boot ordering), and tests/setup.ts relies on env
* being read exactly once at first import (the LOG_LEVEL freeze below).
*/
// Frozen at import on purpose (legacy timing; tests/setup.ts sets it pre-import).
const LOG_LEVEL = (readEnv().app.logLevel || 'info').toLowerCase();
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_LOG_FILES = 5;
const C = {
blue: '\x1b[34m',
cyan: '\x1b[36m',
red: '\x1b[31m',
yellow: '\x1b[33m',
reset: '\x1b[0m',
};
// ── File logger with rotation ─────────────────────────────────────────────
const logsDir = path.join(process.cwd(), 'data/logs');
try { fs.mkdirSync(logsDir, { recursive: true }); } catch {}
const logFilePath = path.join(logsDir, 'trek.log');
function rotateIfNeeded(): void {
try {
if (!fs.existsSync(logFilePath)) return;
const stat = fs.statSync(logFilePath);
if (stat.size < MAX_LOG_SIZE) return;
for (let i = MAX_LOG_FILES - 1; i >= 1; i--) {
const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`;
const dst = `${logFilePath}.${i}`;
if (fs.existsSync(src)) fs.renameSync(src, dst);
}
} catch {}
}
function writeToFile(line: string): void {
try {
rotateIfNeeded();
fs.appendFileSync(logFilePath, line + '\n');
} catch {}
}
// ── Public log helpers ────────────────────────────────────────────────────
function formatTs(): string {
const tz = readEnv().app.tz || 'UTC';
return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T');
}
export function logInfo(msg: string): void {
const ts = formatTs();
console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`);
writeToFile(`[INFO] ${ts} ${msg}`);
}
export function logDebug(msg: string): void {
if (LOG_LEVEL !== 'debug') return;
const ts = formatTs();
console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`);
writeToFile(`[DEBUG] ${ts} ${msg}`);
}
export function logError(msg: string): void {
const ts = formatTs();
console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`);
writeToFile(`[ERROR] ${ts} ${msg}`);
}
export function logWarn(msg: string): void {
const ts = formatTs();
console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`);
writeToFile(`[WARN] ${ts} ${msg}`);
}
export { LOG_LEVEL };
+26
View File
@@ -0,0 +1,26 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { AuditService } from './audit.service';
/**
* Non-Nest entry point for the audit domain — for code running OUTSIDE the
* Nest container (src/index.ts lazy-requires, src/mcp/index.ts and
* oauthProvider.ts, and the legacy airtrail/immich/oauth services). Exports
* the full legacy services/auditLog surface 1:1 so repointing a consumer is an
* import-path-only diff (log*-only consumers import audit-log.logger
* directly). Inside the container, inject AuditService instead. Delete this
* file when the last legacy consumer migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const audit = new AuditService(new DatabaseService(db));
export function writeAudit(entry: Parameters<AuditService['writeAudit']>[0]): void {
return audit.writeAudit(entry);
}
export { getClientIp } from './client-ip';
// LOG_LEVEL has no external consumers today — re-exported purely for 1:1
// surface parity with the legacy module.
export { LOG_LEVEL, logInfo, logDebug, logError, logWarn } from './audit-log.logger';
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
/** Cross-cutting audit domain (Wave 2). No controller/MCP surface of its own —
* the audit-log READ side stays with AdminModule (legacy adminService).
* Exports AuditService (writeAudit) for the in-container consumers; the pure
* helpers (getClientIp, log*) are plain imports from client-ip.ts /
* audit-log.logger.ts. Not @Global — e2e TestingModules resolve it
* transitively through each consumer's explicit import. Registered in
* AppModule. */
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
+89
View File
@@ -0,0 +1,89 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { logInfo, logDebug, logError } from './audit-log.logger';
const ACTION_LABELS: Record<string, string> = {
'user.register': 'registered',
'user.login': 'logged in',
'user.login_failed': 'login failed',
'user.password_change': 'changed password',
'user.account_delete': 'deleted account',
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
'admin.user_delete': 'deleted user',
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
'admin.invite_create': 'created invite',
'immich.private_ip_configured': 'configured Immich with private IP',
};
function buildInfoSummary(action: string, details?: Record<string, unknown>): string {
if (!details || Object.keys(details).length === 0) return '';
if (action === 'trip.create') return ` "${details.title}"`;
if (action === 'trip.delete') return ` tripId=${details.tripId}`;
if (action === 'user.register') return ` ${details.email}`;
if (action === 'user.login') return '';
if (action === 'user.login_failed') return ` reason=${details.reason}`;
if (action === 'settings.app_update') {
const parts: string[] = [];
if (details.notification_channel) parts.push(`channel=${details.notification_channel}`);
if (details.smtp_settings_updated) parts.push('smtp');
if (details.notification_events_updated) parts.push('events');
if (details.webhook_url_updated) parts.push('webhook_url');
if (details.allowed_file_types_updated) parts.push('file_types');
if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`);
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
return '';
}
@Injectable()
export class AuditService {
constructor(private readonly dbs: DatabaseService) {}
private resolveUserEmail(userId: number | null): string {
if (!userId) return 'anonymous';
try {
const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return row?.email || `uid:${userId}`;
} catch { return `uid:${userId}`; }
}
/** Best-effort; never throws — failures are logged only. */
writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
debugDetails?: Record<string, unknown>;
ip?: string | null;
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
this.dbs.run(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`,
entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null
);
const email = this.resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;
const brief = buildInfoSummary(entry.action, entry.details);
logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`);
if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) {
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`);
} else if (detailsJson) {
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`);
}
} catch (e) {
logError(`Audit write failed: ${e instanceof Error ? e.message : e}`);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Request } from 'express';
/**
* Pure request helper — no DB, no side effects — kept out of the injectable
* AuditService so getClientIp-only consumers stay plain-function imports
* (same carve-out precedent as files.constants.ts).
*/
export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
return first || null;
}
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
return req.socket?.remoteAddress || null;
}
+12 -11
View File
@@ -3,7 +3,8 @@ import type { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { OptionalJwtGuard } from './optional-jwt.guard';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import { willDropSecureCookie } from '../../services/cookie';
import type { User } from '../../types';
@@ -23,7 +24,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
*/
@Controller('api/auth')
export class AuthPublicController {
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {}
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
@@ -66,7 +67,7 @@ export class AuthPublicController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
this.auth.setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
@@ -78,7 +79,7 @@ export class AuthPublicController {
const started = Date.now();
const result = this.auth.loginUser(body);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
@@ -110,15 +111,15 @@ export class AuthPublicController {
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
const origin = this.auth.getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed);
@@ -134,13 +135,13 @@ export class AuthPublicController {
const ip = getClientIp(req);
const result = this.auth.resetPassword(body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
throw new HttpException({ error: result.error }, result.status!);
}
if (result.mfa_required) {
return { mfa_required: true };
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
return { success: true };
}
@@ -152,7 +153,7 @@ export class AuthPublicController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
}
+8 -7
View File
@@ -25,7 +25,8 @@ import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import { isDemoEmail } from '../../services/demo';
import type { User } from '../../types';
@@ -60,7 +61,7 @@ const AVATAR_UPLOAD = {
@Controller('api/auth')
@UseGuards(JwtAuthGuard)
export class AuthController {
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {}
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
@@ -87,7 +88,7 @@ export class AuthController {
// Refresh this device's cookie with the new password_version so the user
// stays logged in here while all other sessions are invalidated.
if (result.token) this.auth.setAuthCookie(res, result.token, req);
writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
return { success: true };
}
@@ -97,7 +98,7 @@ export class AuthController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
return { success: true };
}
@@ -176,7 +177,7 @@ export class AuthController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
return { success: true };
}
@@ -209,7 +210,7 @@ export class AuthController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
}
@@ -221,7 +222,7 @@ export class AuthController {
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled };
}
+2
View File
@@ -4,6 +4,7 @@ import { AuthController } from './auth.controller';
import { PasskeyController } from './passkey.controller';
import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { AuditModule } from '../audit/audit.module';
/**
* Auth module — public flows (login/register/reset/mfa-verify/logout) and the
@@ -12,6 +13,7 @@ import { RateLimitService } from './rate-limit.service';
* sub-paths explicitly rather than claiming all of /api/auth.
*/
@Module({
imports: [AuditModule],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, RateLimitService],
})
+7 -6
View File
@@ -5,7 +5,8 @@ import { JwtAuthGuard } from './jwt-auth.guard';
import { PasskeyEnabledGuard } from './passkey-enabled.guard';
import { CurrentUser } from './current-user.decorator';
import { setAuthCookie } from '../../services/cookie';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import * as passkey from '../../services/passkeyService';
import type { User } from '../../types';
@@ -27,7 +28,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
*/
@Controller('api/auth/passkey')
export class PasskeyController {
constructor(private readonly rl: RateLimitService) {}
constructor(private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
@@ -52,7 +53,7 @@ export class PasskeyController {
async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters<typeof passkey.passkeyRegisterVerify>[1]);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
return { success: true, credential: result.credential };
}
@@ -75,14 +76,14 @@ export class PasskeyController {
const started = Date.now();
const result = await passkey.passkeyLoginVerify(body as Parameters<typeof passkey.passkeyLoginVerify>[0]);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
}
// Pad to the same floor as password login so timing can't distinguish a
// known credential from an unknown one.
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
@@ -108,7 +109,7 @@ export class PasskeyController {
this.limit('login', req, 5);
const result = passkey.deletePasskey(user.id, id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
}
+8 -7
View File
@@ -23,7 +23,8 @@ import { BackupService } from './backup.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import { getUploadTmpDir, MAX_BACKUP_UPLOAD_SIZE } from '../../services/backupService';
const UPLOAD = {
@@ -47,7 +48,7 @@ const UPLOAD = {
@Controller('api/backup')
@UseGuards(JwtAuthGuard, AdminGuard)
export class BackupController {
constructor(private readonly backup: BackupService) {}
constructor(private readonly backup: BackupService, private readonly audit: AuditService) {}
@Get('list')
list() {
@@ -66,7 +67,7 @@ export class BackupController {
}
try {
const backup = await this.backup.createBackup();
writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
return { success: true, backup };
} catch {
throw new HttpException({ error: 'Error creating backup' }, 500);
@@ -98,7 +99,7 @@ export class BackupController {
if (!result.success) {
throw new HttpException({ error: result.error }, result.status || 400);
}
writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
return { success: true };
} catch (err) {
if (err instanceof HttpException) throw err;
@@ -120,7 +121,7 @@ export class BackupController {
if (!result.success) {
throw new HttpException({ error: result.error }, result.status || 400);
}
writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
return { success: true };
} catch (err) {
if (err instanceof HttpException) throw err;
@@ -144,7 +145,7 @@ export class BackupController {
updateAutoSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
try {
const settings = this.backup.updateAutoSettings(body || {});
writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
return { settings };
} catch (err) {
console.error('[backup] PUT auto-settings:', err);
@@ -162,7 +163,7 @@ export class BackupController {
throw new HttpException({ error: 'Backup not found' }, 404);
}
this.backup.deleteBackup(filename);
writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
return { success: true };
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuditModule],
controllers: [BackupController],
providers: [BackupService],
})
@@ -5,7 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AirtrailAddonGuard } from './airtrail-addon.guard';
import { AirtrailSettingsDto } from './airtrail.dto';
import { getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import {
getConnectionSettings,
getConnectionStatus,
@@ -4,7 +4,7 @@ import type { User } from '../../types';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
/**
* /api/integrations/memories/immich — Immich connection, browse/search, asset
@@ -6,7 +6,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CookieAuthGuard } from '../auth/cookie-auth.guard';
import { OptionalJwtGuard } from '../auth/optional-jwt.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import type { User } from '../../types';
import type { AuthorizeParams } from '../../services/oauthService';
@@ -2,7 +2,9 @@ import { Controller, Get, Headers, HttpCode, Post, Req, Res } from '@nestjs/comm
import type { Request, Response } from 'express';
import { OauthService } from './oauth.service';
import { RateLimitService } from '../auth/rate-limit.service';
import { writeAudit, getClientIp, logWarn } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { logWarn } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
const MIN = 60_000;
@@ -16,7 +18,7 @@ const MIN = 60_000;
*/
@Controller('oauth')
export class OauthPublicController {
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
@Post('token')
@HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201).
@@ -47,7 +49,7 @@ export class OauthPublicController {
}
const pending = this.oauth.consumeAuthCode(code);
const invalidGrant = (reason: string, userId: number | null) => {
writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
};
if (!pending) return invalidGrant('code_invalid_or_expired', null);
@@ -56,13 +58,13 @@ export class OauthPublicController {
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) return invalidGrant('resource_mismatch', pending.userId);
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
res.json(tokens);
return;
}
@@ -90,12 +92,12 @@ export class OauthPublicController {
const client = this.oauth.authenticateClient(client_id, client_secret);
if (!client) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
return;
}
@@ -114,7 +116,7 @@ export class OauthPublicController {
}
const audience = resource ? resource.replace(/\/+$/, '') : `${this.oauth.mcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
res.json(tokens);
return;
}
@@ -155,7 +157,7 @@ export class OauthPublicController {
}
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
+2
View File
@@ -3,6 +3,7 @@ import { OauthPublicController } from './oauth-public.controller';
import { OauthApiController } from './oauth-api.controller';
import { OauthService } from './oauth.service';
import { RateLimitService } from '../auth/rate-limit.service';
import { AuditModule } from '../audit/audit.module';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
@@ -11,6 +12,7 @@ import { RateLimitService } from '../auth/rate-limit.service';
* strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly.
*/
@Module({
imports: [AuditModule],
controllers: [OauthPublicController, OauthApiController],
providers: [OauthService, RateLimitService],
})
@@ -26,7 +26,7 @@ import { pluginCodeDir, pluginDataDir } from './paths';
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
import { hostSatisfies, hostVersion } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { writeAudit } from '../../services/auditLog';
import { AuditService } from '../audit/audit.service';
import { isAddonEnabled } from '../../services/adminService';
import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
@@ -161,9 +161,11 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
// The registry and host-deps factory stay optional at the type level so tests
// can construct the service without them; Nest always injects the real ones
// (both providers are in the module).
// (both providers are in the module). audit sits before the optionals
// because a required param cannot follow optional ones.
constructor(
private readonly dbs: DatabaseService,
private readonly audit: AuditService,
private readonly registry?: PluginRegistryService,
private readonly hostDeps?: PluginHostDepsFactory,
) {}
@@ -720,7 +722,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
// reconstructible after an incident. This goes to the ADMIN audit log, not the
// plugin capability log — that one answers "what have plugins done in my name?"
// and is shown to end users; a lifecycle action by an admin does not belong there.
writeAudit({
this.audit.writeAudit({
userId: actor.userId,
action: 'admin.plugin_retrust',
resource: id,
@@ -8,7 +8,7 @@ import { PluginRegistryService, RegistryError } from './registry/registry.servic
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
+2 -1
View File
@@ -37,6 +37,7 @@ import { FilesModule } from '../files/files.module';
import { CollabModule } from '../collab/collab.module';
import { VacayModule } from '../vacay/vacay.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
/**
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
@@ -48,7 +49,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
@Module({
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule],
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule, AuditModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
@@ -6,7 +6,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RateLimitService } from '../auth/rate-limit.service';
import { TripInviteLinkCreateDto } from './trip-invite.dto';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
const RL_WINDOW = 15 * 60 * 1000;
@@ -20,7 +21,7 @@ const RL_WINDOW = 15 * 60 * 1000;
@Controller('api/trips/:tripId/invite-link')
@UseGuards(JwtAuthGuard)
export class TripInviteLinkController {
constructor(private readonly invites: TripInviteService) {}
constructor(private readonly invites: TripInviteService, private readonly audit: AuditService) {}
private requireManage(tripId: string, user: User) {
const trip = this.invites.verifyTripAccess(tripId, user.id);
@@ -49,7 +50,7 @@ export class TripInviteLinkController {
? parseInt(String(body.expires_in_days))
: null;
const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null);
writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } });
this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } });
return info;
}
@@ -57,7 +58,7 @@ export class TripInviteLinkController {
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) {
this.requireManage(tripId, user);
this.invites.remove(tripId);
writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) });
this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) });
return { success: true };
}
}
@@ -72,7 +73,7 @@ export class TripInviteLinkController {
@Controller('api/trip-invites')
@UseGuards(JwtAuthGuard)
export class TripInviteController {
constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService) {}
constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(req: Request, max: number): void {
if (!this.rl.check('trip_invite', req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
@@ -95,7 +96,7 @@ export class TripInviteController {
const resolved = this.invites.resolve(token);
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
const result = this.invites.join(resolved.trip_id, user.id);
writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } });
this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } });
return { trip_id: resolved.trip_id, joined: result.joined };
}
}
@@ -3,9 +3,10 @@ import { TripInviteLinkController, TripInviteController } from './trip-invite.co
import { TripInviteService } from './trip-invite.service';
import { RateLimitService } from '../auth/rate-limit.service';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [PermissionsModule],
imports: [PermissionsModule, AuditModule],
controllers: [TripInviteLinkController, TripInviteController],
providers: [TripInviteService, RateLimitService],
})
+9 -7
View File
@@ -27,7 +27,9 @@ import type { User } from '../../types';
import { TripsService } from './trips.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { writeAudit, getClientIp, logInfo } from '../../services/auditLog';
import { getClientIp } from '../audit/client-ip';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { isDemoEmail } from '../../services/demo';
import { NotFoundError, ValidationError } from '../../services/tripService';
import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService';
@@ -66,7 +68,7 @@ const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.get
@Controller('api/trips')
@UseGuards(JwtAuthGuard)
export class TripsController {
constructor(private readonly trips: TripsService) {}
constructor(private readonly trips: TripsService, private readonly audit: AuditService) {}
@Get()
list(@CurrentUser() user: User, @Query('archived') archived?: string) {
@@ -107,7 +109,7 @@ export class TripsController {
}
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined;
const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount });
writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`);
return { trip };
}
@@ -159,7 +161,7 @@ export class TripsController {
this.trips.deleteOldCover(oldCover);
}
if (Object.keys(result.changes).length > 0) {
writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
}
if (result.newReminder !== result.oldReminder) {
@@ -212,7 +214,7 @@ export class TripsController {
}
try {
const newTripId = this.trips.copy(id, user.id, title);
writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } });
this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } });
return { trip: this.trips.getCopiedTrip(newTripId, user.id) };
} catch {
throw new HttpException({ error: 'Failed to copy trip' }, 500);
@@ -229,7 +231,7 @@ export class TripsController {
throw new HttpException({ error: 'No permission to delete this trip' }, 403);
}
const info = this.trips.remove(id, user.id, user.role);
writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } });
this.audit.writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } });
if (info.isAdminDelete && info.ownerEmail) logInfo(`Admin ${user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`);
this.trips.broadcast(String(info.tripId), 'trip:deleted', { id: info.tripId }, socketId);
return { success: true };
@@ -302,7 +304,7 @@ export class TripsController {
}
try {
const result = this.trips.transferOwnership(id, newOwnerId, user.id);
writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
// Nudge everyone viewing the trip to re-read it so the new ownership and the
// recomputed permissions take effect live.
const updatedTrip = this.trips.get(id, user.id);
+2 -1
View File
@@ -5,13 +5,14 @@ import { PackingModule } from '../packing/packing.module';
import { ReservationsModule } from '../reservations/reservations.module';
import { TodoModule } from '../todo/todo.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { TripsController } from './trips.controller';
import { TripsService } from './trips.service';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */
@Module({
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule],
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule],
controllers: [TripsController],
providers: [TripsService],
})
+1 -1
View File
@@ -3,7 +3,7 @@ import archiver from 'archiver';
import { readEnv } from './app-config';
import path from 'node:path';
import fs from 'node:fs';
import { logInfo, logError } from './services/auditLog';
import { logInfo, logError } from './nest/audit/audit-log.logger';
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
@@ -2,7 +2,7 @@ import type { AirtrailFlight } from '@trek/shared';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { writeAudit } from '../../nest/audit/audit.bridge';
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
import { normalizeFlight } from './airtrailMapper';
+1 -1
View File
@@ -2,7 +2,7 @@ import { ADDON_IDS } from '../../addons';
import { db } from '../../db/database';
import { broadcast } from '../../websocket';
import { isAddonEnabled } from '../adminService';
import { logError, logInfo } from '../auditLog';
import { logError, logInfo } from '../../nest/audit/audit-log.logger';
import { getReservation, getReservationWithJoins, updateReservation } from '../../nest/reservations/reservations.bridge';
import {
AirtrailAuthError,
-171
View File
@@ -1,171 +0,0 @@
import { Request } from 'express';
import { readEnv } from '../app-config';
import { db } from '../db/database';
import fs from 'fs';
import path from 'path';
// Frozen at import on purpose (legacy timing; tests/setup.ts sets it pre-import).
const LOG_LEVEL = (readEnv().app.logLevel || 'info').toLowerCase();
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_LOG_FILES = 5;
const C = {
blue: '\x1b[34m',
cyan: '\x1b[36m',
red: '\x1b[31m',
yellow: '\x1b[33m',
reset: '\x1b[0m',
};
// ── File logger with rotation ─────────────────────────────────────────────
const logsDir = path.join(process.cwd(), 'data/logs');
try { fs.mkdirSync(logsDir, { recursive: true }); } catch {}
const logFilePath = path.join(logsDir, 'trek.log');
function rotateIfNeeded(): void {
try {
if (!fs.existsSync(logFilePath)) return;
const stat = fs.statSync(logFilePath);
if (stat.size < MAX_LOG_SIZE) return;
for (let i = MAX_LOG_FILES - 1; i >= 1; i--) {
const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`;
const dst = `${logFilePath}.${i}`;
if (fs.existsSync(src)) fs.renameSync(src, dst);
}
} catch {}
}
function writeToFile(line: string): void {
try {
rotateIfNeeded();
fs.appendFileSync(logFilePath, line + '\n');
} catch {}
}
// ── Public log helpers ────────────────────────────────────────────────────
function formatTs(): string {
const tz = readEnv().app.tz || 'UTC';
return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T');
}
function logInfo(msg: string): void {
const ts = formatTs();
console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`);
writeToFile(`[INFO] ${ts} ${msg}`);
}
function logDebug(msg: string): void {
if (LOG_LEVEL !== 'debug') return;
const ts = formatTs();
console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`);
writeToFile(`[DEBUG] ${ts} ${msg}`);
}
function logError(msg: string): void {
const ts = formatTs();
console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`);
writeToFile(`[ERROR] ${ts} ${msg}`);
}
function logWarn(msg: string): void {
const ts = formatTs();
console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`);
writeToFile(`[WARN] ${ts} ${msg}`);
}
// ── IP + audit ────────────────────────────────────────────────────────────
export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
return first || null;
}
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
return req.socket?.remoteAddress || null;
}
function resolveUserEmail(userId: number | null): string {
if (!userId) return 'anonymous';
try {
const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
return row?.email || `uid:${userId}`;
} catch { return `uid:${userId}`; }
}
const ACTION_LABELS: Record<string, string> = {
'user.register': 'registered',
'user.login': 'logged in',
'user.login_failed': 'login failed',
'user.password_change': 'changed password',
'user.account_delete': 'deleted account',
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
'admin.user_delete': 'deleted user',
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
'admin.invite_create': 'created invite',
'immich.private_ip_configured': 'configured Immich with private IP',
};
/** Best-effort; never throws — failures are logged only. */
export function writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
debugDetails?: Record<string, unknown>;
ip?: string | null;
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
const email = resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;
const brief = buildInfoSummary(entry.action, entry.details);
logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`);
if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) {
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`);
} else if (detailsJson) {
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`);
}
} catch (e) {
logError(`Audit write failed: ${e instanceof Error ? e.message : e}`);
}
}
function buildInfoSummary(action: string, details?: Record<string, unknown>): string {
if (!details || Object.keys(details).length === 0) return '';
if (action === 'trip.create') return ` "${details.title}"`;
if (action === 'trip.delete') return ` tripId=${details.tripId}`;
if (action === 'user.register') return ` ${details.email}`;
if (action === 'user.login') return '';
if (action === 'user.login_failed') return ` reason=${details.reason}`;
if (action === 'settings.app_update') {
const parts: string[] = [];
if (details.notification_channel) parts.push(`channel=${details.notification_channel}`);
if (details.smtp_settings_updated) parts.push('smtp');
if (details.notification_events_updated) parts.push('events');
if (details.webhook_url_updated) parts.push('webhook_url');
if (details.allowed_file_types_updated) parts.push('file_types');
if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`);
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
return '';
}
export { LOG_LEVEL, logInfo, logDebug, logError, logWarn };
@@ -2,7 +2,7 @@ import { Response } from 'express';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { writeAudit } from '../../nest/audit/audit.bridge';
import { addTripPhotos} from './unifiedService';
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService';
+1 -1
View File
@@ -1,6 +1,6 @@
import { readEnv } from '../app-config';
import { db } from '../db/database';
import { logDebug, logError } from './auditLog';
import { logDebug, logError } from '../nest/audit/audit-log.logger';
import {
getActiveChannels,
isEnabledForEvent,
+1 -1
View File
@@ -2,7 +2,7 @@ import { readEnv } from '../app-config';
import { db } from '../db/database';
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
import { decrypt_api_key } from './apiKeyCrypto';
import { logInfo, logDebug, logError } from './auditLog';
import { logInfo, logDebug, logError } from '../nest/audit/audit-log.logger';
// ── Types ──────────────────────────────────────────────────────────────────
import type { NotifEventType } from './notificationPreferencesService';
+1 -1
View File
@@ -4,7 +4,7 @@ import { isAddonEnabled } from './adminService';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { writeAudit, logWarn } from '../nest/audit/audit.bridge';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getMcpSafeUrl } from './notifications';