mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
chore: move auditLog backend to proper DI
This commit is contained in:
+2
-2
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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 {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user