fix : 권한관리 기능 추가 (각 기능 확인 필요)

- 메뉴관리
- 역할관리
- 부서관리
- 메뉴, 부서, 역할, 유저 - 권한 연동
This commit is contained in:
2025-08-16 03:25:06 +09:00
parent 68d97c166f
commit 73d06e03b0
34 changed files with 3656 additions and 84 deletions

View File

@@ -0,0 +1,221 @@
<?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;
}
};

View File

@@ -0,0 +1,261 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// ===== permissions =====
if (Schema::hasTable('permissions')) {
Schema::table('permissions', function (Blueprint $table) {
if (!Schema::hasColumn('permissions', 'guard_name')) {
$table->string('guard_name', 50)->default('api')->after('name');
}
if (!Schema::hasColumn('permissions', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('guard_name');
$table->index(['tenant_id']);
}
});
// unique(name, guard_name, tenant_id)
// 기존 unique가 name 또는 (name, guard_name) 로 있을 수 있으니 안전하게 제거 후 재생성
$this->dropUniqueIfExists('permissions', 'permissions_name_unique');
$this->dropUniqueIfExists('permissions', 'permissions_name_guard_name_unique');
$this->dropUniqueIfExists('permissions', 'uk_permissions_name_guard_tenant');
Schema::table('permissions', function (Blueprint $table) {
$table->unique(['name', 'guard_name', 'tenant_id'], 'uk_permissions_name_guard_tenant');
});
}
// ===== roles =====
if (Schema::hasTable('roles')) {
Schema::table('roles', function (Blueprint $table) {
if (!Schema::hasColumn('roles', 'guard_name')) {
$table->string('guard_name', 50)->default('api')->after('name');
}
if (!Schema::hasColumn('roles', 'tenant_id')) {
// 이미 있으시다 했지만 혹시 없을 경우 대비
$table->unsignedBigInteger('tenant_id')->nullable()->after('guard_name');
}
});
// unique(tenant_id, name, guard_name) 로 교체
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name');
$this->dropUniqueIfExists('roles', 'roles_name_unique');
$this->dropUniqueIfExists('roles', 'roles_name_guard_name_unique');
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name_guard');
Schema::table('roles', function (Blueprint $table) {
$table->unique(['tenant_id', 'name', 'guard_name'], 'uk_roles_tenant_name_guard');
});
// 인덱스 보강
$this->createIndexIfNotExists('roles', 'roles_tenant_id_index', ['tenant_id']);
}
// ===== model_has_roles =====
if (Schema::hasTable('model_has_roles')) {
Schema::table('model_has_roles', function (Blueprint $table) {
if (!Schema::hasColumn('model_has_roles', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('model_id');
}
});
// ✅ FK가 role_id를 참조하므로, PK 교체 전에 보조 인덱스 추가
$this->createIndexIfNotExists('model_has_roles', 'mhr_role_id_idx', ['role_id']);
// PK: (role_id, model_id, model_type, tenant_id)
$this->replacePrimaryKey(
'model_has_roles',
['role_id', 'model_id', 'model_type'],
['role_id', 'model_id', 'model_type', 'tenant_id']
);
// 인덱스 (model_id, model_type, tenant_id)
$this->dropIndexIfExists('model_has_roles', 'model_has_roles_model_id_model_type_index');
$this->createIndexIfNotExists(
'model_has_roles',
'model_has_roles_model_id_model_type_tenant_id_index',
['model_id', 'model_type', 'tenant_id']
);
}
// ===== model_has_permissions =====
if (Schema::hasTable('model_has_permissions')) {
Schema::table('model_has_permissions', function (Blueprint $table) {
if (!Schema::hasColumn('model_has_permissions', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('model_id');
}
});
// ✅ FK가 permission_id를 참조하므로, PK 교체 전에 보조 인덱스 추가
$this->createIndexIfNotExists('model_has_permissions', 'mhp_permission_id_idx', ['permission_id']);
// PK: (permission_id, model_id, model_type, tenant_id)
$this->replacePrimaryKey(
'model_has_permissions',
['permission_id', 'model_id', 'model_type'],
['permission_id', 'model_id', 'model_type', 'tenant_id']
);
// 인덱스 (model_id, model_type, tenant_id)
$this->dropIndexIfExists('model_has_permissions', 'model_has_permissions_model_id_model_type_index');
$this->createIndexIfNotExists(
'model_has_permissions',
'model_has_permissions_model_id_model_type_tenant_id_index',
['model_id', 'model_type', 'tenant_id']
);
}
}
public function down(): void
{
// 되돌리기 (가능한 범위에서)
if (Schema::hasTable('model_has_permissions')) {
// PK 원복: (permission_id, model_id, model_type)
$this->replacePrimaryKey('model_has_permissions', ['permission_id', 'model_id', 'model_type', 'tenant_id'], ['permission_id', 'model_id', 'model_type']);
$this->dropIndexIfExists('model_has_permissions', 'model_has_permissions_model_id_model_type_tenant_id_index');
$this->createIndexIfNotExists('model_has_permissions', 'model_has_permissions_model_id_model_type_index', ['model_id', 'model_type']);
if (Schema::hasColumn('model_has_permissions', 'tenant_id')) {
Schema::table('model_has_permissions', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
}
if (Schema::hasTable('model_has_roles')) {
// PK 원복: (role_id, model_id, model_type)
$this->replacePrimaryKey('model_has_roles', ['role_id', 'model_id', 'model_type', 'tenant_id'], ['role_id', 'model_id', 'model_type']);
$this->dropIndexIfExists('model_has_roles', 'model_has_roles_model_id_model_type_tenant_id_index');
$this->createIndexIfNotExists('model_has_roles', 'model_has_roles_model_id_model_type_index', ['model_id', 'model_type']);
if (Schema::hasColumn('model_has_roles', 'tenant_id')) {
Schema::table('model_has_roles', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
}
if (Schema::hasTable('roles')) {
// unique 원복: (tenant_id, name)
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name_guard');
Schema::table('roles', function (Blueprint $table) {
$table->unique(['tenant_id', 'name'], 'uk_roles_tenant_name');
});
if (Schema::hasColumn('roles', 'guard_name')) {
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('guard_name');
});
}
}
if (Schema::hasTable('permissions')) {
// unique 원복: (name) 또는 (name, guard_name) 상황에 따라
$this->dropUniqueIfExists('permissions', 'uk_permissions_name_guard_tenant');
Schema::table('permissions', function (Blueprint $table) {
// 최소 name unique로 복원
$table->unique(['name'], 'permissions_name_unique');
});
if (Schema::hasColumn('permissions', 'tenant_id')) {
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
if (Schema::hasColumn('permissions', 'guard_name')) {
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('guard_name');
});
}
}
}
// ---------- helpers ----------
private function dropUniqueIfExists(string $table, string $index): void
{
try {
Schema::table($table, function (Blueprint $t) use ($index) {
$t->dropUnique($index);
});
} catch (\Throwable $e) {
// 무시 (없으면 통과)
}
}
private function dropIndexIfExists(string $table, string $index): void
{
try {
Schema::table($table, function (Blueprint $t) use ($index) {
$t->dropIndex($index);
});
} catch (\Throwable $e) {
// 무시
}
}
private function createIndexIfNotExists(string $table, string $index, array $columns): void
{
// 라라벨은 인덱스 존재 체크 API가 없어 try/catch로 처리
try {
Schema::table($table, function (Blueprint $t) use ($index, $columns) {
$t->index($columns, $index);
});
} catch (\Throwable $e) {
// 이미 있으면 통과
}
}
/**
* 기존 PK를 다른 조합으로 교체
*/
private function replacePrimaryKey(string $table, array $old, array $new): void
{
try {
Schema::table($table, function (Blueprint $t) use ($old) {
$t->dropPrimary($this->primaryNameGuess($table, $old));
});
} catch (\Throwable $e) {
// 일부 DB는 이름 지정 안 하면 실패 → 수동 SQL
$this->dropPrimaryKeyBySql($table);
}
Schema::table($table, function (Blueprint $t) use ($new) {
$t->primary($new);
});
}
private function primaryNameGuess(string $table, array $cols): string
{
// 보통 {table}_primary 이지만, 드라이버/버전에 따라 다를 수 있어 try/catch로 대체
return "{$table}_primary";
}
private function dropPrimaryKeyBySql(string $table): void
{
$driver = DB::getDriverName();
if ($driver === 'mysql') {
DB::statement("ALTER TABLE `{$table}` DROP PRIMARY KEY");
} elseif ($driver === 'pgsql') {
// PostgreSQL은 PK 제약명 조회 후 드롭이 필요. 간단히 시도:
$constraint = DB::selectOne("
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = ? AND constraint_type = 'PRIMARY KEY'
LIMIT 1
", [$table]);
if ($constraint && isset($constraint->constraint_name)) {
DB::statement("ALTER TABLE \"{$table}\" DROP CONSTRAINT \"{$constraint->constraint_name}\"");
}
} else {
// 기타 드라이버는 수동 처리 생략
}
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// 1) 유니크 인덱스 존재 시 제거 (information_schema로 확인)
$hasUnique = DB::table('information_schema.statistics')
->whereRaw('TABLE_SCHEMA = DATABASE()')
->where('TABLE_NAME', 'menus')
->where('INDEX_NAME', 'menus_tenant_slug_unique')
->exists();
if ($hasUnique) {
Schema::table('menus', function (Blueprint $table) {
$table->dropUnique('menus_tenant_slug_unique');
});
}
// 2) slug 컬럼 존재 시 제거
if (Schema::hasColumn('menus', 'slug')) {
Schema::table('menus', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
}
public function down(): void
{
// 1) slug 컬럼 복구
if (!Schema::hasColumn('menus', 'slug')) {
Schema::table('menus', function (Blueprint $table) {
$table->string('slug', 150)->nullable()->comment('메뉴 슬러그(권한 키)');
});
}
// 2) 유니크 인덱스 복구 (없을 때만)
$hasUnique = DB::table('information_schema.statistics')
->whereRaw('TABLE_SCHEMA = DATABASE()')
->where('TABLE_NAME', 'menus')
->where('INDEX_NAME', 'menus_tenant_slug_unique')
->exists();
if (! $hasUnique) {
Schema::table('menus', function (Blueprint $table) {
$table->unique(['tenant_id', 'slug'], 'menus_tenant_slug_unique');
});
}
}
};