第 4 篇:日志与审计 —— 合规不是负担,而是信任资产
在前几篇文章中,我们探讨了多租户、认证和授权等安全相关的话题。现在,让我们关注另一个重要的横切关注点:日志与审计。
在现代 SaaS 应用中,日志不仅是调试和监控的工具,更是满足合规要求、建立用户信任的重要资产。正确实现日志与审计系统,可以帮助我们快速定位问题、满足法规要求,并为业务决策提供数据支持。
操作日志 vs 系统日志
在构建日志系统时,首先要区分操作日志和系统日志:
系统日志
系统日志主要用于记录应用程序的运行状态、错误信息和调试信息:
typescript
// 系统日志服务
@Injectable()
export class SystemLogger {
private logger: winston.Logger;
constructor() {
this.logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'saas-app' },
transports: [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10000000, // 10MB
maxFiles: 5,
}),
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 10000000, // 10MB
maxFiles: 5,
}),
],
});
// 在非生产环境中输出到控制台
if (process.env.NODE_ENV !== 'production') {
this.logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
}));
}
}
error(message: string, meta?: any) {
this.logger.error(message, meta);
}
warn(message: string, meta?: any) {
this.logger.warn(message, meta);
}
info(message: string, meta?: any) {
this.logger.info(message, meta);
}
debug(message: string, meta?: any) {
this.logger.debug(message, meta);
}
}操作日志
操作日志用于记录用户的关键业务操作,是审计和合规的基础:
typescript
// 操作日志实体
@Entity()
export class OperationLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
tenantId: string;
@Column()
userId: string;
@Column()
username: string;
@Column()
action: string; // 操作类型:CREATE, UPDATE, DELETE, READ
@Column()
resourceType: string; // 资源类型:Order, User, Product
@Column()
resourceId: string; // 资源ID
@Column({ type: 'jsonb' })
beforeState: any; // 操作前状态
@Column({ type: 'jsonb' })
afterState: any; // 操作后状态
@Column({ type: 'jsonb', nullable: true })
metadata: any; // 额外元数据
@Column()
ipAddress: string;
@Column()
userAgent: string;
@Column({ type: 'timestamp' })
timestamp: Date;
}
// 操作日志服务
@Injectable()
export class OperationLogService {
constructor(
@InjectRepository(OperationLog)
private readonly logRepository: Repository<OperationLog>,
private readonly systemLogger: SystemLogger,
) {}
async logOperation(logEntry: Partial<OperationLog>): Promise<void> {
try {
const log = this.logRepository.create({
...logEntry,
timestamp: new Date(),
});
await this.logRepository.save(log);
} catch (error) {
// 即使日志记录失败,也不应影响主业务流程
this.systemLogger.error('Failed to log operation', {
error: error.message,
logEntry,
});
}
}
async queryLogs(
tenantId: string,
filters?: {
userId?: string;
action?: string;
resourceType?: string;
startDate?: Date;
endDate?: Date;
},
pagination?: {
page: number;
limit: number;
},
): Promise<[OperationLog[], number]> {
const queryBuilder = this.logRepository.createQueryBuilder('log')
.where('log.tenantId = :tenantId', { tenantId });
if (filters?.userId) {
queryBuilder.andWhere('log.userId = :userId', { userId: filters.userId });
}
if (filters?.action) {
queryBuilder.andWhere('log.action = :action', { action: filters.action });
}
if (filters?.resourceType) {
queryBuilder.andWhere('log.resourceType = :resourceType', { resourceType: filters.resourceType });
}
if (filters?.startDate) {
queryBuilder.andWhere('log.timestamp >= :startDate', { startDate: filters.startDate });
}
if (filters?.endDate) {
queryBuilder.andWhere('log.timestamp <= :endDate', { endDate: filters.endDate });
}
if (pagination) {
queryBuilder
.skip((pagination.page - 1) * pagination.limit)
.take(pagination.limit);
}
return queryBuilder.getManyAndCount();
}
}如何自动记录"谁在租户 X 下修改了订单 Y"?
为了自动记录用户操作,我们可以使用拦截器和装饰器模式:
typescript
// 审计装饰器
export interface AuditOptions {
resourceType: string;
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'READ';
includeState?: boolean; // 是否记录操作前后状态
maskFields?: string[]; // 需要掩码的字段(如密码)
}
export function Audit(options: AuditOptions): MethodDecorator {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// 在方法执行前记录
const startTime = Date.now();
// 执行原方法
const result = originalMethod.apply(this, args);
// 处理异步方法
if (result instanceof Promise) {
return result.then(async (resolvedResult) => {
await this['auditService'].logOperation({
options,
args,
result: resolvedResult,
executionTime: Date.now() - startTime,
});
return resolvedResult;
}).catch(async (error) => {
await this['auditService'].logOperation({
options,
args,
error: error.message,
executionTime: Date.now() - startTime,
});
throw error;
});
} else {
// 处理同步方法
this['auditService'].logOperation({
options,
args,
result,
executionTime: Date.now() - startTime,
});
return result;
}
};
};
}
// 审计拦截器
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(
private readonly operationLogService: OperationLogService,
@Inject(REQUEST) private readonly request: any,
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const handler = context.getHandler();
const className = context.getClass().name;
const handlerName = handler.name;
// 获取审计配置
const auditOptions = Reflect.getMetadata('audit:options', handler);
if (!auditOptions) {
return next.handle();
}
const startTime = Date.now();
return next.handle().pipe(
tap(async (result) => {
await this.logOperation(request, auditOptions, result, Date.now() - startTime);
}),
catchError(async (error) => {
await this.logOperation(request, auditOptions, null, Date.now() - startTime, error);
throw error;
}),
);
}
private async logOperation(
request: any,
options: AuditOptions,
result: any,
executionTime: number,
error?: Error,
) {
try {
const user = request.user;
if (!user) {
return;
}
const logEntry: Partial<OperationLog> = {
tenantId: user.tenantId,
userId: user.id,
username: user.username,
action: options.action,
resourceType: options.resourceType,
ipAddress: request.ip || request.connection?.remoteAddress,
userAgent: request.get('User-Agent'),
metadata: {
executionTime,
...(error ? { error: error.message } : {}),
},
};
// 如果需要记录状态变化
if (options.includeState) {
// 这里需要根据具体业务实现状态获取逻辑
// 例如从数据库查询操作前后的状态
}
// 如果指定了资源ID,从结果中提取
if (result && typeof result === 'object') {
if (result.id) {
logEntry.resourceId = result.id.toString();
}
}
await this.operationLogService.logOperation(logEntry);
} catch (logError) {
// 避免日志记录错误影响主流程
console.error('Failed to log operation:', logError);
}
}
}
// 使用示例
@Controller('orders')
@UseInterceptors(AuditInterceptor)
export class OrderController {
constructor(
private readonly orderService: OrderService,
) {}
@Post()
@Audit({
resourceType: 'Order',
action: 'CREATE',
includeState: true,
})
async createOrder(@Body() createOrderDto: CreateOrderDto) {
return this.orderService.create(createOrderDto);
}
@Put(':id')
@Audit({
resourceType: 'Order',
action: 'UPDATE',
includeState: true,
})
async updateOrder(@Param('id') id: string, @Body() updateOrderDto: UpdateOrderDto) {
return this.orderService.update(id, updateOrderDto);
}
@Delete(':id')
@Audit({
resourceType: 'Order',
action: 'DELETE',
})
async deleteOrder(@Param('id') id: string) {
return this.orderService.delete(id);
}
}结构化日志 + ELK / Loki 实践
为了更好地分析和查询日志,我们需要实现结构化日志,并集成到日志分析平台:
typescript
// 结构化日志服务
@Injectable()
export class StructuredLogger {
private logger: winston.Logger;
constructor(
private readonly configService: ConfigService,
) {
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.metadata(),
winston.format((info) => {
// 添加跟踪ID
if (!info.traceId) {
info.traceId = this.generateTraceId();
}
// 添加租户ID(如果可用)
if (info.request?.user?.tenantId) {
info.tenantId = info.request.user.tenantId;
}
return info;
})(),
winston.format.json(),
);
this.logger = winston.createLogger({
level: this.configService.get('LOG_LEVEL', 'info'),
format: logFormat,
defaultMeta: {
service: this.configService.get('SERVICE_NAME', 'saas-app'),
},
transports: [
new winston.transports.File({
filename: 'logs/structured.log',
maxsize: 50000000, // 50MB
maxFiles: 10,
}),
],
});
// 添加 Loki 传输(如果配置了)
const lokiUrl = this.configService.get('LOKI_URL');
if (lokiUrl) {
// 注意:需要安装 winston-loki 包
// this.logger.add(new LokiTransport({
// host: lokiUrl,
// labels: {
// app: this.configService.get('SERVICE_NAME', 'saas-app'),
// environment: this.configService.get('NODE_ENV', 'development'),
// },
// format: logFormat,
// }));
}
}
private generateTraceId(): string {
return uuidv4();
}
log(level: string, message: string, meta?: any) {
this.logger.log(level, message, meta);
}
error(message: string, meta?: any) {
this.logger.error(message, meta);
}
warn(message: string, meta?: any) {
this.logger.warn(message, meta);
}
info(message: string, meta?: any) {
this.logger.info(message, meta);
}
debug(message: string, meta?: any) {
this.logger.debug(message, meta);
}
}
// 请求上下文中间件,用于传递跟踪信息
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 生成或传递跟踪ID
const traceId = req.headers['x-trace-id'] as string || uuidv4();
(req as any).traceId = traceId;
// 在响应头中返回跟踪ID,便于调试
res.setHeader('X-Trace-ID', traceId);
next();
}
}GDPR/等保要求下的最小审计字段清单
根据不同法规要求,我们需要记录特定的审计字段:
typescript
// 合规审计日志实体
@Entity()
export class ComplianceAuditLog {
@PrimaryGeneratedColumn()
id: number;
// 基础字段
@Column()
tenantId: string;
@Column()
userId: string;
@Column()
username: string;
// 操作信息
@Column()
action: string;
@Column()
resourceType: string;
@Column()
resourceId: string;
// 合规必需字段
@Column()
sessionId: string; // 会话ID
@Column()
ipAddress: string;
@Column()
userAgent: string;
@Column({ type: 'jsonb' })
requestData: any; // 请求数据(敏感字段需要掩码)
@Column({ type: 'jsonb', nullable: true })
responseData: any; // 响应数据(敏感字段需要掩码)
@Column()
success: boolean; // 操作是否成功
@Column({ type: 'text', nullable: true })
errorMessage: string; // 错误信息
@Column({ type: 'timestamp' })
timestamp: Date;
// GDPR 相关字段
@Column({ type: 'timestamp', nullable: true })
retentionUntil: Date; // 数据保留期限
@Column({ default: false })
consentGiven: boolean; // 是否已获得用户同意
// 等保相关字段
@Column({ type: 'jsonb', nullable: true })
securityContext: any; // 安全上下文信息
}
// 数据掩码工具
@Injectable()
export class DataMaskingService {
private readonly sensitiveFields = [
'password',
'passwordConfirmation',
'creditCard',
'ssn',
'phoneNumber',
'email',
];
maskData(data: any, fieldsToMask?: string[]): any {
if (!data || typeof data !== 'object') {
return data;
}
const maskedData = { ...data };
const fields = fieldsToMask || this.sensitiveFields;
for (const field of fields) {
if (field in maskedData) {
maskedData[field] = this.maskValue(maskedData[field]);
}
}
// 递归处理嵌套对象
for (const key in maskedData) {
if (typeof maskedData[key] === 'object' && maskedData[key] !== null) {
maskedData[key] = this.maskData(maskedData[key], fields);
}
}
return maskedData;
}
private maskValue(value: any): string {
if (typeof value !== 'string') {
value = String(value);
}
if (value.length <= 4) {
return '*'.repeat(value.length);
}
return value.substring(0, 2) + '*'.repeat(value.length - 4) + value.substring(value.length - 2);
}
}
// 合规审计服务
@Injectable()
export class ComplianceAuditService {
constructor(
@InjectRepository(ComplianceAuditLog)
private readonly auditLogRepository: Repository<ComplianceAuditLog>,
private readonly dataMaskingService: DataMaskingService,
private readonly configService: ConfigService,
) {}
async logComplianceEvent(event: {
tenantId: string;
userId: string;
username: string;
action: string;
resourceType: string;
resourceId?: string;
requestData?: any;
responseData?: any;
success: boolean;
errorMessage?: string;
sessionId: string;
ipAddress: string;
userAgent: string;
}): Promise<void> {
try {
// 根据法规要求设置数据保留期限
const retentionDays = this.configService.get<number>('AUDIT_RETENTION_DAYS', 365);
const retentionUntil = new Date();
retentionUntil.setDate(retentionUntil.getDate() + retentionDays);
const auditLog = this.auditLogRepository.create({
...event,
requestData: event.requestData
? this.dataMaskingService.maskData(event.requestData)
: null,
responseData: event.responseData
? this.dataMaskingService.maskData(event.responseData)
: null,
retentionUntil,
timestamp: new Date(),
});
await this.auditLogRepository.save(auditLog);
} catch (error) {
console.error('Failed to log compliance event:', error);
// 不应影响主业务流程
}
}
// 清理过期审计日志
@Cron('0 0 2 * * *') // 每天凌晨2点执行
async cleanupExpiredLogs() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 1); // 删除昨天之前的过期日志
await this.auditLogRepository
.createQueryBuilder()
.delete()
.where('retentionUntil < :cutoffDate', { cutoffDate })
.execute();
}
}创业团队行动清单
立即行动:
- 实现基本的操作日志记录功能
- 在关键业务接口上添加审计装饰器
- 建立结构化日志格式
一周内完成:
- 集成日志分析平台(如 ELK 或 Loki)
- 实现请求跟踪ID机制
- 添加数据掩码功能
一月内完善:
- 根据适用法规完善审计字段
- 实现日志自动清理机制
- 建立日志监控和告警
总结
日志与审计系统是现代 SaaS 应用不可或缺的组成部分,正确的实现需要:
- 区分操作日志和系统日志:各有不同的用途和实现方式
- 自动化记录:通过拦截器和装饰器减少手动记录工作
- 结构化存储:便于查询和分析
- 合规考虑:根据适用法规记录必要字段并处理数据保留
在下一篇文章中,我们将探讨异常处理机制,确保系统在出现问题时能够优雅地响应并提供有用的错误信息。