🎄 科学と神々株式会社 アドベントカレンダー 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.