feat:DB 트리거 기반 데이터 변경 추적 시스템 구현
Phase 1: DB 기반 구축 - trigger_audit_logs 테이블 (RANGE 파티셔닝 15개, 3개 인덱스) - 789개 MySQL AFTER 트리거 (263 테이블 × INSERT/UPDATE/DELETE) - SetAuditSessionVariables 미들웨어 (@sam_actor_id, @sam_session_info) Phase 2: 복구 메커니즘 - TriggerAuditLog 모델, TriggerAuditLogService, AuditRollbackService - 6개 API 엔드포인트 (index, show, stats, history, rollback-preview, rollback) - FormRequest 검증 (TriggerAuditLogIndexRequest, TriggerAuditRollbackRequest) Phase 3: 관리 도구 - v_unified_audit VIEW (APP + TRIGGER 통합, COLLATE 처리) - audit:partitions 커맨드 (파티션 추가/삭제, dry-run) - audit:triggers 커맨드 (트리거 재생성, 테이블별/전체) - 월 1회 파티션 자동 관리 스케줄러 등록 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 파티셔닝은 Schema::create에서 지원하지 않으므로 raw SQL 사용
|
||||
DB::statement("
|
||||
CREATE TABLE trigger_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT,
|
||||
table_name VARCHAR(64) NOT NULL COMMENT '변경된 테이블명',
|
||||
row_id VARCHAR(64) NOT NULL COMMENT '변경된 레코드 PK',
|
||||
dml_type ENUM('INSERT','UPDATE','DELETE') NOT NULL COMMENT 'DML 유형',
|
||||
old_values JSON DEFAULT NULL COMMENT '변경 전 값 (INSERT시 NULL)',
|
||||
new_values JSON DEFAULT NULL COMMENT '변경 후 값 (DELETE시 NULL)',
|
||||
changed_columns JSON DEFAULT NULL COMMENT 'UPDATE시 변경된 컬럼 목록',
|
||||
tenant_id BIGINT UNSIGNED DEFAULT NULL COMMENT '테넌트 ID',
|
||||
actor_id BIGINT UNSIGNED DEFAULT NULL COMMENT '사용자 ID (세션변수)',
|
||||
session_info VARCHAR(500) DEFAULT NULL COMMENT '세션 정보 JSON (IP, UA 등)',
|
||||
db_user VARCHAR(100) DEFAULT NULL COMMENT 'CURRENT_USER()',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 시각',
|
||||
PRIMARY KEY (id, created_at)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='DB 트리거 기반 데이터 변경 추적'
|
||||
PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
|
||||
PARTITION p202601 VALUES LESS THAN (UNIX_TIMESTAMP('2026-02-01')),
|
||||
PARTITION p202602 VALUES LESS THAN (UNIX_TIMESTAMP('2026-03-01')),
|
||||
PARTITION p202603 VALUES LESS THAN (UNIX_TIMESTAMP('2026-04-01')),
|
||||
PARTITION p202604 VALUES LESS THAN (UNIX_TIMESTAMP('2026-05-01')),
|
||||
PARTITION p202605 VALUES LESS THAN (UNIX_TIMESTAMP('2026-06-01')),
|
||||
PARTITION p202606 VALUES LESS THAN (UNIX_TIMESTAMP('2026-07-01')),
|
||||
PARTITION p202607 VALUES LESS THAN (UNIX_TIMESTAMP('2026-08-01')),
|
||||
PARTITION p202608 VALUES LESS THAN (UNIX_TIMESTAMP('2026-09-01')),
|
||||
PARTITION p202609 VALUES LESS THAN (UNIX_TIMESTAMP('2026-10-01')),
|
||||
PARTITION p202610 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01')),
|
||||
PARTITION p202611 VALUES LESS THAN (UNIX_TIMESTAMP('2026-12-01')),
|
||||
PARTITION p202612 VALUES LESS THAN (UNIX_TIMESTAMP('2027-01-01')),
|
||||
PARTITION p202701 VALUES LESS THAN (UNIX_TIMESTAMP('2027-02-01')),
|
||||
PARTITION p202702 VALUES LESS THAN (UNIX_TIMESTAMP('2027-03-01')),
|
||||
PARTITION p_future VALUES LESS THAN MAXVALUE
|
||||
)
|
||||
");
|
||||
|
||||
// 조회 성능 인덱스
|
||||
DB::statement("
|
||||
CREATE INDEX ix_trig_table_row_created
|
||||
ON trigger_audit_logs (table_name, row_id, created_at)
|
||||
");
|
||||
|
||||
DB::statement("
|
||||
CREATE INDEX ix_trig_tenant_created
|
||||
ON trigger_audit_logs (tenant_id, created_at)
|
||||
");
|
||||
|
||||
DB::statement("
|
||||
CREATE INDEX ix_trig_dml_created
|
||||
ON trigger_audit_logs (dml_type, created_at)
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS trigger_audit_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 전체 대상 테이블에 AFTER INSERT/UPDATE/DELETE 트리거를 자동 생성
|
||||
*
|
||||
* MySQL은 CREATE TRIGGER를 PREPARE/EXECUTE로 실행할 수 없으므로
|
||||
* SP 대신 PHP에서 INFORMATION_SCHEMA를 읽고 DDL을 직접 실행한다.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** 트리거 미적용 테이블 */
|
||||
private array $excludeTables = [
|
||||
'audit_logs',
|
||||
'trigger_audit_logs',
|
||||
'personal_access_tokens',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'migrations',
|
||||
'password_reset_tokens',
|
||||
];
|
||||
|
||||
/** 변경 추적 제외 컬럼 */
|
||||
private array $excludeColumns = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$dbName = DB::getDatabaseName();
|
||||
|
||||
// 대상 테이블 조회
|
||||
$tables = DB::select("
|
||||
SELECT TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_TYPE = 'BASE TABLE'
|
||||
AND TABLE_NAME NOT LIKE 'telescope\\_%'
|
||||
ORDER BY TABLE_NAME
|
||||
", [$dbName]);
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($tables as $tableRow) {
|
||||
$tableName = $tableRow->TABLE_NAME;
|
||||
|
||||
if (in_array($tableName, $this->excludeTables, true)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->createTriggersForTable($dbName, $tableName);
|
||||
$created++;
|
||||
} catch (\Throwable $e) {
|
||||
// 개별 테이블 실패 시 로그 남기고 계속 진행
|
||||
logger()->warning("Audit trigger creation failed for {$tableName}: {$e->getMessage()}");
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
logger()->info("Audit triggers: {$created} tables processed, {$skipped} skipped");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$dbName = DB::getDatabaseName();
|
||||
|
||||
$triggers = DB::select("
|
||||
SELECT TRIGGER_NAME
|
||||
FROM INFORMATION_SCHEMA.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = ?
|
||||
AND (TRIGGER_NAME LIKE 'trg\\_%\\_ai'
|
||||
OR TRIGGER_NAME LIKE 'trg\\_%\\_au'
|
||||
OR TRIGGER_NAME LIKE 'trg\\_%\\_ad')
|
||||
", [$dbName]);
|
||||
|
||||
foreach ($triggers as $trigger) {
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS `{$trigger->TRIGGER_NAME}`");
|
||||
}
|
||||
}
|
||||
|
||||
private function createTriggersForTable(string $dbName, string $tableName): void
|
||||
{
|
||||
// PK 컬럼 확인
|
||||
$pkRow = DB::selectOne("
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI'
|
||||
ORDER BY ORDINAL_POSITION LIMIT 1
|
||||
", [$dbName, $tableName]);
|
||||
|
||||
if (! $pkRow) {
|
||||
return; // PK 없으면 스킵
|
||||
}
|
||||
$pk = $pkRow->COLUMN_NAME;
|
||||
|
||||
// 컬럼 목록 (제외 컬럼 필터링)
|
||||
$columns = DB::select("
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
", [$dbName, $tableName]);
|
||||
|
||||
$cols = [];
|
||||
$hasTenantId = false;
|
||||
foreach ($columns as $col) {
|
||||
if ($col->COLUMN_NAME === 'tenant_id') {
|
||||
$hasTenantId = true;
|
||||
}
|
||||
if (! in_array($col->COLUMN_NAME, $this->excludeColumns, true)) {
|
||||
$cols[] = $col->COLUMN_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($cols)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON_OBJECT 구문 조립
|
||||
$jsonNew = implode(',', array_map(fn ($c) => "'{$c}', NEW.`{$c}`", $cols));
|
||||
$jsonOld = implode(',', array_map(fn ($c) => "'{$c}', OLD.`{$c}`", $cols));
|
||||
|
||||
// UPDATE 변경 감지 조건
|
||||
$changeCheck = implode(' OR ', array_map(
|
||||
fn ($c) => "NOT(OLD.`{$c}` <=> NEW.`{$c}`)",
|
||||
$cols
|
||||
));
|
||||
|
||||
// changed_columns 배열 (변경된 컬럼명만)
|
||||
$changedCols = implode(',', array_map(
|
||||
fn ($c) => "IF(NOT(OLD.`{$c}` <=> NEW.`{$c}`),'{$c}',NULL)",
|
||||
$cols
|
||||
));
|
||||
|
||||
$tenantNew = $hasTenantId ? "NEW.`tenant_id`" : 'NULL';
|
||||
$tenantOld = $hasTenantId ? "OLD.`tenant_id`" : 'NULL';
|
||||
|
||||
// 기존 트리거 삭제
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$tableName}_ai`");
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$tableName}_au`");
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$tableName}_ad`");
|
||||
|
||||
// AFTER INSERT
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$tableName}_ai` AFTER INSERT ON `{$tableName}`
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN
|
||||
INSERT INTO trigger_audit_logs
|
||||
(table_name, row_id, dml_type, old_values, new_values, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES
|
||||
('{$tableName}', CAST(NEW.`{$pk}` AS CHAR), 'INSERT', NULL,
|
||||
JSON_OBJECT({$jsonNew}),
|
||||
{$tenantNew}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
|
||||
// AFTER UPDATE (변경 있을 때만)
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$tableName}_au` AFTER UPDATE ON `{$tableName}`
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN
|
||||
IF {$changeCheck} THEN
|
||||
INSERT INTO trigger_audit_logs
|
||||
(table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES
|
||||
('{$tableName}', CAST(NEW.`{$pk}` AS CHAR), 'UPDATE',
|
||||
JSON_OBJECT({$jsonOld}),
|
||||
JSON_OBJECT({$jsonNew}),
|
||||
JSON_ARRAY({$changedCols}),
|
||||
{$tenantNew}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
|
||||
// AFTER DELETE
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$tableName}_ad` AFTER DELETE ON `{$tableName}`
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN
|
||||
INSERT INTO trigger_audit_logs
|
||||
(table_name, row_id, dml_type, old_values, new_values, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES
|
||||
('{$tableName}', CAST(OLD.`{$pk}` AS CHAR), 'DELETE',
|
||||
JSON_OBJECT({$jsonOld}), NULL,
|
||||
{$tenantOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('DROP VIEW IF EXISTS v_unified_audit');
|
||||
|
||||
// connection charset이 latin1일 수 있으므로 COLLATE를 명시적으로 지정
|
||||
$collate = 'utf8mb4_unicode_ci';
|
||||
|
||||
DB::statement("
|
||||
CREATE VIEW v_unified_audit AS
|
||||
|
||||
-- Layer 1: Laravel Auditable (앱 레벨 감사)
|
||||
SELECT
|
||||
CONCAT('app_', id) COLLATE {$collate} AS uid,
|
||||
'APP' COLLATE {$collate} AS source,
|
||||
tenant_id,
|
||||
target_type COLLATE {$collate} AS table_name,
|
||||
CAST(target_id AS CHAR(64)) COLLATE {$collate} AS row_id,
|
||||
UPPER(action) COLLATE {$collate} AS action,
|
||||
`before` AS old_values,
|
||||
`after` AS new_values,
|
||||
CAST(NULL AS JSON) AS changed_columns,
|
||||
actor_id,
|
||||
ip COLLATE {$collate} AS ip_address,
|
||||
ua COLLATE {$collate} AS user_agent,
|
||||
CAST(NULL AS CHAR(100)) COLLATE {$collate} AS db_user,
|
||||
created_at
|
||||
FROM audit_logs
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Layer 2: MySQL Trigger Audit (DB 레벨 감사)
|
||||
SELECT
|
||||
CONCAT('trg_', id) COLLATE {$collate} AS uid,
|
||||
'TRIGGER' COLLATE {$collate} AS source,
|
||||
tenant_id,
|
||||
table_name COLLATE {$collate},
|
||||
row_id COLLATE {$collate},
|
||||
CAST(dml_type AS CHAR(10)) COLLATE {$collate} AS action,
|
||||
old_values,
|
||||
new_values,
|
||||
changed_columns,
|
||||
actor_id,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(session_info, '$.ip')) AS CHAR(45)) COLLATE {$collate} AS ip_address,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(session_info, '$.ua')) AS CHAR(255)) COLLATE {$collate} AS user_agent,
|
||||
db_user COLLATE {$collate},
|
||||
created_at
|
||||
FROM trigger_audit_logs
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP VIEW IF EXISTS v_unified_audit');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user