From d07bad16df7fa825b42fe428aa68ad40ddb615dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 09:17:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:DB=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=B6=94=EC=A0=81=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/ManageAuditPartitions.php | 141 ++++++++++++ .../Commands/RegenerateAuditTriggers.php | 184 ++++++++++++++++ .../V1/Audit/TriggerAuditLogController.php | 83 +++++++ .../Middleware/SetAuditSessionVariables.php | 27 +++ .../Audit/TriggerAuditLogIndexRequest.php | 36 +++ .../Audit/TriggerAuditRollbackRequest.php | 20 ++ app/Models/Audit/TriggerAuditLog.php | 74 +++++++ app/Services/Audit/AuditRollbackService.php | 128 +++++++++++ app/Services/Audit/TriggerAuditLogService.php | 97 ++++++++ bootstrap/app.php | 4 +- ...100000_create_trigger_audit_logs_table.php | 70 ++++++ ...1_create_audit_triggers_for_all_tables.php | 207 ++++++++++++++++++ ...02_07_100002_create_unified_audit_view.php | 62 ++++++ routes/api.php | 1 + routes/api/v1/audit.php | 21 ++ routes/console.php | 13 ++ 16 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ManageAuditPartitions.php create mode 100644 app/Console/Commands/RegenerateAuditTriggers.php create mode 100644 app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php create mode 100644 app/Http/Middleware/SetAuditSessionVariables.php create mode 100644 app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php create mode 100644 app/Http/Requests/Audit/TriggerAuditRollbackRequest.php create mode 100644 app/Models/Audit/TriggerAuditLog.php create mode 100644 app/Services/Audit/AuditRollbackService.php create mode 100644 app/Services/Audit/TriggerAuditLogService.php create mode 100644 database/migrations/2026_02_07_100000_create_trigger_audit_logs_table.php create mode 100644 database/migrations/2026_02_07_100001_create_audit_triggers_for_all_tables.php create mode 100644 database/migrations/2026_02_07_100002_create_unified_audit_view.php create mode 100644 routes/api/v1/audit.php diff --git a/app/Console/Commands/ManageAuditPartitions.php b/app/Console/Commands/ManageAuditPartitions.php new file mode 100644 index 0000000..dd16d3b --- /dev/null +++ b/app/Console/Commands/ManageAuditPartitions.php @@ -0,0 +1,141 @@ +option('add-months'); + $retentionMonths = (int) $this->option('retention-months'); + $doDrop = $this->option('drop'); + $dryRun = $this->option('dry-run'); + + $this->info('=== 트리거 감사 로그 파티션 관리 ==='); + $this->newLine(); + + // 현재 파티션 목록 조회 + $partitions = $this->getPartitions(); + $this->info('현재 파티션: '.count($partitions).'개'); + $this->table( + ['파티션명', '상한값 (UNIX_TIMESTAMP)', '행 수'], + collect($partitions)->map(fn ($p) => [ + $p->PARTITION_NAME, + $p->PARTITION_DESCRIPTION === 'MAXVALUE' ? 'MAXVALUE' : Carbon::createFromTimestamp($p->PARTITION_DESCRIPTION)->format('Y-m-d'), + number_format($p->TABLE_ROWS), + ])->toArray() + ); + + $this->newLine(); + + // 1. 미래 파티션 추가 + $added = $this->addFuturePartitions($partitions, $addMonths, $dryRun); + + // 2. 오래된 파티션 삭제 + $dropped = 0; + if ($doDrop) { + $dropped = $this->dropOldPartitions($partitions, $retentionMonths, $dryRun); + } else { + $this->warn('파티션 삭제는 --drop 옵션 필요'); + } + + $this->newLine(); + $this->info("결과: 추가 {$added}개, 삭제 {$dropped}개".($dryRun ? ' (dry-run)' : '')); + + return self::SUCCESS; + } + + private function getPartitions(): array + { + return DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [config('database.connections.mysql.database')]); + } + + private function addFuturePartitions(array $partitions, int $addMonths, bool $dryRun): int + { + $existingBounds = []; + foreach ($partitions as $p) { + if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') { + $existingBounds[] = (int) $p->PARTITION_DESCRIPTION; + } + } + + $added = 0; + $now = Carbon::now(); + + for ($i = 0; $i <= $addMonths; $i++) { + $target = $now->copy()->addMonths($i)->startOfMonth()->addMonth(); + $ts = $target->timestamp; + $name = 'p'.$target->copy()->subMonth()->format('Ym'); + + if (in_array($ts, $existingBounds)) { + continue; + } + + $sql = "ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO ( + PARTITION {$name} VALUES LESS THAN ({$ts}), + PARTITION p_future VALUES LESS THAN MAXVALUE + )"; + + if ($dryRun) { + $this->line(" [DRY-RUN] 추가: {$name} (< {$target->format('Y-m-d')})"); + } else { + DB::statement($sql); + $this->info(" 추가: {$name} (< {$target->format('Y-m-d')})"); + } + $added++; + } + + if ($added === 0) { + $this->info(' 추가할 파티션 없음'); + } + + return $added; + } + + private function dropOldPartitions(array $partitions, int $retentionMonths, bool $dryRun): int + { + $cutoff = Carbon::now()->subMonths($retentionMonths)->startOfMonth()->timestamp; + $dropped = 0; + + foreach ($partitions as $p) { + if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') { + continue; + } + + $bound = (int) $p->PARTITION_DESCRIPTION; + if ($bound <= $cutoff) { + if ($dryRun) { + $this->line(" [DRY-RUN] 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)"); + } else { + DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$p->PARTITION_NAME}"); + $this->warn(" 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)"); + } + $dropped++; + } + } + + if ($dropped === 0) { + $this->info(' 삭제할 파티션 없음'); + } + + return $dropped; + } +} diff --git a/app/Console/Commands/RegenerateAuditTriggers.php b/app/Console/Commands/RegenerateAuditTriggers.php new file mode 100644 index 0000000..62bb57b --- /dev/null +++ b/app/Console/Commands/RegenerateAuditTriggers.php @@ -0,0 +1,184 @@ +option('table'); + $dropOnly = $this->option('drop-only'); + $dryRun = $this->option('dry-run'); + $dbName = config('database.connections.mysql.database'); + + $this->info('=== 트리거 감사 로그 트리거 '.($dropOnly ? '삭제' : '재생성').' ==='); + $this->newLine(); + + // 대상 테이블 목록 + $tables = $this->getTargetTables($dbName, $specificTable); + $this->info('대상 테이블: '.count($tables).'개'); + + if ($dryRun) { + foreach ($tables as $t) { + $this->line(" - {$t}"); + } + $this->newLine(); + $this->info('[DRY-RUN] 실제 변경 없음'); + + return self::SUCCESS; + } + + $dropped = 0; + $created = 0; + + foreach ($tables as $table) { + // 기존 트리거 삭제 + foreach (['ai', 'au', 'ad'] as $suffix) { + $triggerName = "trg_{$table}_{$suffix}"; + DB::unprepared("DROP TRIGGER IF EXISTS `{$triggerName}`"); + $dropped++; + } + + if (! $dropOnly) { + // 트리거 재생성 + $this->createTriggersForTable($dbName, $table); + $created += 3; + } + + $this->line(" {$table}: ".($dropOnly ? '삭제 완료' : '재생성 완료')); + } + + $this->newLine(); + $this->info("결과: 삭제 {$dropped}개, 생성 {$created}개"); + + return self::SUCCESS; + } + + private function getTargetTables(string $dbName, ?string $specificTable): array + { + if ($specificTable) { + if (in_array($specificTable, $this->excludeTables)) { + $this->error("{$specificTable}은(는) 트리거 제외 테이블입니다."); + + return []; + } + + return [$specificTable]; + } + + $rows = DB::select(" + SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME + ", [$dbName]); + + return collect($rows) + ->pluck('TABLE_NAME') + ->reject(fn ($t) => in_array($t, $this->excludeTables)) + ->values() + ->toArray(); + } + + private function createTriggersForTable(string $dbName, string $table): void + { + // PK 컬럼 + $pkRow = DB::selectOne(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI' + LIMIT 1 + ", [$dbName, $table]); + + $pkCol = $pkRow?->COLUMN_NAME ?? 'id'; + + // 컬럼 목록 (제외: created_at, updated_at, deleted_at, remember_token) + $excludeCols = ['created_at', 'updated_at', 'deleted_at', 'remember_token']; + $columns = DB::select(' + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + ', [$dbName, $table]); + + $cols = collect($columns) + ->pluck('COLUMN_NAME') + ->reject(fn ($c) => in_array($c, $excludeCols)) + ->values() + ->toArray(); + + if (empty($cols)) { + return; + } + + // JSON_OBJECT 표현식 + $newJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ').')'; + $oldJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ').')'; + + // changed_columns (UPDATE용) + $changedCols = collect($cols)->map(fn ($c) => "IF(NOT (NEW.`{$c}` <=> OLD.`{$c}`), '{$c}', NULL)")->implode(', '); + $changeCheck = collect($cols)->map(fn ($c) => "NOT (NEW.`{$c}` <=> OLD.`{$c}`)")->implode(' OR '); + + $tenantExpr = in_array('tenant_id', $cols) ? 'NEW.`tenant_id`' : 'NULL'; + $tenantExprOld = in_array('tenant_id', $cols) ? 'OLD.`tenant_id`' : 'NULL'; + + $guard = 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN'; + + // INSERT trigger + DB::unprepared(" + CREATE TRIGGER `trg_{$table}_ai` AFTER INSERT ON `{$table}` + FOR EACH ROW BEGIN + {$guard} + 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 ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + END IF; + END + "); + + // UPDATE trigger + DB::unprepared(" + CREATE TRIGGER `trg_{$table}_au` AFTER UPDATE ON `{$table}` + FOR EACH ROW BEGIN + {$guard} + 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 ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + END IF; + END IF; + END + "); + + // DELETE trigger + DB::unprepared(" + CREATE TRIGGER `trg_{$table}_ad` AFTER DELETE ON `{$table}` + FOR EACH ROW BEGIN + {$guard} + 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 ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + END IF; + END + "); + } +} diff --git a/app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php b/app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php new file mode 100644 index 0000000..43b0cef --- /dev/null +++ b/app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php @@ -0,0 +1,83 @@ +service->paginate($request->validated()); + }, __('message.fetched')); + } + + /** + * 트리거 감사 로그 상세 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return \App\Models\Audit\TriggerAuditLog::findOrFail($id); + }, __('message.fetched')); + } + + /** + * 특정 레코드의 변경 이력 + */ + public function recordHistory(string $tableName, string $rowId) + { + return ApiResponse::handle(function () use ($tableName, $rowId) { + return $this->service->recordHistory($tableName, $rowId); + }, __('message.fetched')); + } + + /** + * 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(function () { + $tenantId = request()->query('tenant_id'); + + return $this->service->stats($tenantId ? (int) $tenantId : null); + }, __('message.fetched')); + } + + /** + * 롤백 SQL 미리보기 + */ + public function rollbackPreview(int $id) + { + return ApiResponse::handle(function () use ($id) { + return [ + 'audit_id' => $id, + 'rollback_sql' => $this->rollbackService->generateRollbackSQL($id), + ]; + }, __('message.fetched')); + } + + /** + * 롤백 실행 + */ + public function rollbackExecute(TriggerAuditRollbackRequest $request, int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->rollbackService->executeRollback($id); + }, __('message.updated')); + } +} diff --git a/app/Http/Middleware/SetAuditSessionVariables.php b/app/Http/Middleware/SetAuditSessionVariables.php new file mode 100644 index 0000000..bdf3f4b --- /dev/null +++ b/app/Http/Middleware/SetAuditSessionVariables.php @@ -0,0 +1,27 @@ +check()) { + DB::statement('SET @sam_actor_id = ?', [auth()->id()]); + DB::statement('SET @sam_session_info = ?', [ + json_encode([ + 'ip' => $request->ip(), + 'ua' => mb_substr((string) $request->userAgent(), 0, 255), + 'route' => $request->route()?->getName(), + ], JSON_UNESCAPED_UNICODE), + ]); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php b/app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php new file mode 100644 index 0000000..bc46a3e --- /dev/null +++ b/app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php @@ -0,0 +1,36 @@ + 'nullable|integer|min:1', + 'size' => 'nullable|integer|min:1', + 'table_name' => 'nullable|string|max:64', + 'row_id' => 'nullable|string|max:64', + 'dml_type' => 'nullable|string|in:INSERT,UPDATE,DELETE', + 'tenant_id' => 'nullable|integer|min:1', + 'actor_id' => 'nullable|integer|min:1', + 'db_user' => 'nullable|string|max:100', + 'from' => 'nullable|date', + 'to' => 'nullable|date|after_or_equal:from', + 'sort' => 'nullable|string|in:created_at,id', + 'order' => 'nullable|string|in:asc,desc', + ]; + } +} diff --git a/app/Http/Requests/Audit/TriggerAuditRollbackRequest.php b/app/Http/Requests/Audit/TriggerAuditRollbackRequest.php new file mode 100644 index 0000000..af154b7 --- /dev/null +++ b/app/Http/Requests/Audit/TriggerAuditRollbackRequest.php @@ -0,0 +1,20 @@ + 'required|boolean|accepted', + ]; + } +} diff --git a/app/Models/Audit/TriggerAuditLog.php b/app/Models/Audit/TriggerAuditLog.php new file mode 100644 index 0000000..64a8bba --- /dev/null +++ b/app/Models/Audit/TriggerAuditLog.php @@ -0,0 +1,74 @@ + 'array', + 'new_values' => 'array', + 'changed_columns' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * changed_columns에서 NULL 값 필터링 + */ + public function getChangedColumnsAttribute($value): ?array + { + $decoded = is_string($value) ? json_decode($value, true) : $value; + + if (! is_array($decoded)) { + return null; + } + + return array_values(array_filter($decoded, fn ($v) => $v !== null)); + } + + /** + * session_info JSON 디코딩 + */ + public function getSessionInfoAttribute($value): ?array + { + if (is_string($value)) { + return json_decode($value, true); + } + + return $value; + } + + public function scopeForTable($query, string $tableName) + { + return $query->where('table_name', $tableName); + } + + public function scopeForRecord($query, string $tableName, string $rowId) + { + return $query->where('table_name', $tableName)->where('row_id', $rowId); + } + + public function scopeForTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/app/Services/Audit/AuditRollbackService.php b/app/Services/Audit/AuditRollbackService.php new file mode 100644 index 0000000..4e21222 --- /dev/null +++ b/app/Services/Audit/AuditRollbackService.php @@ -0,0 +1,128 @@ +dml_type) { + 'INSERT' => $this->buildDeleteSQL($log), + 'UPDATE' => $this->buildRevertUpdateSQL($log), + 'DELETE' => $this->buildReinsertSQL($log), + }; + } + + /** + * 복구 실행 (트랜잭션) + */ + public function executeRollback(int $auditId): array + { + $log = TriggerAuditLog::findOrFail($auditId); + $sql = $this->generateRollbackSQL($auditId); + + DB::statement('SET @disable_audit_trigger = 1'); + + try { + DB::transaction(function () use ($sql) { + DB::statement($sql); + }); + + return [ + 'success' => true, + 'audit_id' => $auditId, + 'table_name' => $log->table_name, + 'row_id' => $log->row_id, + 'original_dml' => $log->dml_type, + 'rollback_sql' => $sql, + ]; + } catch (\Throwable $e) { + return [ + 'success' => false, + 'audit_id' => $auditId, + 'error' => $e->getMessage(), + 'rollback_sql' => $sql, + ]; + } finally { + DB::statement('SET @disable_audit_trigger = NULL'); + } + } + + /** + * 특정 레코드의 특정 시점 상태 조회 + */ + public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array + { + $log = TriggerAuditLog::where('table_name', $table) + ->where('row_id', $rowId) + ->where('created_at', '<=', $at) + ->orderByDesc('created_at') + ->first(); + + if (! $log) { + return null; + } + + return match ($log->dml_type) { + 'INSERT', 'UPDATE' => $log->new_values, + 'DELETE' => null, + }; + } + + /** + * INSERT 복구: 삽입된 레코드 삭제 + */ + private function buildDeleteSQL(TriggerAuditLog $log): string + { + $rowId = DB::getPdo()->quote($log->row_id); + + return "DELETE FROM `{$log->table_name}` WHERE `id` = {$rowId} LIMIT 1"; + } + + /** + * UPDATE 복구: old_values로 되돌림 + */ + private function buildRevertUpdateSQL(TriggerAuditLog $log): string + { + if (empty($log->old_values)) { + throw new \RuntimeException("No old_values for audit #{$log->id}"); + } + + $pdo = DB::getPdo(); + $sets = collect($log->old_values) + ->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val))) + ->implode(', '); + + $rowId = $pdo->quote($log->row_id); + + return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = {$rowId} LIMIT 1"; + } + + /** + * DELETE 복구: old_values로 재삽입 + */ + private function buildReinsertSQL(TriggerAuditLog $log): string + { + if (empty($log->old_values)) { + throw new \RuntimeException("No old_values for audit #{$log->id}"); + } + + $pdo = DB::getPdo(); + $cols = collect($log->old_values)->keys()->map(fn ($c) => "`{$c}`")->implode(', '); + $vals = collect($log->old_values)->values() + ->map(fn ($v) => $v === null ? 'NULL' : $pdo->quote((string) $v)) + ->implode(', '); + + return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})"; + } +} diff --git a/app/Services/Audit/TriggerAuditLogService.php b/app/Services/Audit/TriggerAuditLogService.php new file mode 100644 index 0000000..80090a0 --- /dev/null +++ b/app/Services/Audit/TriggerAuditLogService.php @@ -0,0 +1,97 @@ +where('tenant_id', (int) $filters['tenant_id']); + } + if (! empty($filters['table_name'])) { + $q->where('table_name', $filters['table_name']); + } + if (! empty($filters['row_id'])) { + $q->where('row_id', $filters['row_id']); + } + if (! empty($filters['dml_type'])) { + $q->where('dml_type', $filters['dml_type']); + } + if (! empty($filters['actor_id'])) { + $q->where('actor_id', (int) $filters['actor_id']); + } + if (! empty($filters['db_user'])) { + $q->where('db_user', 'like', "%{$filters['db_user']}%"); + } + if (! empty($filters['from'])) { + $q->where('created_at', '>=', $filters['from']); + } + if (! empty($filters['to'])) { + $q->where('created_at', '<=', $filters['to']); + } + + return $q->orderBy($sort, $order)->paginate($size, ['*'], 'page', $page); + } + + /** + * 특정 레코드의 변경 이력 조회 + */ + public function recordHistory(string $tableName, string $rowId): \Illuminate\Support\Collection + { + return TriggerAuditLog::forRecord($tableName, $rowId) + ->orderByDesc('created_at') + ->get(); + } + + /** + * 테이블별 변경 통계 + */ + public function stats(?int $tenantId = null): array + { + $q = TriggerAuditLog::query(); + + if ($tenantId) { + $q->where('tenant_id', $tenantId); + } + + $total = (clone $q)->count(); + + $byDmlType = (clone $q) + ->selectRaw('dml_type, COUNT(*) as count') + ->groupBy('dml_type') + ->pluck('count', 'dml_type') + ->toArray(); + + $topTables = (clone $q) + ->selectRaw('table_name, COUNT(*) as count') + ->groupBy('table_name') + ->orderByDesc('count') + ->limit(10) + ->pluck('count', 'table_name') + ->toArray(); + + $today = (clone $q) + ->whereDate('created_at', today()) + ->count(); + + return [ + 'total' => $total, + 'today' => $today, + 'by_dml_type' => $byDmlType, + 'top_tables' => $topTables, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 9091600..bded977 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,6 +9,7 @@ use App\Http\Middleware\CorsMiddleware; use App\Http\Middleware\LogApiRequest; use App\Http\Middleware\PermMapper; +use App\Http\Middleware\SetAuditSessionVariables; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -28,8 +29,9 @@ $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 $middleware->append(ApiVersionMiddleware::class); // 3. API 버전 해석 및 폴백 - // API 미들웨어 그룹에 로깅 추가 + // API 미들웨어 그룹에 로깅 + 감사 세션변수 추가 $middleware->appendToGroup('api', LogApiRequest::class); + $middleware->appendToGroup('api', SetAuditSessionVariables::class); $middleware->alias([ 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth (alias 유지) diff --git a/database/migrations/2026_02_07_100000_create_trigger_audit_logs_table.php b/database/migrations/2026_02_07_100000_create_trigger_audit_logs_table.php new file mode 100644 index 0000000..8efbe11 --- /dev/null +++ b/database/migrations/2026_02_07_100000_create_trigger_audit_logs_table.php @@ -0,0 +1,70 @@ +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 + "); + } +}; diff --git a/database/migrations/2026_02_07_100002_create_unified_audit_view.php b/database/migrations/2026_02_07_100002_create_unified_audit_view.php new file mode 100644 index 0000000..8cfdbc8 --- /dev/null +++ b/database/migrations/2026_02_07_100002_create_unified_audit_view.php @@ -0,0 +1,62 @@ +name('v1.files.share.download'); diff --git a/routes/api/v1/audit.php b/routes/api/v1/audit.php new file mode 100644 index 0000000..5ac955e --- /dev/null +++ b/routes/api/v1/audit.php @@ -0,0 +1,21 @@ +group(function () { + Route::get('', [TriggerAuditLogController::class, 'index'])->name('v1.trigger-audit-logs.index'); + Route::get('/stats', [TriggerAuditLogController::class, 'stats'])->name('v1.trigger-audit-logs.stats'); + Route::get('/{id}', [TriggerAuditLogController::class, 'show'])->whereNumber('id')->name('v1.trigger-audit-logs.show'); + Route::get('/{id}/rollback-preview', [TriggerAuditLogController::class, 'rollbackPreview'])->whereNumber('id')->name('v1.trigger-audit-logs.rollback-preview'); + Route::post('/{id}/rollback', [TriggerAuditLogController::class, 'rollbackExecute'])->whereNumber('id')->name('v1.trigger-audit-logs.rollback'); + Route::get('/{tableName}/{rowId}/history', [TriggerAuditLogController::class, 'recordHistory'])->name('v1.trigger-audit-logs.record-history'); +}); diff --git a/routes/console.php b/routes/console.php index e749df0..73cd822 100644 --- a/routes/console.php +++ b/routes/console.php @@ -133,6 +133,19 @@ \Illuminate\Support\Facades\Log::error('❌ db:backup-check 스케줄러 실행 실패', ['time' => now()]); }); +// ─── 트리거 감사 로그 파티션 관리 ─── + +// 매월 1일 새벽 04:10에 파티션 관리 (3개월 미래 추가 + 13개월 초과 삭제) +Schedule::command('audit:partitions --add-months=3 --retention-months=13 --drop') + ->monthlyOn(1, '04:10') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ audit:partitions 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ audit:partitions 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 오전 09:00에 KPI 목표 대비 알림 체크 Schedule::command('stat:check-kpi-alerts') ->dailyAt('09:00')