From 3ee7719cbca350caf58a8ab3de3bdf2871b74461 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 21 Aug 2025 18:38:27 +0900 Subject: [PATCH] =?UTF-8?q?fix=20:=20=EB=B6=80=EC=84=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route, Controller, Service, Swagger, DB 수정 - 모델 위치 이동 --- .../Api/V1/DepartmentController.php | 8 +- app/Models/Commons/Department.php | 60 ---- app/Models/Commons/DepartmentPermission.php | 40 --- app/Models/Members/User.php | 6 +- app/Models/Members/UserRole.php | 2 +- app/Models/Permissions/PermissionOverride.php | 40 +++ app/Models/{Commons => Permissions}/Role.php | 3 +- .../RoleMenuPermission.php | 4 +- app/Models/Tenants/Department.php | 65 ++++ .../Pivots}/DepartmentUser.php | 11 +- app/Models/Tenants/Tenant.php | 2 +- app/Models/Tenants/TenantUserProfile.php | 1 - app/Services/DepartmentService.php | 258 +++++++++++--- app/Swagger/v1/DepartmentApi.php | 336 +++++++++++------- ...fy_permissions_to_spatie_and_overrides.php | 172 +++++++++ routes/api.php | 4 +- 16 files changed, 727 insertions(+), 285 deletions(-) delete mode 100644 app/Models/Commons/Department.php delete mode 100644 app/Models/Commons/DepartmentPermission.php create mode 100644 app/Models/Permissions/PermissionOverride.php rename app/Models/{Commons => Permissions}/Role.php (88%) rename app/Models/{Commons => Permissions}/RoleMenuPermission.php (79%) create mode 100644 app/Models/Tenants/Department.php rename app/Models/{Commons => Tenants/Pivots}/DepartmentUser.php (88%) create mode 100644 database/migrations/2025_08_21_000000_unify_permissions_to_spatie_and_overrides.php diff --git a/app/Http/Controllers/Api/V1/DepartmentController.php b/app/Http/Controllers/Api/V1/DepartmentController.php index 1cfe8ea..f5275fd 100644 --- a/app/Http/Controllers/Api/V1/DepartmentController.php +++ b/app/Http/Controllers/Api/V1/DepartmentController.php @@ -92,18 +92,18 @@ public function listPermissions($id, Request $request) } // POST /v1/departments/{id}/permissions - public function upsertPermission($id, Request $request) + public function upsertPermissions($id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->upsertPermission((int)$id, $request->all()); + return $this->service->upsertPermissions((int)$id, $request->all()); }, '부서 권한 부여/차단'); } // DELETE /v1/departments/{id}/permissions/{permission} - public function revokePermission($id, $permission, Request $request) + public function revokePermissions($id, $permission, Request $request) { return ApiResponse::handle(function () use ($id, $permission, $request) { - return $this->service->revokePermission((int)$id, (int)$permission, $request->all()); + return $this->service->revokePermissions((int)$id, (int)$permission, $request->all()); }, '부서 권한 제거'); } } diff --git a/app/Models/Commons/Department.php b/app/Models/Commons/Department.php deleted file mode 100644 index c54d950..0000000 --- a/app/Models/Commons/Department.php +++ /dev/null @@ -1,60 +0,0 @@ - 'integer', - 'is_active' => 'integer', - 'sort_order'=> 'integer', - ]; - - protected $hidden = [ - 'deleted_by','deleted_at' - ]; - - /** Relations */ - public function departmentUsers() - { - return $this->hasMany(DepartmentUser::class, 'department_id'); - } - - public function users() - { - // User 네임스페이스가 다르면 여기만 맞춰줘. - return $this->belongsToMany(User::class, 'department_user', 'department_id', 'user_id') - ->withPivot(['tenant_id','is_primary','joined_at','left_at','created_at','updated_at','deleted_at']) - ->withTimestamps(); - } - - public function departmentPermissions() - { - return $this->hasMany(DepartmentPermission::class, 'department_id'); - } - - public function permissions() - { - return $this->belongsToMany(\Spatie\Permission\Models\Permission::class, 'department_permissions', 'department_id', 'permission_id') - ->withPivot(['tenant_id','menu_id','is_allowed','created_at','updated_at','deleted_at']) - ->withTimestamps(); - } -} diff --git a/app/Models/Commons/DepartmentPermission.php b/app/Models/Commons/DepartmentPermission.php deleted file mode 100644 index e409dcd..0000000 --- a/app/Models/Commons/DepartmentPermission.php +++ /dev/null @@ -1,40 +0,0 @@ - 'integer', - 'department_id' => 'integer', - 'permission_id' => 'integer', - 'menu_id' => 'integer', - 'is_allowed' => 'integer', - ]; - - public function department() - { - return $this->belongsTo(Department::class, 'department_id'); - } - - public function permission() - { - return $this->belongsTo(\Spatie\Permission\Models\Permission::class, 'permission_id'); - } -} diff --git a/app/Models/Members/User.php b/app/Models/Members/User.php index e821ed9..33499b6 100644 --- a/app/Models/Members/User.php +++ b/app/Models/Members/User.php @@ -9,12 +9,8 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Spatie\Permission\Traits\HasRoles; -use Spatie\Permission\Models\Role as SpatieRole; -use App\Models\Commons\Role as CommonRole; - /** * @mixin IdeHelperUser */ @@ -37,7 +33,7 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', 'last_login_at' => 'datetime', - 'options' => 'array', + 'options' => 'array', 'deleted_at' => 'datetime', 'password' => 'hashed', // ← 이걸 쓰면 자동 해싱 ]; diff --git a/app/Models/Members/UserRole.php b/app/Models/Members/UserRole.php index 2977412..df966a1 100644 --- a/app/Models/Members/UserRole.php +++ b/app/Models/Members/UserRole.php @@ -2,7 +2,7 @@ namespace App\Models\Members; -use App\Models\Commons\Role; +use App\Models\Permissions\Role; use App\Models\Tenants\Tenant; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; diff --git a/app/Models/Permissions/PermissionOverride.php b/app/Models/Permissions/PermissionOverride.php new file mode 100644 index 0000000..c29b956 --- /dev/null +++ b/app/Models/Permissions/PermissionOverride.php @@ -0,0 +1,40 @@ + 'int', + 'model_id' => 'int', + 'permission_id' => 'int', + 'effect' => 'int', // 1=ALLOW, -1=DENY + 'effective_from' => 'datetime', + 'effective_to' => 'datetime', + ]; + + /** + * 예외가 걸린 주체(User, Department 등) + * model_type, model_id를 사용하는 morphTo + */ + public function model() + { + // morphTo(relationshipName, type, id) + return $this->morphTo('model', 'model_type', 'model_id'); + } + + /** 연결된 Spatie Permission */ + public function permission() + { + return $this->belongsTo(SpatiePermission::class, 'permission_id'); + } +} diff --git a/app/Models/Commons/Role.php b/app/Models/Permissions/Role.php similarity index 88% rename from app/Models/Commons/Role.php rename to app/Models/Permissions/Role.php index 3fd7022..d76b521 100644 --- a/app/Models/Commons/Role.php +++ b/app/Models/Permissions/Role.php @@ -1,7 +1,8 @@ 'int', + 'parent_id' => 'int', + 'is_active' => 'bool', + 'sort_order'=> 'int', + ]; + + protected $hidden = [ + 'deleted_by','deleted_at' + ]; + + // 스파티 가드명(프로젝트 설정에 맞게 조정) + protected string $guard_name = 'web'; + + /** 상위/하위 부서 */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children() + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** 부서-사용자 N:N (추가 컬럼 포함 Pivot) */ + public function users() + { + return $this->belongsToMany(User::class, 'department_user') + ->using(DepartmentUser::class) + ->withTimestamps() + ->withPivot(['tenant_id','is_primary','joined_at','left_at']); + } + + /** 부서의 권한 오버라이드(DENY/임시허용) */ + public function permissionOverrides(): MorphMany + { + return $this->morphMany(PermissionOverride::class, 'model'); + } + + /** 부서-사용자 매핑 로우들(피벗 테이블의 레코드들) */ + public function departmentUsers() + { + return $this->hasMany(DepartmentUser::class, 'department_id'); + } +} diff --git a/app/Models/Commons/DepartmentUser.php b/app/Models/Tenants/Pivots/DepartmentUser.php similarity index 88% rename from app/Models/Commons/DepartmentUser.php rename to app/Models/Tenants/Pivots/DepartmentUser.php index 33657f8..1b566a8 100644 --- a/app/Models/Commons/DepartmentUser.php +++ b/app/Models/Tenants/Pivots/DepartmentUser.php @@ -1,12 +1,14 @@ belongsTo(User::class, 'user_id'); } } diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index dd8d959..50b7f31 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -2,11 +2,11 @@ namespace App\Models\Tenants; -use App\Models\Commons\Role; use App\Models\Commons\File; use App\Models\Members\User; use App\Models\Members\UserRole; use App\Models\Members\UserTenant; +use App\Models\Permissions\Role; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index dd727a2..44b9351 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -3,7 +3,6 @@ namespace App\Models\Tenants; use App\Models\Members\User; -use App\Models\Commons\Department; use Illuminate\Database\Eloquent\Model; /** diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php index 2cdc045..6fc6afa 100644 --- a/app/Services/DepartmentService.php +++ b/app/Services/DepartmentService.php @@ -2,9 +2,8 @@ namespace App\Services; -use App\Models\Commons\Department; -use App\Models\Commons\DepartmentPermission; -use App\Models\Commons\DepartmentUser; +use App\Models\Tenants\Department; +use App\Models\Tenants\Pivots\DepartmentUser; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; @@ -299,6 +298,7 @@ public function setPrimary(int $deptId, int $userId, array $params) /** 부서 권한 목록 */ public function listPermissions(int $deptId, array $params) { + // 1) 파라미터 검증 $p = $this->v($params, [ 'menu_id' => 'nullable|integer|min:1', 'is_allowed' => 'nullable|in:0,1', @@ -308,18 +308,78 @@ public function listPermissions(int $deptId, array $params) if ($p instanceof JsonResponse) return $p; if (isset($p['error'])) return $p; + // 2) 부서 확인 $dept = Department::query()->find($deptId); if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; - $q = DepartmentPermission::query() - ->whereNull('deleted_at') - ->where('department_id', $deptId); + $tenantId = (int)$dept->tenant_id; + $modelType = Department::class; - if (isset($p['menu_id'])) $q->where('menu_id', $p['menu_id']); - if (isset($p['is_allowed'])) $q->where('is_allowed', (int)$p['is_allowed']); + // 3) ALLOW/DENY 서브쿼리 준비 (컬럼 형태 통일) + $allowSub = DB::table('model_has_permissions') + ->select([ + 'permission_id', + DB::raw('1 as is_allowed'), + DB::raw('NULL as reason'), + DB::raw('NULL as effective_from'), + DB::raw('NULL as effective_to'), + DB::raw('NULL as override_id'), + DB::raw('NULL as override_updated_at'), + ]) + ->where([ + 'model_type' => $modelType, + 'model_id' => $deptId, + 'tenant_id' => $tenantId, + ]); - $q->orderByDesc('is_allowed')->orderBy('permission_id'); + $denySub = DB::table('permission_overrides') + ->select([ + 'permission_id', + DB::raw('0 as is_allowed'), + 'reason', + 'effective_from', + 'effective_to', + 'id as override_id', + 'updated_at as override_updated_at', + ]) + ->where([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + ]) + ->where('effect', -1); + // 4) 합치고 permissions 조인 + $q = DB::query() + ->fromSub(function ($sub) use ($allowSub, $denySub) { + $sub->from($allowSub->unionAll($denySub), 'u'); + }, 'u') + ->join('permissions', 'permissions.id', '=', 'u.permission_id') + ->select([ + 'u.permission_id', + 'u.is_allowed', + 'permissions.name as permission_code', + 'permissions.guard_name', + 'u.reason', + 'u.effective_from', + 'u.effective_to', + 'u.override_id', + 'u.override_updated_at', + ]); + + // 5) 필터 + if (isset($p['is_allowed'])) { + $q->where('u.is_allowed', (int)$p['is_allowed']); + } + if (isset($p['menu_id'])) { + // 권한코드가 menu.{id}.xxx 형태라는 전제 + $q->where('permissions.name', 'like', 'menu.' . (int)$p['menu_id'] . '.%'); + } + + // 6) 정렬(ALLOW 우선, 그 다음 permission_id 오름차순) + $q->orderByDesc('u.is_allowed')->orderBy('u.permission_id'); + + // 7) 페이지네이션 $perPage = $p['per_page'] ?? 20; $page = $p['page'] ?? null; @@ -327,58 +387,170 @@ public function listPermissions(int $deptId, array $params) } /** 권한 부여/차단 upsert */ - public function upsertPermission(int $deptId, array $params) + public function upsertPermissions(int $deptId, array $payload): array { - $p = $this->v($params, [ - 'permission_id' => 'required|integer|min:1', - 'menu_id' => 'nullable|integer|min:1', - 'is_allowed' => 'nullable|in:0,1', + // 단일이면 items로 감싸기 + $items = isset($payload['permission_id']) + ? [ $payload ] + : ($payload['items'] ?? $payload); + + $v = Validator::make(['items'=>$items], [ + 'items' => 'required|array|max:1000', + 'items.*.permission_id' => 'required|integer|min:1', + 'items.*.is_allowed' => 'nullable|in:0,1', // 생략 시 1(허용) ]); - if ($p instanceof JsonResponse) return $p; - if (isset($p['error'])) return $p; + if ($v->fails()) return ['error'=>$v->errors()->first(),'code'=>422]; $dept = Department::query()->find($deptId); if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; - $payload = [ - 'department_id' => $deptId, - 'permission_id' => $p['permission_id'], - 'menu_id' => $p['menu_id'] ?? null, + $tenantId = (int)$dept->tenant_id; + $modelType = Department::class; + + $ok = 0; $failed = []; + + DB::transaction(function () use ($items, $tenantId, $deptId, $modelType, &$ok, &$failed) { + foreach ($items as $i => $r) { + try { + $permissionId = (int)$r['permission_id']; + $isAllowed = array_key_exists('is_allowed', $r) ? (int)$r['is_allowed'] : 1; + + if ($isAllowed === 1) { + // ALLOW: Spatie 표준에 넣고, 동일 권한의 DENY 제거 + $exists = DB::table('model_has_permissions')->where([ + 'permission_id' => $permissionId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'tenant_id' => $tenantId, + ])->exists(); + + if (!$exists) { + DB::table('model_has_permissions')->insert([ + 'permission_id' => $permissionId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'tenant_id' => $tenantId, + ]); + } + + DB::table('permission_overrides')->where([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'permission_id' => $permissionId, + 'effect' => -1, + ])->delete(); + } else { + // DENY: overrides(effect=-1) upsert, 그리고 ALLOW 제거 + $exists = DB::table('permission_overrides')->where([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'permission_id' => $permissionId, + ])->exists(); + + if ($exists) { + DB::table('permission_overrides')->where([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'permission_id' => $permissionId, + ])->update(['effect' => -1, 'updated_at' => now()]); + } else { + DB::table('permission_overrides')->insert([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'permission_id' => $permissionId, + 'effect' => -1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + DB::table('model_has_permissions')->where([ + 'permission_id' => $permissionId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'tenant_id' => $tenantId, + ])->delete(); + } + + $ok++; + } catch (\Throwable $e) { + $failed[] = [ + 'index' => $i, + 'permission_id' => $r['permission_id'] ?? null, + 'message' => $e->getMessage(), + ]; + } + } + }); + + return [ + 'processed' => count($items), + 'succeeded' => $ok, + 'failed' => $failed ]; - - $model = DepartmentPermission::withTrashed()->firstOrNew($payload); - $model->is_allowed = isset($p['is_allowed']) ? (int)$p['is_allowed'] : 1; - $model->deleted_at = null; - $model->save(); - - // 변경 후 목록 반환 - return $this->listPermissions($deptId, []); } /** 권한 제거 (menu_id 없으면 전체 제거) */ - public function revokePermission(int $deptId, int $permissionId, array $params) + public function revokePermissions(int $deptId, array $payload): array { - $p = $this->v($params, [ - 'menu_id' => 'nullable|integer|min:1', + $items = isset($payload['permission_id']) + ? [ $payload ] + : ($payload['items'] ?? $payload); + + $v = Validator::make(['items'=>$items], [ + 'items' => 'required|array|max:1000', + 'items.*.permission_id' => 'required|integer|min:1', ]); - if ($p instanceof JsonResponse) return $p; - if (isset($p['error'])) return $p; + if ($v->fails()) return ['error'=>$v->errors()->first(),'code'=>422]; - $q = DepartmentPermission::whereNull('deleted_at') - ->where('department_id', $deptId) - ->where('permission_id', $permissionId); + $dept = Department::query()->find($deptId); + if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; - if (isset($p['menu_id'])) $q->where('menu_id', $p['menu_id']); + $tenantId = (int)$dept->tenant_id; + $modelType = Department::class; - $rows = $q->get(); - if ($rows->isEmpty()) return ['error' => '대상 권한을 찾을 수 없습니다.', 'code' => 404]; + $ok = 0; $failed = []; - foreach ($rows as $row) $row->delete(); + DB::transaction(function () use ($items, $tenantId, $deptId, $modelType, &$ok, &$failed) { + foreach ($items as $i => $r) { + try { + $permissionId = (int)$r['permission_id']; + + // ALLOW 제거 + DB::table('model_has_permissions')->where([ + 'permission_id' => $permissionId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'tenant_id' => $tenantId, + ])->delete(); + + // DENY/임시허용 오버라이드 제거 + DB::table('permission_overrides')->where([ + 'tenant_id' => $tenantId, + 'model_type' => $modelType, + 'model_id' => $deptId, + 'permission_id' => $permissionId, + ])->delete(); + + $ok++; + } catch (\Throwable $e) { + $failed[] = [ + 'index' => $i, + 'permission_id' => $r['permission_id'] ?? null, + 'message' => $e->getMessage(), + ]; + } + } + }); return [ - 'permission_id' => $permissionId, - 'menu_id' => $p['menu_id'] ?? null, - 'deleted_count' => $rows->count(), + 'processed' => count($items), + 'succeeded' => $ok, + 'failed' => $failed ]; } } diff --git a/app/Swagger/v1/DepartmentApi.php b/app/Swagger/v1/DepartmentApi.php index ea3bbc1..327eb2a 100644 --- a/app/Swagger/v1/DepartmentApi.php +++ b/app/Swagger/v1/DepartmentApi.php @@ -11,7 +11,33 @@ /** * ========================= - * Schemas + * 공통 응답 스키마 + * ========================= + */ + +/** + * @OA\Schema( + * schema="ApiResponse", + * type="object", + * required={"success","message"}, + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 생성 성공"), + * @OA\Property(property="data", nullable=true) + * ) + * + * @OA\Schema( + * schema="ErrorResponse", + * type="object", + * required={"success","message"}, + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="부서 생성 실패"), + * @OA\Property(property="data", type="null", example=null) + * ) + */ + +/** + * ========================= + * Domain 스키마 * ========================= */ @@ -76,16 +102,8 @@ * type="object", * required={"user_id"}, * @OA\Property(property="user_id", type="integer", example=12), - * @OA\Property(property="is_primary", type="integer", enum={0,1}, nullable=true, example=0) - * ) - * - * @OA\Schema( - * schema="DepartmentPermissionUpsertRequest", - * type="object", - * required={"permission_id"}, - * @OA\Property(property="permission_id", type="integer", example=25), - * @OA\Property(property="menu_id", type="integer", nullable=true, example=101, description="특정 메뉴에 한정하려면 지정"), - * @OA\Property(property="is_allowed", type="integer", enum={0,1}, example=1, description="1=ALLOW, 0=DENY(차단)") + * @OA\Property(property="is_primary", type="integer", enum={0,1}, nullable=true, example=0), + * @OA\Property(property="joined_at", type="string", format="date-time", nullable=true, example="2025-08-21 10:00:00") * ) * * @OA\Schema( @@ -98,8 +116,46 @@ * @OA\Property(property="email", type="string", format="email", example="hong@example.com"), * @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678"), * @OA\Property(property="is_active", type="integer", example=1) - * ) + * ) + * + * @OA\Schema( + * schema="DepartmentPermissionUpsertSingle", + * type="object", + * required={"permission_id"}, + * @OA\Property(property="permission_id", type="integer", example=25), + * @OA\Property(property="is_allowed", type="integer", enum={0,1}, example=1, description="1=ALLOW, 0=DENY(차단)") + * ) + * + * @OA\Schema( + * schema="DepartmentPermissionUpsertMany", + * type="object", + * required={"items"}, + * @OA\Property( + * property="items", + * type="array", + * @OA\Items(ref="#/components/schemas/DepartmentPermissionUpsertSingle") + * ) + * ) + * + * @OA\Schema( + * schema="DepartmentPermissionRevokeSingle", + * type="object", + * required={"permission_id"}, + * @OA\Property(property="permission_id", type="integer", example=25) + * ) + * + * @OA\Schema( + * schema="DepartmentPermissionRevokeMany", + * type="object", + * required={"items"}, + * @OA\Property( + * property="items", + * type="array", + * @OA\Items(ref="#/components/schemas/DepartmentPermissionRevokeSingle") + * ) + * ) */ + class DepartmentApi { /** @@ -113,22 +169,19 @@ class DepartmentApi * @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer", example=10)), * @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string", example="운영")), * @OA\Parameter(name="is_active", in="query", required=false, @OA\Schema(type="integer", enum={0,1}, example=1)), - * @OA\Response(response=200, description="목록 조회 성공", + * @OA\Response(response=200, description="부서 목록 조회 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="object", - * @OA\Property(property="current_page", type="integer", example=1), - * @OA\Property(property="per_page", type="integer", example=10), - * @OA\Property(property="total", type="integer", example=2), - * @OA\Property(property="data", ref="#/components/schemas/DepartmentList") - * )) - * } + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 목록 조회 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="current_page", type="integer", example=1), + * @OA\Property(property="per_page", type="integer", example=10), + * @OA\Property(property="total", type="integer", example=2), + * @OA\Property(property="data", ref="#/components/schemas/DepartmentList") + * ) * ) * ), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function index() {} @@ -137,22 +190,17 @@ public function index() {} * @OA\Get( * path="/api/v1/departments/{id}", * summary="부서 단건 조회", - * description="ID로 부서 상세를 조회합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), - * @OA\Response(response=200, description="단건 조회 성공", + * @OA\Response(response=200, description="부서 조회 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department")) - * } + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 조회 성공"), + * @OA\Property(property="data", ref="#/components/schemas/Department") * ) * ), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function show() {} @@ -161,22 +209,17 @@ public function show() {} * @OA\Post( * path="/api/v1/departments", * summary="부서 생성", - * description="새 부서를 생성합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentCreateRequest")), - * @OA\Response(response=200, description="생성 성공", + * @OA\Response(response=200, description="부서 생성 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department")) - * } + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 생성 성공"), + * @OA\Property(property="data", ref="#/components/schemas/Department") * ) * ), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function store() {} @@ -185,24 +228,19 @@ public function store() {} * @OA\Patch( * path="/api/v1/departments/{id}", * summary="부서 수정", - * description="기존 부서 정보를 부분 수정합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentUpdateRequest")), - * @OA\Response(response=200, description="수정 성공", + * @OA\Response(response=200, description="부서 수정 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department")) - * } + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 수정 성공"), + * @OA\Property(property="data", ref="#/components/schemas/Department") * ) * ), * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function update() {} @@ -210,18 +248,21 @@ public function update() {} /** * @OA\Delete( * path="/api/v1/departments/{id}", - * summary="부서 삭제", - * description="지정한 부서를 삭제합니다(소프트 삭제 권장).", + * summary="부서 삭제(소프트)", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), - * @OA\Response(response=200, description="삭제 성공", - * @OA\JsonContent(ref="#/components/schemas/ApiResponse") + * @OA\Response(response=200, description="부서 삭제 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 삭제 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="id", type="integer", example=7), + * @OA\Property(property="deleted_at", type="string", format="date-time", example="2025-08-21 11:00:00") + * ) + * ) * ), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function destroy() {} @@ -236,22 +277,19 @@ public function destroy() {} * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)), * @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer", example=20)), - * @OA\Response(response=200, description="조회 성공", + * @OA\Response(response=200, description="부서 사용자 목록 조회 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="object", - * @OA\Property(property="current_page", type="integer", example=1), - * @OA\Property(property="per_page", type="integer", example=20), - * @OA\Property(property="total", type="integer", example=1), - * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UserBrief")) - * )) - * } + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 사용자 목록 조회 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="current_page", type="integer", example=1), + * @OA\Property(property="per_page", type="integer", example=20), + * @OA\Property(property="total", type="integer", example=1), + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UserBrief")) + * ) * ) * ), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function users() {} @@ -260,16 +298,23 @@ public function users() {} * @OA\Post( * path="/api/v1/departments/{id}/users", * summary="부서 사용자 배정(단건)", - * description="특정 사용자 한 명을 해당 부서에 배정합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentUserAttachRequest")), - * @OA\Response(response=200, description="배정 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=200, description="부서 사용자 배정 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 사용자 배정 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="department_id", type="integer", example=7), + * @OA\Property(property="user_id", type="integer", example=12) + * ) + * ) + * ), * @OA\Response(response=409, description="이미 배정됨", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="부서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function attachUser() {} @@ -278,15 +323,21 @@ public function attachUser() {} * @OA\Delete( * path="/api/v1/departments/{id}/users/{user}", * summary="부서 사용자 해제(단건)", - * description="해당 부서에서 특정 사용자를 제거합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), * @OA\Parameter(name="user", in="path", required=true, @OA\Schema(type="integer", example=12)), - * @OA\Response(response=200, description="해제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=200, description="부서 사용자 해제 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 사용자 해제 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="user_id", type="integer", example=12), + * @OA\Property(property="deleted_at", type="string", format="date-time", example="2025-08-21 11:00:00") + * ) + * ) + * ), + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function detachUser() {} @@ -295,7 +346,6 @@ public function detachUser() {} * @OA\Patch( * path="/api/v1/departments/{id}/users/{user}/primary", * summary="주부서 설정/해제", - * description="부서 사용자 주부서 여부를 설정 또는 해제합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), @@ -306,11 +356,19 @@ public function detachUser() {} * @OA\Property(property="is_primary", type="integer", enum={0,1}, example=1) * ) * ), - * @OA\Response(response=200, description="설정 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=200, description="주부서 설정 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="주부서 설정 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="user_id", type="integer", example=12), + * @OA\Property(property="department_id", type="integer", example=7), + * @OA\Property(property="is_primary", type="integer", example=1) + * ) + * ) + * ), * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function setPrimary() {} @@ -319,29 +377,36 @@ public function setPrimary() {} * @OA\Get( * path="/api/v1/departments/{id}/permissions", * summary="부서 권한 목록", - * description="부서에 매핑된 권한(메뉴 한정 포함)을 조회합니다.", + * description="부서에 설정된 ALLOW/DENY 목록을 조회합니다. (is_allowed=1|0, menu_id 필터 지원)", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), - * @OA\Response(response=200, description="조회 성공", + * @OA\Parameter(name="menu_id", in="query", required=false, @OA\Schema(type="integer", example=101)), + * @OA\Parameter(name="is_allowed", in="query", required=false, @OA\Schema(type="integer", enum={0,1}, example=1)), + * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)), + * @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer", example=20)), + * @OA\Response(response=200, description="부서 권한 목록 조회 성공", * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property( - * property="data", - * type="array", + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 권한 목록 조회 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="current_page", type="integer", example=1), + * @OA\Property(property="per_page", type="integer", example=20), + * @OA\Property(property="total", type="integer", example=3), + * @OA\Property(property="data", type="array", * @OA\Items(type="object", * @OA\Property(property="permission_id", type="integer", example=25), - * @OA\Property(property="menu_id", type="integer", nullable=true, example=101), - * @OA\Property(property="is_allowed", type="integer", example=1) + * @OA\Property(property="permission_code", type="string", example="menu.101.read"), + * @OA\Property(property="is_allowed", type="integer", example=1), + * @OA\Property(property="reason", type="string", nullable=true, example="보안 이슈"), + * @OA\Property(property="effective_from", type="string", format="date-time", nullable=true), + * @OA\Property(property="effective_to", type="string", format="date-time", nullable=true) * ) - * )) - * } + * ) + * ) * ) * ), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="부서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function listPermissions() {} @@ -349,34 +414,63 @@ public function listPermissions() {} /** * @OA\Post( * path="/api/v1/departments/{id}/permissions", - * summary="부서 권한 부여/차단(단건 Upsert)", - * description="permission_id 기준으로 ALLOW(1) 또는 DENY(0) 처리합니다. menu_id 지정 시 해당 메뉴 범위로 제한됩니다.", + * summary="부서 권한 부여/차단(Upsert: 단건/배치)", + * description="permission_id 기준으로 ALLOW(1) 또는 DENY(0) 처리합니다. 단건 또는 items 배열을 모두 지원합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentPermissionUpsertRequest")), - * @OA\Response(response=200, description="적용 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(oneOf={ + * @OA\Schema(ref="#/components/schemas/DepartmentPermissionUpsertSingle"), + * @OA\Schema(ref="#/components/schemas/DepartmentPermissionUpsertMany") + * }) + * ), + * @OA\Response(response=200, description="부서 권한 적용 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 권한 적용 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="processed", type="integer", example=2), + * @OA\Property(property="succeeded", type="integer", example=2), + * @OA\Property(property="failed", type="array", @OA\Items(type="object")) + * ) + * ) + * ), + * @OA\Response(response=404, description="부서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function upsertPermission() {} /** * @OA\Delete( - * path="/api/v1/departments/{id}/permissions/{permission}", - * summary="부서 권한 해제(단건)", - * description="지정 권한을 부서 매핑에서 제거합니다. (menu_id 쿼리 파라미터로 특정 메뉴 범위를 지정 가능)", + * path="/api/v1/departments/{id}/permissions", + * summary="부서 권한 해제(단건/배치)", + * description="지정 권한을 부서 매핑에서 제거합니다. 단건 또는 items 배열을 모두 지원합니다.", * tags={"Department"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)), - * @OA\Parameter(name="permission", in="path", required=true, @OA\Schema(type="integer", example=25)), - * @OA\Parameter(name="menu_id", in="query", required=false, @OA\Schema(type="integer", example=101)), - * @OA\Response(response=200, description="해제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(oneOf={ + * @OA\Schema(ref="#/components/schemas/DepartmentPermissionRevokeSingle"), + * @OA\Schema(ref="#/components/schemas/DepartmentPermissionRevokeMany") + * }) + * ), + * @OA\Response(response=200, description="부서 권한 해제 성공", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="부서 권한 해제 성공"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="processed", type="integer", example=2), + * @OA\Property(property="succeeded", type="integer", example=2), + * @OA\Property(property="failed", type="array", @OA\Items(type="object")) + * ) + * ) + * ), + * @OA\Response(response=404, description="부서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function revokePermission() {} diff --git a/database/migrations/2025_08_21_000000_unify_permissions_to_spatie_and_overrides.php b/database/migrations/2025_08_21_000000_unify_permissions_to_spatie_and_overrides.php new file mode 100644 index 0000000..91c1fe1 --- /dev/null +++ b/database/migrations/2025_08_21_000000_unify_permissions_to_spatie_and_overrides.php @@ -0,0 +1,172 @@ +bigIncrements('id')->comment('PK'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('model_type', 255)->comment('App\\Models\\User | App\\Models\\Department 등'); + $table->unsignedBigInteger('model_id')->comment('모델 PK'); + $table->unsignedBigInteger('permission_id')->comment('permissions.id'); + $table->tinyInteger('effect')->comment('1=ALLOW, -1=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', 'model_type', 'model_id', 'permission_id'], 'uq_perm_override'); + $table->index(['tenant_id', 'permission_id'], 'idx_po_perm'); + + $table->foreign('permission_id') + ->references('id')->on('permissions') + ->onDelete('cascade'); + }); + } + + // 2) 혼재 테이블 드롭(존재하면) + foreach ([ + 'user_menu_permissions', + 'role_menu_permissions', + 'department_permissions', + 'user_permission_overrides', + ] as $legacyTable) { + if (Schema::hasTable($legacyTable)) { + Schema::drop($legacyTable); + } + } + + // 3) 보조 인덱스(선택) : model_has_permissions (tenant_id, permission_id) + if (Schema::hasTable('model_has_permissions')) { + if (!$this->mysqlIndexExists('model_has_permissions', 'idx_mhp_tenant_perm')) { + Schema::table('model_has_permissions', function (Blueprint $table) { + $table->index(['tenant_id', 'permission_id'], 'idx_mhp_tenant_perm'); + }); + } + } + } + + public function down(): void + { + // 1) permission_overrides 제거 + if (Schema::hasTable('permission_overrides')) { + Schema::drop('permission_overrides'); + } + + // 2) 드롭했던 테이블 복구(원본 스키마로) + if (!Schema::hasTable('user_menu_permissions')) { + Schema::create('user_menu_permissions', function (Blueprint $table) { + $table->bigIncrements('id')->comment('PK: 사용자-메뉴 권한 ID'); + $table->unsignedBigInteger('user_id')->comment('FK: 사용자 ID'); + $table->unsignedBigInteger('menu_id')->comment('FK: 메뉴 ID'); + $table->tinyInteger('access')->default(0)->comment('메뉴 접근 권한'); + $table->tinyInteger('read')->default(0)->comment('조회 권한'); + $table->tinyInteger('write')->default(0)->comment('등록/수정/삭제 권한'); + $table->tinyInteger('export')->default(0)->comment('다운로드 권한'); + $table->tinyInteger('approve')->default(0)->comment('승인/반려 권한'); + $table->timestamps(); + + $table->unique(['user_id', 'menu_id'], 'user_menu_permissions_user_id_menu_id_unique'); + $table->foreign('menu_id')->references('id')->on('menus')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + if (!Schema::hasTable('role_menu_permissions')) { + Schema::create('role_menu_permissions', function (Blueprint $table) { + $table->bigIncrements('id')->comment('PK: 역할-메뉴 권한 ID'); + $table->unsignedBigInteger('role_id')->comment('FK: 역할 ID'); + $table->unsignedBigInteger('menu_id')->comment('FK: 메뉴 ID'); + $table->tinyInteger('access')->default(0)->comment('메뉴 접근 권한'); + $table->tinyInteger('read')->default(0)->comment('조회 권한'); + $table->tinyInteger('write')->default(0)->comment('등록/수정/삭제 권한'); + $table->tinyInteger('export')->default(0)->comment('다운로드 권한'); + $table->tinyInteger('approve')->default(0)->comment('승인/반려 권한'); + $table->timestamps(); + + $table->unique(['role_id', 'menu_id'], 'role_menu_permissions_role_id_menu_id_unique'); + $table->foreign('menu_id')->references('id')->on('menus')->onDelete('cascade'); + $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); + }); + } + + 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', 'menu_id'], 'dept_perm_menu_idx'); + $table->index(['tenant_id', 'permission_id'], 'dept_perm_perm_idx'); + }); + } + + 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', 'permission_id'], 'user_perm_perm_idx'); + $table->index(['tenant_id', 'user_id'], 'user_perm_user_idx'); + }); + } + + // 3) 보조 인덱스 롤백 + if (Schema::hasTable('model_has_permissions')) { + if ($this->mysqlIndexExists('model_has_permissions', 'idx_mhp_tenant_perm')) { + Schema::table('model_has_permissions', function (Blueprint $table) { + $table->dropIndex('idx_mhp_tenant_perm'); + }); + } + } + } + + /** + * MySQL: 특정 테이블에 인덱스 존재 여부 확인 + */ + private function mysqlIndexExists(string $table, string $indexName): bool + { + // 스키마(데이터베이스)명 + $schema = DB::getDatabaseName(); + + $exists = DB::table('information_schema.STATISTICS') + ->where('TABLE_SCHEMA', $schema) + ->where('TABLE_NAME', $table) + ->where('INDEX_NAME', $indexName) + ->exists(); + + return (bool) $exists; + } +}; diff --git a/routes/api.php b/routes/api.php index 57628da..1210800 100644 --- a/routes/api.php +++ b/routes/api.php @@ -175,8 +175,8 @@ // 부서-권한 Route::get ('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('departments.permissions.index'); // 권한 목록 - Route::post ('/{id}/permissions', [DepartmentController::class, 'upsertPermission'])->name('departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) - Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermission'])->name('departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) + Route::post ('/{id}/permissions', [DepartmentController::class, 'upsertPermissions'])->name('departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) + Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) });