Skip to content

第 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();
  }
}

创业团队行动清单

  1. 立即行动

    • 实现基本的操作日志记录功能
    • 在关键业务接口上添加审计装饰器
    • 建立结构化日志格式
  2. 一周内完成

    • 集成日志分析平台(如 ELK 或 Loki)
    • 实现请求跟踪ID机制
    • 添加数据掩码功能
  3. 一月内完善

    • 根据适用法规完善审计字段
    • 实现日志自动清理机制
    • 建立日志监控和告警

总结

日志与审计系统是现代 SaaS 应用不可或缺的组成部分,正确的实现需要:

  1. 区分操作日志和系统日志:各有不同的用途和实现方式
  2. 自动化记录:通过拦截器和装饰器减少手动记录工作
  3. 结构化存储:便于查询和分析
  4. 合规考虑:根据适用法规记录必要字段并处理数据保留

在下一篇文章中,我们将探讨异常处理机制,确保系统在出现问题时能够优雅地响应并提供有用的错误信息。