Files
sam-api/database/migrations/2025_08_15_000000_create_authz_structures.php

223 lines
10 KiB
PHP
Raw Normal View History

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
/**
* 1) MENUS: 권한관리용 필드 추가
* - slug: 메뉴/퍼미션 매핑용 고유 식별자 (테넌트 범위 유니크)
* - SoftDeletes + acted-by 컬럼
*/
Schema::table('menus', function (Blueprint $table) {
// slug 추가 (권한 키로 활용) - 테넌트 내 유니크
if (! Schema::hasColumn('menus', 'slug')) {
$table->string('slug', 150)->nullable()->after('name')->comment('메뉴 슬러그(권한 키)');
}
// Soft delete
if (! Schema::hasColumn('menus', 'deleted_at')) {
$table->softDeletes()->comment('소프트삭제 시각');
}
// acted-by (누가 생성/수정/삭제 했는지)
if (! Schema::hasColumn('menus', 'created_by')) {
$table->unsignedBigInteger('created_by')->nullable()->after('updated_at')->comment('생성자 사용자 ID');
}
if (! Schema::hasColumn('menus', 'updated_by')) {
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by')->comment('수정자 사용자 ID');
}
if (! Schema::hasColumn('menus', 'deleted_by')) {
$table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by')->comment('삭제자 사용자 ID');
}
// 인덱스/유니크
// slug는 테넌트 범위에서 유니크 보장
$table->unique(['tenant_id', 'slug'], 'menus_tenant_slug_unique');
$table->index(['tenant_id', 'is_active', 'hidden'], 'menus_tenant_active_hidden_idx');
$table->index(['sort_order'], 'menus_sort_idx');
});
/**
* 2) 부서 테이블
*/
if (! Schema::hasTable('departments')) {
Schema::create('departments', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK: 부서 ID');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('code', 50)->nullable()->comment('부서 코드');
$table->string('name', 100)->comment('부서명');
$table->string('description', 255)->nullable()->comment('설명');
$table->tinyInteger('is_active')->default(1)->comment('활성여부(1=활성,0=비활성)');
$table->integer('sort_order')->default(0)->comment('정렬순서');
$table->timestamps();
$table->softDeletes();
// acted-by
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 사용자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 사용자 ID');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 사용자 ID');
// 인덱스
$table->unique(['tenant_id', 'code'], 'dept_tenant_code_unique');
$table->index(['tenant_id', 'name'], 'dept_tenant_name_idx');
$table->index(['tenant_id', 'is_active'], 'dept_tenant_active_idx');
});
}
/**
* 3) 부서-사용자 매핑
* - 동일 부서-사용자 중복 매핑 방지(tenant_id 포함)
*/
if (! Schema::hasTable('department_user')) {
Schema::create('department_user', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('department_id')->comment('부서 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->tinyInteger('is_primary')->default(0)->comment('주 부서 여부(1=Y,0=N)');
$table->timestamp('joined_at')->nullable()->comment('부서 배정일');
$table->timestamp('left_at')->nullable()->comment('부서 이탈일');
$table->timestamps();
$table->softDeletes();
// 인덱스/유니크
$table->unique(['tenant_id', 'department_id', 'user_id'], 'dept_user_unique');
$table->index(['tenant_id', 'user_id'], 'dept_user_user_idx');
$table->index(['tenant_id', 'department_id'], 'dept_user_dept_idx');
});
}
/**
* 4) 부서별 권한 매핑
* - Spatie permissions 테이블의 permission_id와 연결( FK는 미사용)
* - ALLOW/DENY는 is_allowed로 표현
* - 필요시 메뉴 단위 범위 제한을 위해 menu_id(옵션)
*/
if (! Schema::hasTable('department_permissions')) {
Schema::create('department_permissions', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('department_id')->comment('부서 ID');
$table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id');
$table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택), 특정 메뉴 범위 권한');
$table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)');
$table->timestamps();
$table->softDeletes();
// 인덱스/유니크 (동일 권한 중복 방지)
$table->unique(
['tenant_id', 'department_id', 'permission_id', 'menu_id'],
'dept_perm_unique'
);
$table->index(['tenant_id', 'department_id'], 'dept_perm_dept_idx');
$table->index(['tenant_id', 'permission_id'], 'dept_perm_perm_idx');
$table->index(['tenant_id', 'menu_id'], 'dept_perm_menu_idx');
});
}
/**
* 5) 사용자 퍼미션 오버라이드 (개인 단위 허용/차단)
* - 개인 DENY가 최우선 해석 레이어에서 우선순위 처리
*/
if (! Schema::hasTable('user_permission_overrides')) {
Schema::create('user_permission_overrides', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id');
$table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택)');
$table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)');
$table->string('reason', 255)->nullable()->comment('사유/메모');
$table->timestamp('effective_from')->nullable()->comment('효력 시작');
$table->timestamp('effective_to')->nullable()->comment('효력 종료');
$table->timestamps();
$table->softDeletes();
// 중복 방지
$table->unique(
['tenant_id', 'user_id', 'permission_id', 'menu_id'],
'user_perm_override_unique'
);
$table->index(['tenant_id', 'user_id'], 'user_perm_user_idx');
$table->index(['tenant_id', 'permission_id'], 'user_perm_perm_idx');
});
}
}
public function down(): void
{
// 생성 테이블 역순 드롭
Schema::dropIfExists('user_permission_overrides');
Schema::dropIfExists('department_permissions');
Schema::dropIfExists('department_user');
Schema::dropIfExists('departments');
// MENUS 원복: 추가했던 컬럼/인덱스 제거
Schema::table('menus', function (Blueprint $table) {
// 인덱스/유니크 먼저 제거
if ($this->indexExists('menus', 'menus_tenant_slug_unique')) {
$table->dropUnique('menus_tenant_slug_unique');
}
if ($this->indexExists('menus', 'menus_tenant_active_hidden_idx')) {
$table->dropIndex('menus_tenant_active_hidden_idx');
}
if ($this->indexExists('menus', 'menus_sort_idx')) {
$table->dropIndex('menus_sort_idx');
}
// 컬럼 제거 (존재 체크는 직접 불가하므로 try-catch는 생략)
if (Schema::hasColumn('menus', 'slug')) {
$table->dropColumn('slug');
}
if (Schema::hasColumn('menus', 'deleted_at')) {
$table->dropSoftDeletes();
}
if (Schema::hasColumn('menus', 'deleted_by')) {
$table->dropColumn('deleted_by');
}
if (Schema::hasColumn('menus', 'updated_by')) {
$table->dropColumn('updated_by');
}
if (Schema::hasColumn('menus', 'created_by')) {
$table->dropColumn('created_by');
}
});
}
/**
* Laravel의 Blueprint에서는 인덱스 존재 체크가 불가하므로
* 스키마 매니저를 직접 조회하는 헬퍼.
*/
private function indexExists(string $table, string $indexName): bool
{
try {
$connection = Schema::getConnection();
$schemaManager = $connection->getDoctrineSchemaManager();
$doctrineTable = $schemaManager->introspectTable(
$connection->getTablePrefix().$table
);
foreach ($doctrineTable->getIndexes() as $idx) {
if ($idx->getName() === $indexName) {
return true;
}
}
} catch (\Throwable $e) {
// 무시
}
return false;
}
};