0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hybrid License System Day 19: 監査ログシステム

Last updated at Posted at 2025-12-18

🎄 科学と神々株式会社 アドベントカレンダー 2025

Hybrid License System Day 19: 監査ログシステム

Admin Service編 (4/5)


📖 はじめに

Day 19では、監査ログシステムを学びます。監査ログの設計、重要操作の記録、ログ検索機能、セキュリティ監視を実装しましょう。


🔍 監査ログの重要性

なぜ監査ログが必要か?

1. セキュリティ
   - 不正アクセスの検出
   - 異常操作の追跡
   - インシデント対応

2. コンプライアンス
   - GDPR対応
   - SOC 2準拠
   - 監査証跡

3. トラブルシューティング
   - 問題の原因特定
   - 操作履歴の追跡
   - データ復旧

4. ビジネス分析
   - ユーザー行動分析
   - 機能利用状況
   - パフォーマンス最適化

🗄️ 監査ログテーブル設計

テーブル定義

CREATE TABLE IF NOT EXISTS audit_logs (
  log_id TEXT PRIMARY KEY,
  user_id TEXT,                    -- 実行ユーザー(NULL可: システム操作)
  action TEXT NOT NULL,             -- 操作種類
  resource_type TEXT,               -- リソースタイプ(user, license, admin)
  resource_id TEXT,                 -- リソースID
  details TEXT,                     -- 詳細(JSON)
  ip_address TEXT,                  -- IPアドレス
  user_agent TEXT,                  -- User-Agent
  timestamp TEXT NOT NULL,          -- タイムスタンプ
  severity TEXT DEFAULT 'INFO',     -- 重要度(INFO, WARNING, ERROR, CRITICAL)
  status TEXT DEFAULT 'SUCCESS',    -- 実行結果(SUCCESS, FAILURE)
  FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL
);

-- インデックス(検索性能向上)
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_logs_severity ON audit_logs(severity);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id);

📝 監査ログ記録実装

AuditLogger クラス

// admin-service/src/services/auditLogger.js
class AuditLogger {
  constructor(db) {
    this.db = db;
  }

  /**
   * 監査ログ記録
   * @param {Object} logData - ログデータ
   */
  log(logData) {
    const {
      userId = null,
      action,
      resourceType = null,
      resourceId = null,
      details = {},
      ipAddress = null,
      userAgent = null,
      severity = 'INFO',
      status = 'SUCCESS'
    } = logData;

    // 検証
    if (!action) {
      throw new Error('Action is required for audit log');
    }

    const logId = `log-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
    const timestamp = new Date().toISOString();

    try {
      this.db.prepare(`
        INSERT INTO audit_logs (
          log_id, user_id, action, resource_type, resource_id,
          details, ip_address, user_agent, timestamp, severity, status
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `).run(
        logId,
        userId,
        action,
        resourceType,
        resourceId,
        JSON.stringify(details),
        ipAddress,
        userAgent,
        timestamp,
        severity,
        status
      );

      return logId;
    } catch (error) {
      console.error('Failed to write audit log:', error);
      // ログ記録失敗は業務処理を止めない
      return null;
    }
  }

  /**
   * ユーザー操作ログ記録(ショートカット)
   */
  logUserAction(userId, action, details = {}, req = null) {
    return this.log({
      userId,
      action,
      resourceType: 'user',
      resourceId: userId,
      details,
      ipAddress: req ? this.getClientIP(req) : null,
      userAgent: req ? req.get('user-agent') : null,
      severity: 'INFO',
      status: 'SUCCESS'
    });
  }

  /**
   * セキュリティイベント記録
   */
  logSecurityEvent(userId, action, details = {}, severity = 'WARNING', req = null) {
    return this.log({
      userId,
      action,
      resourceType: 'security',
      details,
      ipAddress: req ? this.getClientIP(req) : null,
      userAgent: req ? req.get('user-agent') : null,
      severity,
      status: details.success ? 'SUCCESS' : 'FAILURE'
    });
  }

  /**
   * システム操作ログ記録
   */
  logSystemAction(action, details = {}) {
    return this.log({
      userId: null,
      action,
      resourceType: 'system',
      details,
      severity: 'INFO',
      status: 'SUCCESS'
    });
  }

  /**
   * クライアントIPアドレス取得
   */
  getClientIP(req) {
    return req.headers['x-forwarded-for']?.split(',')[0].trim() ||
           req.headers['x-real-ip'] ||
           req.connection.remoteAddress ||
           req.socket.remoteAddress;
  }
}

module.exports = AuditLogger;

🔌 監査ログミドルウェア

Express ミドルウェア統合

// admin-service/src/middleware/auditMiddleware.js
const AuditLogger = require('../services/auditLogger');

class AuditMiddleware {
  constructor(db) {
    this.auditLogger = new AuditLogger(db);
  }

  /**
   * 監査ログミドルウェア
   */
  logRequest() {
    return (req, res, next) => {
      // リクエスト開始時刻
      const startTime = Date.now();

      // レスポンス完了時にログ記録
      res.on('finish', () => {
        const duration = Date.now() - startTime;

        // ログ対象の操作か判定
        if (this.shouldLog(req.method, req.path)) {
          const action = this.getActionName(req.method, req.path);
          const severity = this.getSeverity(res.statusCode);

          this.auditLogger.log({
            userId: req.user?.userId || req.user?.admin_id || null,
            action,
            resourceType: this.getResourceType(req.path),
            resourceId: req.params?.userId || req.params?.licenseId || null,
            details: {
              method: req.method,
              path: req.path,
              statusCode: res.statusCode,
              duration: `${duration}ms`,
              query: req.query,
              params: req.params
            },
            ipAddress: this.auditLogger.getClientIP(req),
            userAgent: req.get('user-agent'),
            severity,
            status: res.statusCode < 400 ? 'SUCCESS' : 'FAILURE'
          });
        }
      });

      next();
    };
  }

  /**
   * ログ記録が必要か判定
   */
  shouldLog(method, path) {
    // GET以外は全て記録
    if (method !== 'GET') {
      return true;
    }

    // 重要なGETエンドポイントのみ記録
    const importantPaths = ['/api/admin/users/', '/api/admin/licenses/'];
    return importantPaths.some(p => path.startsWith(p));
  }

  /**
   * アクション名生成
   */
  getActionName(method, path) {
    const resource = this.getResourceType(path);

    const actionMap = {
      POST: `${resource.toUpperCase()}_CREATED`,
      PUT: `${resource.toUpperCase()}_UPDATED`,
      PATCH: `${resource.toUpperCase()}_UPDATED`,
      DELETE: `${resource.toUpperCase()}_DELETED`,
      GET: `${resource.toUpperCase()}_ACCESSED`
    };

    return actionMap[method] || 'UNKNOWN_ACTION';
  }

  /**
   * リソースタイプ取得
   */
  getResourceType(path) {
    if (path.includes('/users')) return 'user';
    if (path.includes('/licenses')) return 'license';
    if (path.includes('/admins')) return 'admin';
    if (path.includes('/analytics')) return 'analytics';
    return 'unknown';
  }

  /**
   * 重要度判定
   */
  getSeverity(statusCode) {
    if (statusCode >= 500) return 'ERROR';
    if (statusCode >= 400) return 'WARNING';
    return 'INFO';
  }
}

module.exports = AuditMiddleware;

🔍 ログ検索API

検索・フィルタリング機能

// admin-service/src/controllers/auditLogController.js
class AuditLogController {
  constructor(db) {
    this.db = db;
  }

  /**
   * 監査ログ一覧取得
   */
  async getLogs(req, res) {
    try {
      const {
        page = 1,
        pageSize = 50,
        userId,
        action,
        severity,
        status,
        resourceType,
        startDate,
        endDate,
        search
      } = req.query;

      // WHERE句構築
      let whereClause = '1=1';
      const params = [];

      if (userId) {
        whereClause += ' AND user_id = ?';
        params.push(userId);
      }

      if (action) {
        whereClause += ' AND action = ?';
        params.push(action);
      }

      if (severity) {
        whereClause += ' AND severity = ?';
        params.push(severity);
      }

      if (status) {
        whereClause += ' AND status = ?';
        params.push(status);
      }

      if (resourceType) {
        whereClause += ' AND resource_type = ?';
        params.push(resourceType);
      }

      if (startDate) {
        whereClause += ' AND timestamp >= ?';
        params.push(startDate);
      }

      if (endDate) {
        whereClause += ' AND timestamp <= ?';
        params.push(endDate);
      }

      if (search) {
        whereClause += ' AND (action LIKE ? OR details LIKE ?)';
        params.push(`%${search}%`, `%${search}%`);
      }

      // 総件数
      const countStmt = this.db.prepare(`
        SELECT COUNT(*) as count
        FROM audit_logs
        WHERE ${whereClause}
      `);
      const { count: total } = countStmt.get(...params);

      // データ取得
      const offset = (parseInt(page) - 1) * parseInt(pageSize);
      const dataStmt = this.db.prepare(`
        SELECT *
        FROM audit_logs
        WHERE ${whereClause}
        ORDER BY timestamp DESC
        LIMIT ? OFFSET ?
      `);

      const logs = dataStmt.all(...params, parseInt(pageSize), offset);

      // JSON詳細をパース
      const parsedLogs = logs.map(log => ({
        ...log,
        details: log.details ? JSON.parse(log.details) : {}
      }));

      res.json({
        success: true,
        data: parsedLogs,
        pagination: {
          page: parseInt(page),
          pageSize: parseInt(pageSize),
          total,
          totalPages: Math.ceil(total / parseInt(pageSize))
        }
      });
    } catch (error) {
      console.error('Get audit logs error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }

  /**
   * セキュリティアラート検索
   */
  async getSecurityAlerts(req, res) {
    try {
      const { hours = 24 } = req.query;

      const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();

      const alerts = this.db.prepare(`
        SELECT *
        FROM audit_logs
        WHERE severity IN ('WARNING', 'ERROR', 'CRITICAL')
          AND timestamp >= ?
        ORDER BY timestamp DESC
      `).all(since);

      const parsedAlerts = alerts.map(log => ({
        ...log,
        details: log.details ? JSON.parse(log.details) : {}
      }));

      res.json({
        success: true,
        data: {
          count: parsedAlerts.length,
          alerts: parsedAlerts
        }
      });
    } catch (error) {
      console.error('Get security alerts error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }

  /**
   * ユーザー操作履歴
   */
  async getUserActivity(req, res) {
    try {
      const { userId } = req.params;
      const { limit = 100 } = req.query;

      const activity = this.db.prepare(`
        SELECT *
        FROM audit_logs
        WHERE user_id = ?
        ORDER BY timestamp DESC
        LIMIT ?
      `).all(userId, parseInt(limit));

      const parsedActivity = activity.map(log => ({
        ...log,
        details: log.details ? JSON.parse(log.details) : {}
      }));

      res.json({
        success: true,
        data: parsedActivity
      });
    } catch (error) {
      console.error('Get user activity error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }
}

module.exports = AuditLogController;

🧪 テストケース

// admin-service/tests/auditLog.test.js
const AuditLogger = require('../src/services/auditLogger');
const DBService = require('../src/db/dbService');

describe('Audit Log System', () => {
  let db, auditLogger;

  beforeEach(() => {
    db = new DBService();
    auditLogger = new AuditLogger(db.db);
  });

  it('should log user action', () => {
    const logId = auditLogger.logUserAction('user-123', 'USER_LOGIN', {
      success: true
    });

    expect(logId).toBeTruthy();

    const log = db.db.prepare('SELECT * FROM audit_logs WHERE log_id = ?').get(logId);
    expect(log.user_id).toBe('user-123');
    expect(log.action).toBe('USER_LOGIN');
  });

  it('should log security event with high severity', () => {
    const logId = auditLogger.logSecurityEvent(
      'user-123',
      'FAILED_LOGIN_ATTEMPT',
      { attempts: 5 },
      'WARNING'
    );

    const log = db.db.prepare('SELECT * FROM audit_logs WHERE log_id = ?').get(logId);
    expect(log.severity).toBe('WARNING');
  });

  it('should search logs by action', () => {
    auditLogger.log({ action: 'USER_CREATED', details: { email: 'test@example.com' } });

    const logs = db.db.prepare(
      'SELECT * FROM audit_logs WHERE action = ?'
    ).all('USER_CREATED');

    expect(logs.length).toBeGreaterThan(0);
  });
});

🎯 次のステップ

Day 20では、バニラJS管理ダッシュボードを学びます。Reactコンポーネント設計、状態管理(Context API)、API連携、リアルタイム更新について詳しく解説します。


🔗 関連リンク


次回予告: Day 20では、フェッチパターンを詳しく解説します!


Copyright © 2025 Gods & Golem, Inc. All rights reserved.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?