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:
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ManageAuditPartitions extends Command
|
||||
{
|
||||
protected $signature = 'audit:partitions
|
||||
{--add-months=3 : 미래 파티션 추가 개월 수}
|
||||
{--retention-months=13 : 보관 기간 (개월)}
|
||||
{--drop : 보관 기간 초과 파티션 삭제 실행}
|
||||
{--dry-run : 변경 없이 계획만 출력}';
|
||||
|
||||
protected $description = '트리거 감사 로그 파티션 자동 관리 (추가/삭제)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$addMonths = (int) $this->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;
|
||||
}
|
||||
}
|
||||
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateAuditTriggers extends Command
|
||||
{
|
||||
protected $signature = 'audit:triggers
|
||||
{--table= : 특정 테이블만 재생성}
|
||||
{--drop-only : 트리거 삭제만 (재생성 안 함)}
|
||||
{--dry-run : 변경 없이 대상 목록만 출력}';
|
||||
|
||||
protected $description = '트리거 감사 로그용 MySQL 트리거 재생성 (스키마 변경 후 사용)';
|
||||
|
||||
/** @var string[] 트리거 제외 테이블 */
|
||||
private array $excludeTables = [
|
||||
'audit_logs',
|
||||
'trigger_audit_logs',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'migrations',
|
||||
'personal_access_tokens',
|
||||
'api_request_logs',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$specificTable = $this->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
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Audit;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Audit\TriggerAuditLogIndexRequest;
|
||||
use App\Http\Requests\Audit\TriggerAuditRollbackRequest;
|
||||
use App\Services\Audit\AuditRollbackService;
|
||||
use App\Services\Audit\TriggerAuditLogService;
|
||||
|
||||
class TriggerAuditLogController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TriggerAuditLogService $service,
|
||||
protected AuditRollbackService $rollbackService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 트리거 감사 로그 목록 조회
|
||||
*/
|
||||
public function index(TriggerAuditLogIndexRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->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'));
|
||||
}
|
||||
}
|
||||
27
app/Http/Middleware/SetAuditSessionVariables.php
Normal file
27
app/Http/Middleware/SetAuditSessionVariables.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetAuditSessionVariables
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (auth()->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);
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php
Normal file
36
app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Audit;
|
||||
|
||||
use App\Http\Requests\Traits\HasPagination;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TriggerAuditLogIndexRequest extends FormRequest
|
||||
{
|
||||
use HasPagination;
|
||||
|
||||
protected int $maxSize = 200;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Audit/TriggerAuditRollbackRequest.php
Normal file
20
app/Http/Requests/Audit/TriggerAuditRollbackRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Audit;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TriggerAuditRollbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'confirm' => 'required|boolean|accepted',
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Models/Audit/TriggerAuditLog.php
Normal file
74
app/Models/Audit/TriggerAuditLog.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Audit;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TriggerAuditLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'trigger_audit_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'table_name',
|
||||
'row_id',
|
||||
'dml_type',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'changed_columns',
|
||||
'tenant_id',
|
||||
'actor_id',
|
||||
'session_info',
|
||||
'db_user',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_values' => '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);
|
||||
}
|
||||
}
|
||||
128
app/Services/Audit/AuditRollbackService.php
Normal file
128
app/Services/Audit/AuditRollbackService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\Audit\TriggerAuditLog;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AuditRollbackService extends Service
|
||||
{
|
||||
/**
|
||||
* 역방향 SQL 생성 (실행하지 않음, 미리보기용)
|
||||
*/
|
||||
public function generateRollbackSQL(int $auditId): string
|
||||
{
|
||||
$log = TriggerAuditLog::findOrFail($auditId);
|
||||
|
||||
return match ($log->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})";
|
||||
}
|
||||
}
|
||||
97
app/Services/Audit/TriggerAuditLogService.php
Normal file
97
app/Services/Audit/TriggerAuditLogService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\Audit\TriggerAuditLog;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class TriggerAuditLogService extends Service
|
||||
{
|
||||
public function paginate(array $filters): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
$size = (int) ($filters['size'] ?? 20);
|
||||
$sort = $filters['sort'] ?? 'created_at';
|
||||
$order = $filters['order'] ?? 'desc';
|
||||
|
||||
$q = TriggerAuditLog::query();
|
||||
|
||||
// 테넌트 필터 (선택적: 전체 조회도 가능)
|
||||
if (! empty($filters['tenant_id'])) {
|
||||
$q->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 유지)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -39,6 +39,7 @@
|
||||
require __DIR__.'/api/v1/common.php';
|
||||
require __DIR__.'/api/v1/stats.php';
|
||||
require __DIR__.'/api/v1/app.php';
|
||||
require __DIR__.'/api/v1/audit.php';
|
||||
|
||||
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
|
||||
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');
|
||||
|
||||
21
routes/api/v1/audit.php
Normal file
21
routes/api/v1/audit.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 트리거 감사 로그 API 라우트 (v1)
|
||||
*
|
||||
* - DB 트리거 기반 변경 추적 로그 조회
|
||||
* - 특정 레코드 변경 이력
|
||||
* - 롤백 미리보기 및 실행
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Api\V1\Audit\TriggerAuditLogController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('trigger-audit-logs')->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');
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user