From 73d06e03b0212451490a52601a391935f23f6d91 Mon Sep 17 00:00:00 2001 From: kent Date: Sat, 16 Aug 2025 03:25:06 +0900 Subject: [PATCH] =?UTF-8?q?fix=20:=20=EA=B6=8C=ED=95=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(=EA=B0=81=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=99=95=EC=9D=B8=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메뉴관리 - 역할관리 - 부서관리 - 메뉴, 부서, 역할, 유저 - 권한 연동 --- app/Console/Commands/SeedMenuPermissions.php | 44 ++ .../Api/V1/DepartmentController.php | 107 +++++ .../Controllers/Api/V1/MenuController.php | 62 +++ .../Controllers/Api/V1/RoleController.php | 46 +++ .../Api/V1/RolePermissionController.php | 39 ++ .../Controllers/Api/V1/UserRoleController.php | 39 ++ app/Http/Middleware/CheckPermission.php | 52 ++- app/Http/Middleware/PermMapper.php | 49 +++ app/Models/Commons/Department.php | 52 +++ app/Models/Commons/DepartmentPermission.php | 37 ++ app/Models/Commons/DepartmentUser.php | 38 ++ app/Models/Commons/Menu.php | 5 +- app/Models/Members/User.php | 11 +- app/Observers/MenuObserver.php | 98 +++++ app/Providers/AppServiceProvider.php | 7 +- app/Services/Authz/AccessService.php | 111 +++++ app/Services/Authz/RolePermissionService.php | 214 ++++++++++ app/Services/Authz/RoleService.php | 141 +++++++ app/Services/Authz/UserRoleService.php | 203 ++++++++++ app/Services/DepartmentService.php | 316 +++++++++++++++ app/Services/MenuService.php | 220 ++++++++++ app/Swagger/v1/DepartmentApi.php | 383 ++++++++++++++++++ app/Swagger/v1/MemberApi.php | 6 - app/Swagger/v1/MenuApi.php | 232 +++++++++++ app/Swagger/v1/RoleApi.php | 180 ++++++++ app/Swagger/v1/RolePermissionApi.php | 171 ++++++++ app/Swagger/v1/UserRoleApi.php | 151 +++++++ bootstrap/app.php | 7 +- config/authz.php | 6 + ...5_08_15_000000_create_authz_structures.php | 221 ++++++++++ ..._spatie_permission_for_teams_and_guard.php | 261 ++++++++++++ ...8_15_000200_drop_slug_from_menus_table.php | 54 +++ public/api-docs/index.html | 81 ++-- routes/api.php | 96 +++-- 34 files changed, 3656 insertions(+), 84 deletions(-) create mode 100644 app/Console/Commands/SeedMenuPermissions.php create mode 100644 app/Http/Controllers/Api/V1/DepartmentController.php create mode 100644 app/Http/Controllers/Api/V1/MenuController.php create mode 100644 app/Http/Controllers/Api/V1/RoleController.php create mode 100644 app/Http/Controllers/Api/V1/RolePermissionController.php create mode 100644 app/Http/Controllers/Api/V1/UserRoleController.php create mode 100644 app/Http/Middleware/PermMapper.php create mode 100644 app/Models/Commons/Department.php create mode 100644 app/Models/Commons/DepartmentPermission.php create mode 100644 app/Models/Commons/DepartmentUser.php create mode 100644 app/Observers/MenuObserver.php create mode 100644 app/Services/Authz/AccessService.php create mode 100644 app/Services/Authz/RolePermissionService.php create mode 100644 app/Services/Authz/RoleService.php create mode 100644 app/Services/Authz/UserRoleService.php create mode 100644 app/Services/DepartmentService.php create mode 100644 app/Services/MenuService.php create mode 100644 app/Swagger/v1/DepartmentApi.php delete mode 100644 app/Swagger/v1/MemberApi.php create mode 100644 app/Swagger/v1/MenuApi.php create mode 100644 app/Swagger/v1/RoleApi.php create mode 100644 app/Swagger/v1/RolePermissionApi.php create mode 100644 app/Swagger/v1/UserRoleApi.php create mode 100644 config/authz.php create mode 100644 database/migrations/2025_08_15_000000_create_authz_structures.php create mode 100644 database/migrations/2025_08_15_000100_update_spatie_permission_for_teams_and_guard.php create mode 100644 database/migrations/2025_08_15_000200_drop_slug_from_menus_table.php diff --git a/app/Console/Commands/SeedMenuPermissions.php b/app/Console/Commands/SeedMenuPermissions.php new file mode 100644 index 0000000..5f50120 --- /dev/null +++ b/app/Console/Commands/SeedMenuPermissions.php @@ -0,0 +1,44 @@ +option('tenant') ? (int)$this->option('tenant') : null; + $guard = $this->option('guard') ?: 'api'; + $actions = config('authz.menu_actions', ['view','create','update','delete','approve']); + + $menus = Menu::query() + ->when($tenant !== null, fn ($q) => $q->where('tenant_id', $tenant)) + ->get(); + + $count = 0; + + foreach ($menus as $m) { + app(PermissionRegistrar::class)->setPermissionsTeamId((int)$m->tenant_id); + + foreach ($actions as $act) { + Permission::firstOrCreate([ + 'tenant_id' => (int)$m->tenant_id, + 'guard_name' => $guard, + 'name' => "menu:{$m->id}.{$act}", + ]); + $count++; + } + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->info("Ensured {$count} permissions."); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/DepartmentController.php b/app/Http/Controllers/Api/V1/DepartmentController.php new file mode 100644 index 0000000..d50d3d3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/DepartmentController.php @@ -0,0 +1,107 @@ +all()); + }, '부서 목록 조회'); + } + + // POST /v1/departments + public function store(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return DepartmentService::store($request->all()); + }, '부서 생성'); + } + + // GET /v1/departments/{id} + public function show($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::show((int)$id, $request->all()); + }, '부서 단건 조회'); + } + + // PATCH /v1/departments/{id} + public function update($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::update((int)$id, $request->all()); + }, '부서 수정'); + } + + // DELETE /v1/departments/{id} + public function destroy($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::destroy((int)$id, $request->all()); + }, '부서 삭제'); + } + + // GET /v1/departments/{id}/users + public function listUsers($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::listUsers((int)$id, $request->all()); + }, '부서 사용자 목록'); + } + + // POST /v1/departments/{id}/users + public function attachUser($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::attachUser((int)$id, $request->all()); + }, '부서 사용자 배정'); + } + + // DELETE /v1/departments/{id}/users/{user} + public function detachUser($id, $user, Request $request) + { + return ApiResponse::handle(function () use ($id, $user, $request) { + return DepartmentService::detachUser((int)$id, (int)$user, $request->all()); + }, '부서 사용자 제거'); + } + + // PATCH /v1/departments/{id}/users/{user}/primary + public function setPrimary($id, $user, Request $request) + { + return ApiResponse::handle(function () use ($id, $user, $request) { + return DepartmentService::setPrimary((int)$id, (int)$user, $request->all()); + }, '주 부서 설정/해제'); + } + + // GET /v1/departments/{id}/permissions + public function listPermissions($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::listPermissions((int)$id, $request->all()); + }, '부서 권한 목록'); + } + + // POST /v1/departments/{id}/permissions + public function upsertPermission($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return DepartmentService::upsertPermission((int)$id, $request->all()); + }, '부서 권한 부여/차단'); + } + + // DELETE /v1/departments/{id}/permissions/{permission} + public function revokePermission($id, $permission, Request $request) + { + return ApiResponse::handle(function () use ($id, $permission, $request) { + return DepartmentService::revokePermission((int)$id, (int)$permission, $request->all()); + }, '부서 권한 제거'); + } +} diff --git a/app/Http/Controllers/Api/V1/MenuController.php b/app/Http/Controllers/Api/V1/MenuController.php new file mode 100644 index 0000000..a67d108 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MenuController.php @@ -0,0 +1,62 @@ +all()); + }, '메뉴 목록 조회'); + } + + public function show($id) + { + return ApiResponse::handle(function () use ($id) { + return MenuService::show(['id' => (int)$id]); + }, '메뉴 단건 조회'); + } + + public function store(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return MenuService::store($request->all()); + }, '메뉴 등록'); + } + + public function update(Request $request, $id) + { + return ApiResponse::handle(function () use ($request, $id) { + $params = $request->all(); $params['id'] = (int)$id; + return MenuService::update($params); + }, '메뉴 수정'); + } + + public function destroy($id) + { + return ApiResponse::handle(function () use ($id) { + return MenuService::destroy(['id' => (int)$id]); + }, '메뉴 삭제'); + } + + public function reorder(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return MenuService::reorder($request->all()); + }, '메뉴 정렬 변경'); + } + + public function toggle(Request $request, $id) + { + return ApiResponse::handle(function () use ($request, $id) { + $params = $request->all(); $params['id'] = (int)$id; + return MenuService::toggle($params); + }, '메뉴 상태 토글'); + } +} diff --git a/app/Http/Controllers/Api/V1/RoleController.php b/app/Http/Controllers/Api/V1/RoleController.php new file mode 100644 index 0000000..94d29ee --- /dev/null +++ b/app/Http/Controllers/Api/V1/RoleController.php @@ -0,0 +1,46 @@ +all()); + }, '역할 목록 조회'); + } + + public function store(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return RoleService::store($request->all()); + }, '역할 생성'); + } + + public function show($id) + { + return ApiResponse::handle(function () use ($id) { + return RoleService::show((int)$id); + }, '역할 상세 조회'); + } + + public function update(Request $request, $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return RoleService::update((int)$id, $request->all()); + }, '역할 수정'); + } + + public function destroy($id) + { + return ApiResponse::handle(function () use ($id) { + return RoleService::destroy((int)$id); + }, '역할 삭제'); + } +} diff --git a/app/Http/Controllers/Api/V1/RolePermissionController.php b/app/Http/Controllers/Api/V1/RolePermissionController.php new file mode 100644 index 0000000..b78f3ba --- /dev/null +++ b/app/Http/Controllers/Api/V1/RolePermissionController.php @@ -0,0 +1,39 @@ +all()); + }, '역할 퍼미션 부여'); + } + + public function revoke($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return RolePermissionService::revoke((int)$id, $request->all()); + }, '역할 퍼미션 회수'); + } + + public function sync($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return RolePermissionService::sync((int)$id, $request->all()); + }, '역할 퍼미션 동기화'); + } +} diff --git a/app/Http/Controllers/Api/V1/UserRoleController.php b/app/Http/Controllers/Api/V1/UserRoleController.php new file mode 100644 index 0000000..047689a --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserRoleController.php @@ -0,0 +1,39 @@ +all()); + }, '사용자에게 역할 부여'); + } + + public function revoke($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return UserRoleService::revoke((int)$id, $request->all()); + }, '사용자의 역할 회수'); + } + + public function sync($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return UserRoleService::sync((int)$id, $request->all()); + }, '사용자의 역할 동기화'); + } +} diff --git a/app/Http/Middleware/CheckPermission.php b/app/Http/Middleware/CheckPermission.php index 4b60546..5fc861f 100644 --- a/app/Http/Middleware/CheckPermission.php +++ b/app/Http/Middleware/CheckPermission.php @@ -4,22 +4,54 @@ use Closure; use Illuminate\Http\Request; -use App\Services\AdminPermissionService; +use Spatie\Permission\PermissionRegistrar; +use App\Services\Authz\AccessService; +use App\Models\Members\User as UserModel; class CheckPermission { - public function handle(Request $request, Closure $next, string $permissionCode) + public function handle(Request $request, Closure $next) { - $userToken = $request->input('user_token'); - if (!$userToken) { - $userToken = $request->header('X-API-KEY'); - if (!$userToken) { - return response()->json(['error' => '토큰이 없습니다.'], 401); - } + // Perm 키 가져오기 (attributes 우선, 없으면 route defaults) + $perm = $request->attributes->get('perm') + ?? ($request->route()?->defaults['perm'] ?? null); + + // 다중 ANY-매칭 + $permsAny = $request->attributes->get('perms_any'); + + // perm 미지정 라우트 처리 정책 + // TODO :: 초기 도입 단계: 통과. (정책에 따라 403으로 바꿔도 됨) + if (!$perm && !$permsAny) { + return $next($request); + // return response()->json(['success'=>false,'message'=>'권한 설정 누락','data'=>null], 403); } - if (!AdminPermissionService::hasPermission($userToken, $permissionCode)) { - return response()->json(['error' => '권한이 없습니다.'], 403); + // 컨텍스트 확보 + $tenantId = (int) app('tenant_id'); + $userId = (int) app('api_user'); + if (!$tenantId || !$userId) { + return response()->json(['success'=>false,'message'=>'인증 또는 테넌트 정보가 없습니다.','data'=>null], 401); + } + + $user = UserModel::find($userId); + if (!$user) { + return response()->json(['success'=>false,'message'=>'사용자 없음','data'=>null], 401); + } + + // Spatie Teams 컨텍스트 고정 + app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); + + // 최종 판정(DENY 최우선/부서 ALLOW 포함) + if ($permsAny) { + foreach ($permsAny as $p) { + if (AccessService::allows($user, $p, $tenantId, 'api')) { + return $next($request); + } + } + return response()->json(['success'=>false,'message'=>'권한이 없습니다.','data'=>null], 403); + } + if (! AccessService::allows($user, $perm, $tenantId, 'api')) { + return response()->json(['success'=>false,'message'=>'권한이 없습니다.','data'=>null], 403); } return $next($request); diff --git a/app/Http/Middleware/PermMapper.php b/app/Http/Middleware/PermMapper.php new file mode 100644 index 0000000..dfca000 --- /dev/null +++ b/app/Http/Middleware/PermMapper.php @@ -0,0 +1,49 @@ + 'view', + 'HEAD' => 'view', + 'POST' => 'create', + 'PUT' => 'update', + 'PATCH' => 'update', + 'DELETE' => 'delete', + ]; + + public function handle(Request $request, Closure $next) + { + $route = $request->route(); + if (!$route) { + return $next($request); + } + + // 1) 이미 perm이 attributes에 있으면 존중 + if ($request->attributes->get('perm')) { + return $next($request); + } + + // 2) 라우트 defaults로 강제 지정된 perm/permission 우선 + $forced = $route->defaults['perm'] ?? $route->defaults['permission'] ?? null; + if ($forced) { + $request->attributes->set('perm', $forced); + return $next($request); + } + + // 3) menu_id 가 지정된 경우: HTTP 메서드 → 액션으로 perm 생성 + $menuId = $route->defaults['menu_id'] ?? null; + if ($menuId) { + $action = $this->actionMap[$request->method()] ?? 'view'; + $request->attributes->set('perm', "menu:{$menuId}.{$action}"); + } + + // 4) menu_id/perm 둘 다 없으면 설정 안 함(체크 미들웨어 정책에 따름) + return $next($request); + } +} diff --git a/app/Models/Commons/Department.php b/app/Models/Commons/Department.php new file mode 100644 index 0000000..38fd81a --- /dev/null +++ b/app/Models/Commons/Department.php @@ -0,0 +1,52 @@ + 'integer', + 'is_active' => 'integer', + 'sort_order'=> 'integer', + ]; + + /** Relations */ + public function departmentUsers() + { + return $this->hasMany(DepartmentUser::class, 'department_id'); + } + + public function users() + { + // User 네임스페이스가 다르면 여기만 맞춰줘. + return $this->belongsToMany(\App\Models\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 new file mode 100644 index 0000000..28607af --- /dev/null +++ b/app/Models/Commons/DepartmentPermission.php @@ -0,0 +1,37 @@ + '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/Commons/DepartmentUser.php b/app/Models/Commons/DepartmentUser.php new file mode 100644 index 0000000..96d4e1f --- /dev/null +++ b/app/Models/Commons/DepartmentUser.php @@ -0,0 +1,38 @@ + 'integer', + 'department_id' => 'integer', + 'user_id' => 'integer', + 'is_primary' => 'integer', + 'joined_at' => 'datetime', + 'left_at' => 'datetime', + ]; + + public function department() + { + return $this->belongsTo(Department::class, 'department_id'); + } + + public function user() + { + // User 네임스페이스가 다르면 여기만 맞춰줘. + return $this->belongsTo(\App\Models\User::class, 'user_id'); + } +} diff --git a/app/Models/Commons/Menu.php b/app/Models/Commons/Menu.php index 81848a0..ecbb15b 100644 --- a/app/Models/Commons/Menu.php +++ b/app/Models/Commons/Menu.php @@ -3,11 +3,14 @@ namespace App\Models\Commons; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class Menu extends Model { + use SoftDeletes; + protected $fillable = [ - 'tenant_id', 'parent_id', 'name', 'url', 'is_active', 'sort_order', + 'tenant_id', 'parent_id', 'slug', 'name', 'url', 'is_active', 'sort_order', 'hidden', 'is_external', 'external_url', 'icon' ]; diff --git a/app/Models/Members/User.php b/app/Models/Members/User.php index 07dd8ea..6a6474a 100644 --- a/app/Models/Members/User.php +++ b/app/Models/Members/User.php @@ -2,7 +2,6 @@ namespace App\Models\Members; -use App\Models\Commons\Role; use App\Models\Commons\File; use App\Models\Tenants\Tenant; use App\Traits\ModelTrait; @@ -13,10 +12,15 @@ 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; + class User extends Authenticatable { use HasApiTokens, Notifiable, SoftDeletes, ModelTrait, HasRoles; + protected $guard_name = 'api'; // ★ 중요: 권한/역할 가드 통일 + protected $fillable = [ 'user_id', 'name', @@ -56,9 +60,10 @@ public function userRoles() return $this->hasMany(UserRole::class); } - public function roles() + public function orgRoles() { - return $this->belongsToMany(Role::class, 'user_roles')->withPivot('tenant_id', 'assigned_at'); + return $this->belongsToMany(CommonRole::class, 'user_roles') + ->withPivot(['tenant_id', 'assigned_at']); } public function userTenantById($tenantId) diff --git a/app/Observers/MenuObserver.php b/app/Observers/MenuObserver.php new file mode 100644 index 0000000..d36bbd3 --- /dev/null +++ b/app/Observers/MenuObserver.php @@ -0,0 +1,98 @@ +shouldHandle($menu)) return; + + $this->setTeam((int)$menu->tenant_id); + $this->ensurePermissions($menu); + $this->forgetCache(); + } + + public function updated(Menu $menu): void + { + // 메뉴 ID 기반: 권한 이름 변경 불필요 + // 필요 시, 기본 롤 자동 부여/회수 같은 정책을 이곳에… + } + + public function deleted(Menu $menu): void + { + if (!$this->shouldHandle($menu)) return; + + $this->setTeam((int)$menu->tenant_id); + $this->removePermissions($menu); + $this->forgetCache(); + } + + public function restored(Menu $menu): void + { + if (!$this->shouldHandle($menu)) return; + + $this->setTeam((int)$menu->tenant_id); + $this->ensurePermissions($menu); + $this->forgetCache(); + } + + public function forceDeleted(Menu $menu): void + { + if (!$this->shouldHandle($menu)) return; + + $this->setTeam((int)$menu->tenant_id); + $this->removePermissions($menu); + $this->forgetCache(); + } + + /** teams 사용 시 tenant_id 없는 공용 메뉴는 스킵하는 게 안전 */ + protected function shouldHandle(Menu $menu): bool + { + return !is_null($menu->tenant_id); + } + + protected function setTeam(int $tenantId): void + { + app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); + } + + protected function ensurePermissions(Menu $menu): void + { + foreach ($this->actions() as $act) { + Permission::firstOrCreate([ + 'tenant_id' => (int)$menu->tenant_id, + 'guard_name' => $this->guard, + 'name' => "menu:{$menu->id}.{$act}", + ]); + } + } + + protected function removePermissions(Menu $menu): void + { + Permission::where('tenant_id', (int)$menu->tenant_id) + ->where('guard_name', $this->guard) + ->where(function ($q) use ($menu) { + foreach ($this->actions() as $act) { + $q->orWhere('name', "menu:{$menu->id}.{$act}"); + } + })->delete(); + } + + protected function forgetCache(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8ae7c57..bd2c6dc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\DB; +use App\Models\Commons\Menu; +use App\Observers\MenuObserver; class AppServiceProvider extends ServiceProvider { @@ -27,7 +29,7 @@ public function register(): void */ public function boot(): void { - // + // DB::enableQueryLog(); Builder::macro('debug', function($debug = null) { if (is_null($debug) && app()->environment('local')) { $debug = true; @@ -37,5 +39,8 @@ public function boot(): void } return $this; }); + + // 메뉴 생성/수정/삭제 ↔ 권한 자동 동기화 + Menu::observe(MenuObserver::class); } } diff --git a/app/Services/Authz/AccessService.php b/app/Services/Authz/AccessService.php new file mode 100644 index 0000000..f872044 --- /dev/null +++ b/app/Services/Authz/AccessService.php @@ -0,0 +1,111 @@ +id}:$guard:$permission:v{$ver}"; // ★ 키 강화 + return Cache::remember($key, now()->addSeconds(20), function () use ($user, $permission, $tenantId, $guard) { + + // 1) 개인 DENY + if (self::hasUserOverride($user->id, $permission, $tenantId, false, $guard)) { + return false; + } + + // 2) Spatie can (팀 컨텍스트는 미들웨어에서 이미 세팅됨) + if ($user->can($permission)) { + return true; + } + + // 3) 부서 ALLOW + if (self::departmentAllows($user->id, $permission, $tenantId, $guard)) { + return true; + } + + // 4) 개인 ALLOW + if (self::hasUserOverride($user->id, $permission, $tenantId, true, $guard)) { + return true; + } + + return false; + }); + } + + protected static function hasUserOverride( + int $userId, + string $permissionName, + int $tenantId, + bool $allow, + ?string $guardName = null + ): bool { + $now = now(); + $guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★ + + $q = DB::table('user_permission_overrides as uo') + ->join('permissions as p', 'p.id', '=', 'uo.permission_id') + ->whereNull('uo.deleted_at') + ->where('uo.user_id', $userId) + ->where('uo.tenant_id', $tenantId) + ->where('p.name', $permissionName) + ->where('p.tenant_id', $tenantId) // ★ 테넌트 일치 + ->where('p.guard_name', $guard) // ★ 가드 일치 + ->where(function ($w) use ($now) { + $w->whereNull('uo.effective_from')->orWhere('uo.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('uo.effective_to')->orWhere('uo.effective_to', '>=', $now); + }) + ->where('uo.is_allowed', $allow ? 1 : 0); + + return $q->exists(); + } + + protected static function departmentAllows( + int $userId, + string $permissionName, + int $tenantId, + ?string $guardName = null + ): bool { + $guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★ + + $q = DB::table('department_user as du') + ->join('department_permissions as dp', function ($j) { + $j->on('dp.department_id', '=', 'du.department_id') + ->whereNull('dp.deleted_at') + ->where('dp.is_allowed', 1); + }) + ->join('permissions as p', 'p.id', '=', 'dp.permission_id') + ->whereNull('du.deleted_at') + ->where('du.user_id', $userId) + ->where('du.tenant_id', $tenantId) + ->where('dp.tenant_id', $tenantId) + ->where('p.tenant_id', $tenantId) // ★ 테넌트 일치 + ->where('p.guard_name', $guard) // ★ 가드 일치 + ->where('p.name', $permissionName); + + return $q->exists(); + } + + public static function allowsOrAbort(User $user, string $permission, int $tenantId, ?string $guardName = null): void + { + if (! self::allows($user, $permission, $tenantId, $guardName)) { + abort(403, 'Forbidden'); + } + } + + // (선택) 권한 변경 시 호출해 캐시 무효화 + public static function bumpVersion(int $tenantId): void + { + Cache::increment("access:version:$tenantId"); + } +} diff --git a/app/Services/Authz/RolePermissionService.php b/app/Services/Authz/RolePermissionService.php new file mode 100644 index 0000000..89184cb --- /dev/null +++ b/app/Services/Authz/RolePermissionService.php @@ -0,0 +1,214 @@ +setPermissionsTeamId($tenantId); + } + + /** 역할 로드 (테넌트/가드 검증) */ + protected static function loadRoleOrError(int $roleId, int $tenantId): ?Role + { + $role = Role::query() + ->where('tenant_id', $tenantId) + ->where('guard_name', self::$guard) + ->find($roleId); + + return $role; + } + + /** A) permission_names[] → 그대로 사용 + * B) menus[] + actions[] → "menu:{id}.{act}" 배열로 변환(필요 시 Permission 생성) + */ + protected static function resolvePermissionNames(int $tenantId, array $params): array + { + $names = []; + + if (!empty($params['permission_names']) && is_array($params['permission_names'])) { + // 문자열 배열만 추림 + foreach ($params['permission_names'] as $n) { + if (is_string($n) && $n !== '') $names[] = trim($n); + } + } + + if (!empty($params['menus']) && is_array($params['menus']) && + !empty($params['actions']) && is_array($params['actions'])) { + + $allowed = config('authz.menu_actions', ['view','create','update','delete','approve']); + $acts = array_values(array_unique(array_filter(array_map('trim', $params['actions'])))); + $acts = array_intersect($acts, $allowed); + + $menuIds = array_values(array_unique(array_map('intval', $params['menus']))); + + foreach ($menuIds as $mid) { + foreach ($acts as $act) { + $names[] = "menu:{$mid}.{$act}"; + } + } + } + + // 빈/중복 제거 + $names = array_values(array_unique(array_filter($names))); + + // 존재하지 않는 Permission은 생성(tenant+guard 포함) + foreach ($names as $permName) { + Permission::firstOrCreate([ + 'tenant_id' => $tenantId, + 'guard_name' => self::$guard, + 'name' => $permName, + ]); + } + + return $names; + } + + /** 역할의 퍼미션 목록 */ + public static function list(int $roleId) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + self::setTeam($tenantId); + + $perms = $role->permissions() + ->where('tenant_id', $tenantId) + ->where('guard_name', self::$guard) + ->orderBy('name') + ->get(['id','tenant_id','name','guard_name','created_at','updated_at']); + + return ApiResponse::response('result', $perms); + } + + /** 부여 (중복 무시) */ + public static function grant(int $roleId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + // 유효성: 두 방식 중 하나만 요구하진 않지만, 최소 하나는 있어야 함 + $v = Validator::make($params, [ + 'permission_names' => 'sometimes|array', + 'permission_names.*' => 'string|min:1', + 'menus' => 'sometimes|array', + 'menus.*' => 'integer|min:1', + 'actions' => 'sometimes|array', + 'actions.*' => [ + 'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])), + ], + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { + return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolvePermissionNames($tenantId, $params); + if (empty($names)) { + return ApiResponse::error('유효한 퍼미션이 없습니다.', 422); + } + + // Spatie: 이름 배열 부여 OK (teams 컨텍스트 적용됨) + $role->givePermissionTo($names); + + return ApiResponse::response('success'); + } + + /** 회수 (없는 건 무시) */ + public static function revoke(int $roleId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'permission_names' => 'sometimes|array', + 'permission_names.*' => 'string|min:1', + 'menus' => 'sometimes|array', + 'menus.*' => 'integer|min:1', + 'actions' => 'sometimes|array', + 'actions.*' => [ + 'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])), + ], + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { + return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolvePermissionNames($tenantId, $params); + if (empty($names)) { + return ApiResponse::error('유효한 퍼미션이 없습니다.', 422); + } + + $role->revokePermissionTo($names); + + return ApiResponse::response('success'); + } + + /** 동기화(완전 교체) */ + public static function sync(int $roleId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'permission_names' => 'sometimes|array', + 'permission_names.*' => 'string|min:1', + 'menus' => 'sometimes|array', + 'menus.*' => 'integer|min:1', + 'actions' => 'sometimes|array', + 'actions.*' => [ + 'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])), + ], + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { + return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolvePermissionNames($tenantId, $params); // 존재하지 않으면 생성 + // 동기화 + $role->syncPermissions($names); + + return ApiResponse::response('success'); + } +} diff --git a/app/Services/Authz/RoleService.php b/app/Services/Authz/RoleService.php new file mode 100644 index 0000000..8a475de --- /dev/null +++ b/app/Services/Authz/RoleService.php @@ -0,0 +1,141 @@ +where('tenant_id', $tenantId) + ->where('guard_name', self::$guard); + + if ($q !== '') { + $query->where(function($w) use ($q) { + $w->where('name', 'like', "%{$q}%") + ->orWhere('description', 'like', "%{$q}%"); + }); + } + + $list = $query->orderBy('id','desc') + ->paginate($size, ['*'], 'page', $page); + + return ApiResponse::response('result', $list); + } + + /** 생성 */ + public static function store(array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $v = Validator::make($params, [ + 'name' => [ + 'required','string','max:100', + Rule::unique('roles','name')->where(fn($q)=>$q + ->where('tenant_id',$tenantId) + ->where('guard_name', self::$guard)), + ], + 'description' => 'nullable|string|max:255', + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); + + $role = Role::create([ + 'tenant_id' => $tenantId, + 'guard_name' => self::$guard, + 'name' => $v->validated()['name'], + 'description'=> $params['description'] ?? null, + ]); + + return ApiResponse::response('result', $role); + } + + /** 단건 */ + public static function show(int $id) + { + $tenantId = (int) app('tenant_id'); + + $role = Role::where('tenant_id',$tenantId) + ->where('guard_name', self::$guard) + ->find($id); + + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + return ApiResponse::response('result', $role); + } + + /** 수정 */ + public static function update(int $id, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $role = Role::where('tenant_id',$tenantId) + ->where('guard_name', self::$guard) + ->find($id); + + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'name' => [ + 'sometimes','string','max:100', + Rule::unique('roles','name') + ->where(fn($q)=>$q->where('tenant_id',$tenantId)->where('guard_name', self::$guard)) + ->ignore($role->id), + ], + 'description' => 'sometimes|nullable|string|max:255', + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $payload = $v->validated(); + $role->fill($payload)->save(); + + return ApiResponse::response('result', $role); + } + + /** 삭제 (현재는 하드삭제) */ + public static function destroy(int $id) + { + $tenantId = (int) app('tenant_id'); + + $role = Role::where('tenant_id',$tenantId) + ->where('guard_name', self::$guard) + ->find($id); + + if (!$role) { + return ApiResponse::error('역할을 찾을 수 없습니다.', 404); + } + + DB::transaction(function () use ($role) { + // 연관 피벗은 스파티가 onDelete cascade 하므로 기본 동작으로 OK + $role->delete(); // ※ 기본 Spatie Role은 SoftDeletes 미사용 → 하드 삭제 + }); + + return ApiResponse::response('success'); + } +} diff --git a/app/Services/Authz/UserRoleService.php b/app/Services/Authz/UserRoleService.php new file mode 100644 index 0000000..5e3531b --- /dev/null +++ b/app/Services/Authz/UserRoleService.php @@ -0,0 +1,203 @@ +setPermissionsTeamId($tenantId); + } + + /** 유저 로드 (존재 체크) */ + protected static function loadUserOrError(int $userId): ?User + { + return User::find($userId); + } + + /** 입력으로 받은 role_names[] 또는 role_ids[] → 실제 역할 이름 배열로 변환(검증 포함) */ + protected static function resolveRoleNames(int $tenantId, array $params): array + { + $names = []; + + // A) role_names[] 직접 + if (!empty($params['role_names']) && is_array($params['role_names'])) { + foreach ($params['role_names'] as $n) { + if (is_string($n) && $n !== '') $names[] = trim($n); + } + } + + // B) role_ids[] → 이름으로 변환 + if (!empty($params['role_ids']) && is_array($params['role_ids'])) { + $ids = array_values(array_unique(array_map('intval', $params['role_ids']))); + if (!empty($ids)) { + $rows = Role::query() + ->where('tenant_id', $tenantId) + ->where('guard_name', self::$guard) + ->whereIn('id', $ids) + ->pluck('name') + ->all(); + $names = array_merge($names, $rows); + } + } + + // 정제 + $names = array_values(array_unique(array_filter($names))); + + // 존재하지 않는 이름이 섞였는지 확인(실패 시 422로 안내하고 싶다면 여기서 검사) + if (!empty($names)) { + $count = Role::query() + ->where('tenant_id', $tenantId) + ->where('guard_name', self::$guard) + ->whereIn('name', $names) + ->count(); + + if ($count !== count($names)) { + // 존재하지 않는 역할 이름이 포함됨 + // 필요하면 어떤 이름이 없는지 찾아서 에러 반환하도록 개선 가능 + } + } + + return $names; + } + + /** 목록 */ + public static function list(int $userId) + { + $tenantId = (int) app('tenant_id'); + + $user = self::loadUserOrError($userId); + if (!$user) { + return ApiResponse::error('사용자를 찾을 수 없습니다.', 404); + } + + self::setTeam($tenantId); + + // 현재 테넌트의 역할만 + $roles = $user->roles() + ->where('roles.tenant_id', $tenantId) + ->where('roles.guard_name', self::$guard) + ->orderBy('roles.id', 'desc') + ->get(['roles.id','roles.tenant_id','roles.name','roles.description','roles.guard_name','roles.created_at','roles.updated_at']); + + return ApiResponse::response('result', $roles); + } + + /** 부여 (중복 무시) */ + public static function grant(int $userId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $user = self::loadUserOrError($userId); + if (!$user) { + return ApiResponse::error('사용자를 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'role_names' => 'sometimes|array', + 'role_names.*' => 'string|min:1', + 'role_ids' => 'sometimes|array', + 'role_ids.*' => 'integer|min:1', + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['role_names']) && empty($params['role_ids'])) { + return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolveRoleNames($tenantId, $params); + if (empty($names)) { + return ApiResponse::error('유효한 역할이 없습니다.', 422); + } + + // Spatie: 이름 배열로 부여 (teams 컨텍스트 적용) + $user->assignRole($names); + + return ApiResponse::response('success'); + } + + /** 회수 (없는 건 무시) */ + public static function revoke(int $userId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $user = self::loadUserOrError($userId); + if (!$user) { + return ApiResponse::error('사용자를 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'role_names' => 'sometimes|array', + 'role_names.*' => 'string|min:1', + 'role_ids' => 'sometimes|array', + 'role_ids.*' => 'integer|min:1', + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['role_names']) && empty($params['role_ids'])) { + return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolveRoleNames($tenantId, $params); + if (empty($names)) { + return ApiResponse::error('유효한 역할이 없습니다.', 422); + } + + $user->removeRole($names); // 배열 허용 + + return ApiResponse::response('success'); + } + + /** 동기화(완전 교체) */ + public static function sync(int $userId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $user = self::loadUserOrError($userId); + if (!$user) { + return ApiResponse::error('사용자를 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'role_names' => 'sometimes|array', + 'role_names.*' => 'string|min:1', + 'role_ids' => 'sometimes|array', + 'role_ids.*' => 'integer|min:1', + ]); + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + if (empty($params['role_names']) && empty($params['role_ids'])) { + return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422); + } + + self::setTeam($tenantId); + + $names = self::resolveRoleNames($tenantId, $params); + if (empty($names)) { + // 빈 목록으로 sync = 모두 제거 의도라면 허용할 수도 있음. + // 정책에 맞춰 처리: 여기서는 빈 목록이면 실패 처리 + return ApiResponse::error('유효한 역할이 없습니다.', 422); + } + + $user->syncRoles($names); // 교체 + + return ApiResponse::response('success'); + } +} diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php new file mode 100644 index 0000000..906770a --- /dev/null +++ b/app/Services/DepartmentService.php @@ -0,0 +1,316 @@ +fails()) abort(422, $v->errors()->first()); + return $v->validated(); + } + + /** 목록 */ + public static function index(array $params) + { + $p = self::v($params, [ + 'q' => 'nullable|string|max:100', + 'is_active' => 'nullable|in:0,1', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:200', + ]); + + $q = Department::query(); + + if (isset($p['is_active'])) $q->where('is_active', (int)$p['is_active']); + if (!empty($p['q'])) { + $q->where(function($w) use ($p) { + $w->where('name','like','%'.$p['q'].'%') + ->orWhere('code','like','%'.$p['q'].'%'); + }); + } + + return $q->orderBy('sort_order')->orderBy('name')->paginate($p['per_page'] ?? 20); + } + + /** 생성 */ + public static function store(array $params) + { + $p = self::v($params, [ + 'code' => 'nullable|string|max:50', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:255', + 'is_active' => 'nullable|in:0,1', + 'sort_order' => 'nullable|integer', + 'created_by' => 'nullable|integer|min:1', + ]); + + if (!empty($p['code'])) { + $exists = Department::query()->where('code', $p['code'])->exists(); + if ($exists) abort(409, '이미 존재하는 부서 코드입니다.'); + } + + $dept = Department::create([ + 'code' => $p['code'] ?? null, + 'name' => $p['name'], + 'description' => $p['description'] ?? null, + 'is_active' => isset($p['is_active']) ? (int)$p['is_active'] : 1, + 'sort_order' => $p['sort_order'] ?? 0, + 'created_by' => $p['created_by'] ?? null, + 'updated_by' => $p['created_by'] ?? null, + ]); + + return self::show($dept->id, []); + } + + /** 단건 */ + public static function show(int $id, array $params) + { + $dept = Department::query()->find($id); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + return $dept; + } + + /** 수정 */ + public static function update(int $id, array $params) + { + $p = self::v($params, [ + 'code' => 'nullable|string|max:50', + 'name' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:255', + 'is_active' => 'nullable|in:0,1', + 'sort_order' => 'nullable|integer', + 'updated_by' => 'nullable|integer|min:1', + ]); + + $dept = Department::query()->find($id); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + if (array_key_exists('code', $p) && !is_null($p['code'])) { + $exists = Department::query() + ->where('code', $p['code']) + ->where('id','!=',$id) + ->exists(); + if ($exists) abort(409, '이미 존재하는 부서 코드입니다.'); + } + + $dept->fill([ + 'code' => array_key_exists('code', $p) ? $p['code'] : $dept->code, + 'name' => $p['name'] ?? $dept->name, + 'description' => $p['description'] ?? $dept->description, + 'is_active' => isset($p['is_active']) ? (int)$p['is_active'] : $dept->is_active, + 'sort_order' => $p['sort_order'] ?? $dept->sort_order, + 'updated_by' => $p['updated_by'] ?? $dept->updated_by, + ])->save(); + + return $dept->fresh(); + } + + /** 삭제(soft) */ + public static function destroy(int $id, array $params) + { + $p = self::v($params, [ + 'deleted_by' => 'nullable|integer|min:1', + ]); + + $dept = Department::query()->find($id); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + if (!empty($p['deleted_by'])) { + $dept->deleted_by = $p['deleted_by']; + $dept->save(); + } + $dept->delete(); + + return ['id'=>$id, 'deleted_at'=>now()->toDateTimeString()]; + } + + /** 부서 사용자 목록 */ + public static function listUsers(int $deptId, array $params) + { + $p = self::v($params, [ + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:200', + ]); + + $dept = Department::query()->find($deptId); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + return $dept->departmentUsers() + ->with('user') + ->orderByDesc('is_primary') + ->orderBy('id') + ->paginate($p['per_page'] ?? 20); + } + + /** 사용자 배정 (단건) */ + public static function attachUser(int $deptId, array $params) + { + $p = self::v($params, [ + 'user_id' => 'required|integer|min:1', + 'is_primary' => 'nullable|in:0,1', + 'joined_at' => 'nullable|date', + ]); + + $dept = Department::query()->find($deptId); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + return DB::transaction(function () use ($dept, $p) { + $du = DepartmentUser::withTrashed() + ->where('department_id', $dept->id) + ->where('user_id', $p['user_id']) + ->first(); + + if ($du && is_null($du->deleted_at)) { + abort(409, '이미 배정된 사용자입니다.'); + } + + if (!empty($p['is_primary']) && (int)$p['is_primary'] === 1) { + DepartmentUser::whereNull('deleted_at') + ->where('user_id', $p['user_id']) + ->update(['is_primary'=>0]); + } + + $payload = [ + 'department_id' => $dept->id, + 'user_id' => $p['user_id'], + 'is_primary' => isset($p['is_primary']) ? (int)$p['is_primary'] : 0, + 'joined_at' => !empty($p['joined_at']) ? Carbon::parse($p['joined_at']) : now(), + ]; + + if ($du) { + $du->fill($payload); + $du->restore(); + $du->save(); + } else { + DepartmentUser::create($payload); + } + + return ['department_id'=>$dept->id, 'user_id'=>$p['user_id']]; + }); + } + + /** 사용자 제거(soft) */ + public static function detachUser(int $deptId, int $userId, array $params) + { + $du = DepartmentUser::whereNull('deleted_at') + ->where('department_id', $deptId) + ->where('user_id', $userId) + ->first(); + + if (!$du) abort(404, '배정된 사용자를 찾을 수 없습니다.'); + $du->delete(); + + return ['user_id'=>$userId, 'deleted_at'=>now()->toDateTimeString()]; + } + + /** 주부서 설정/해제 */ + public static function setPrimary(int $deptId, int $userId, array $params) + { + $p = self::v($params, [ + 'is_primary' => 'required|in:0,1', + ]); + + return DB::transaction(function () use ($deptId, $userId, $p) { + $du = DepartmentUser::whereNull('deleted_at') + ->where('department_id', $deptId) + ->where('user_id', $userId) + ->first(); + + if (!$du) abort(404, '배정된 사용자를 찾을 수 없습니다.'); + + if ((int)$p['is_primary'] === 1) { + DepartmentUser::whereNull('deleted_at') + ->where('user_id', $userId) + ->update(['is_primary' => 0]); + } + + $du->is_primary = (int)$p['is_primary']; + $du->save(); + + return ['user_id'=>$userId,'department_id'=>$deptId,'is_primary'=>$du->is_primary]; + }); + } + + /** 부서 권한 목록 */ + public static function listPermissions(int $deptId, array $params) + { + $p = self::v($params, [ + 'menu_id' => 'nullable|integer|min:1', + 'is_allowed' => 'nullable|in:0,1', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:200', + ]); + + $dept = Department::query()->find($deptId); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + $q = DepartmentPermission::query() + ->whereNull('deleted_at') + ->where('department_id', $deptId); + + 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']); + + return $q->orderByDesc('is_allowed')->orderBy('permission_id')->paginate($p['per_page'] ?? 20); + } + + /** 권한 부여/차단 upsert */ + public static function upsertPermission(int $deptId, array $params) + { + $p = self::v($params, [ + 'permission_id' => 'required|integer|min:1', + 'menu_id' => 'nullable|integer|min:1', + 'is_allowed' => 'nullable|in:0,1', + ]); + + $dept = Department::query()->find($deptId); + if (!$dept) abort(404, '부서를 찾을 수 없습니다.'); + + $payload = [ + 'department_id' => $deptId, + 'permission_id' => $p['permission_id'], + 'menu_id' => $p['menu_id'] ?? null, + ]; + + $model = DepartmentPermission::withTrashed()->firstOrNew($payload); + $model->is_allowed = isset($p['is_allowed']) ? (int)$p['is_allowed'] : 1; + $model->deleted_at = null; + $model->save(); + + return self::listPermissions($deptId, []); + } + + /** 권한 제거 (menu_id 없으면 전체 제거) */ + public static function revokePermission(int $deptId, int $permissionId, array $params) + { + $p = self::v($params, [ + 'menu_id' => 'nullable|integer|min:1', + ]); + + $q = DepartmentPermission::whereNull('deleted_at') + ->where('department_id', $deptId) + ->where('permission_id', $permissionId); + + if (isset($p['menu_id'])) $q->where('menu_id', $p['menu_id']); + + $rows = $q->get(); + if ($rows->isEmpty()) abort(404, '대상 권한을 찾을 수 없습니다.'); + + foreach ($rows as $row) $row->delete(); + + return [ + 'permission_id' => $permissionId, + 'menu_id' => $p['menu_id'] ?? null, + 'deleted_count' => $rows->count(), + ]; + } +} diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php new file mode 100644 index 0000000..e3569db --- /dev/null +++ b/app/Services/MenuService.php @@ -0,0 +1,220 @@ +attributes->get('tenant_id'); + } + + protected static function actorId(array $params): ?int + { + return $params['user_id'] ?? (request()->user()->id ?? null); + } + + public static function index(array $params) + { + $tenantId = self::tenantId($params); + + $q = DB::table('menus')->whereNull('deleted_at'); + if (!is_null($tenantId)) { + $q->where(function ($w) use ($tenantId) { + $w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + }); + } + // 옵션: parent_id / is_active / hidden 필터 + if (isset($params['parent_id'])) $q->where('parent_id', $params['parent_id']); + if (isset($params['is_active'])) $q->where('is_active', (int)$params['is_active']); + if (isset($params['hidden'])) $q->where('hidden', (int)$params['hidden']); + + return $q->orderBy('parent_id')->orderBy('sort_order')->get(); + } + + public static function show(array $params) + { + $id = (int)($params['id'] ?? 0); + $tenantId = self::tenantId($params); + + $q = DB::table('menus')->where('id', $id)->whereNull('deleted_at'); + if (!is_null($tenantId)) { + $q->where(function ($w) use ($tenantId) { + $w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + }); + } + + $row = $q->first(); + throw_if(!$row, ValidationException::withMessages(['id' => 'Menu not found'])); + return $row; + } + + public static function store(array $params) + { + $tenantId = self::tenantId($params); + $userId = self::actorId($params); + + $v = Validator::make($params, [ + 'parent_id' => ['nullable','integer'], + 'name' => ['required','string','max:100'], + 'slug' => ['nullable','string','max:150'], + 'url' => ['nullable','string','max:255'], + 'is_active' => ['nullable','boolean'], + 'sort_order' => ['nullable','integer'], + 'hidden' => ['nullable','boolean'], + 'is_external' => ['nullable','boolean'], + 'external_url' => ['nullable','string','max:255'], + 'icon' => ['nullable','string','max:50'], + ]); + $data = $v->validated(); + + // slug 유니크(테넌트 범위) 체크 + if (!empty($data['slug'])) { + $exists = DB::table('menus') + ->whereNull('deleted_at') + ->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id')) + ->where('slug',$data['slug']) + ->exists(); + if ($exists) { + throw ValidationException::withMessages(['slug'=>'이미 사용 중인 슬러그입니다.']); + } + } + + $now = now(); + $id = DB::table('menus')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => $data['parent_id'] ?? null, + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + 'url' => $data['url'] ?? null, + 'is_active' => (int)($data['is_active'] ?? 1), + 'sort_order' => (int)($data['sort_order'] ?? 0), + 'hidden' => (int)($data['hidden'] ?? 0), + 'is_external' => (int)($data['is_external'] ?? 0), + 'external_url' => $data['external_url'] ?? null, + 'icon' => $data['icon'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return ['id' => $id]; + } + + public static function update(array $params) + { + $id = (int)($params['id'] ?? 0); + $tenantId = self::tenantId($params); + $userId = self::actorId($params); + + $v = Validator::make($params, [ + 'parent_id' => ['nullable','integer'], + 'name' => ['nullable','string','max:100'], + 'slug' => ['nullable','string','max:150'], + 'url' => ['nullable','string','max:255'], + 'is_active' => ['nullable','boolean'], + 'sort_order' => ['nullable','integer'], + 'hidden' => ['nullable','boolean'], + 'is_external' => ['nullable','boolean'], + 'external_url' => ['nullable','string','max:255'], + 'icon' => ['nullable','string','max:50'], + ]); + $data = $v->validated(); + + // 대상 존재 확인 & 테넌트 범위 + $exists = DB::table('menus')->where('id',$id)->whereNull('deleted_at') + ->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id')) + ->exists(); + if (!$exists) throw ValidationException::withMessages(['id'=>'Menu not found']); + + // slug 유니크(테넌트 범위) 체크 + if (!empty($data['slug'])) { + $dup = DB::table('menus')->whereNull('deleted_at') + ->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id')) + ->where('slug',$data['slug'])->where('id','<>',$id)->exists(); + if ($dup) throw ValidationException::withMessages(['slug'=>'이미 사용 중인 슬러그입니다.']); + } + + $update = Arr::only($data, ['parent_id','name','slug','url','is_active','sort_order','hidden','is_external','external_url','icon']); + $update = array_filter($update, fn($v)=>!is_null($v)); + $update['updated_at'] = now(); + $update['updated_by'] = $userId; + + DB::table('menus')->where('id',$id)->update($update); + return ['id' => $id]; + } + + public static function destroy(array $params) + { + $id = (int)($params['id'] ?? 0); + $tenantId = self::tenantId($params); + $userId = self::actorId($params); + + $q = DB::table('menus')->where('id',$id)->whereNull('deleted_at'); + $q = !is_null($tenantId) + ? $q->where('tenant_id',$tenantId) + : $q->whereNull('tenant_id'); + + $row = $q->first(); + if (!$row) throw ValidationException::withMessages(['id'=>'Menu not found']); + + DB::table('menus')->where('id',$id)->update([ + 'deleted_at' => now(), + 'deleted_by' => $userId, + ]); + return ['id' => $id, 'deleted' => true]; + } + + /** + * 정렬 일괄 변경 + * $params = [ ['id'=>10, 'sort_order'=>1], ... ] + */ + public static function reorder(array $params) + { + if (!is_array($params) || empty($params)) { + throw ValidationException::withMessages(['items'=>'유효한 정렬 목록이 필요합니다.']); + } + DB::transaction(function () use ($params) { + foreach ($params as $it) { + if (!isset($it['id'], $it['sort_order'])) continue; + DB::table('menus')->where('id',(int)$it['id'])->update([ + 'sort_order' => (int)$it['sort_order'], + 'updated_at' => now(), + ]); + } + }); + return true; + } + + /** + * 상태 토글 + * 허용 필드: is_active / hidden / is_external + */ + public static function toggle(array $params) + { + $id = (int)($params['id'] ?? 0); + $userId = self::actorId($params); + + $payload = array_filter([ + 'is_active' => isset($params['is_active']) ? (int)$params['is_active'] : null, + 'hidden' => isset($params['hidden']) ? (int)$params['hidden'] : null, + 'is_external' => isset($params['is_external']) ? (int)$params['is_external'] : null, + ], fn($v)=>!is_null($v)); + + if (empty($payload)) { + throw ValidationException::withMessages(['toggle'=>'변경할 필드가 없습니다.']); + } + + $payload['updated_at'] = now(); + $payload['updated_by'] = $userId; + + DB::table('menus')->where('id',$id)->update($payload); + return ['id' => $id]; + } +} diff --git a/app/Swagger/v1/DepartmentApi.php b/app/Swagger/v1/DepartmentApi.php new file mode 100644 index 0000000..ea3bbc1 --- /dev/null +++ b/app/Swagger/v1/DepartmentApi.php @@ -0,0 +1,383 @@ +append(CorsMiddleware::class); $middleware->alias([ - 'auth.apikey' => ApiKeyMiddleware::class, + 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth 'swagger.auth' => CheckSwaggerAuth::class, - 'permission' => CheckPermission::class, + 'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입 + 'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단 ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/config/authz.php b/config/authz.php new file mode 100644 index 0000000..e60f7ce --- /dev/null +++ b/config/authz.php @@ -0,0 +1,6 @@ + ['view','create','update','delete','approve', 'export', 'manage'], +]; diff --git a/database/migrations/2025_08_15_000000_create_authz_structures.php b/database/migrations/2025_08_15_000000_create_authz_structures.php new file mode 100644 index 0000000..3e35c6c --- /dev/null +++ b/database/migrations/2025_08_15_000000_create_authz_structures.php @@ -0,0 +1,221 @@ +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; + } +}; diff --git a/database/migrations/2025_08_15_000100_update_spatie_permission_for_teams_and_guard.php b/database/migrations/2025_08_15_000100_update_spatie_permission_for_teams_and_guard.php new file mode 100644 index 0000000..716e85a --- /dev/null +++ b/database/migrations/2025_08_15_000100_update_spatie_permission_for_teams_and_guard.php @@ -0,0 +1,261 @@ +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 { + // 기타 드라이버는 수동 처리 생략 + } + } +}; diff --git a/database/migrations/2025_08_15_000200_drop_slug_from_menus_table.php b/database/migrations/2025_08_15_000200_drop_slug_from_menus_table.php new file mode 100644 index 0000000..0063738 --- /dev/null +++ b/database/migrations/2025_08_15_000200_drop_slug_from_menus_table.php @@ -0,0 +1,54 @@ +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'); + }); + } + } +}; diff --git a/public/api-docs/index.html b/public/api-docs/index.html index 2fcd4e4..dd9b196 100644 --- a/public/api-docs/index.html +++ b/public/api-docs/index.html @@ -1,52 +1,71 @@ - + - + +> + + - - diff --git a/routes/api.php b/routes/api.php index ed75cd4..e364ac5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,7 +4,6 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\CommonController; use App\Http\Controllers\Api\V1\ApiController; -use App\Http\Controllers\Api\V1\AdminApiController; use App\Http\Controllers\Api\V1\FileController; use App\Http\Controllers\Api\V1\ProductController; use App\Http\Controllers\Api\V1\MaterialController; @@ -13,6 +12,11 @@ use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\TenantController; use App\Http\Controllers\Api\V1\AdminController; +use App\Http\Controllers\Api\V1\MenuController; +use App\Http\Controllers\Api\V1\RoleController; +use App\Http\Controllers\Api\V1\RolePermissionController; +use App\Http\Controllers\Api\V1\UserRoleController; +use App\Http\Controllers\Api\V1\DepartmentController; // error test Route::get('/test-error', function () { @@ -33,10 +37,6 @@ Route::middleware('auth:sanctum')->post('/logout', [ApiController::class, 'logout']); - // Admin API - Route::post('admin/list', [AdminApiController::class, 'list'])->middleware('permission:SR'); // 관리자 리스트 조회 - - // Common API Route::prefix('common')->group(function () { Route::get('code', [CommonController::class, 'getComeCode'])->name('v1.common.code'); // 공통코드 조회 @@ -114,30 +114,66 @@ Route::resource('models', ModelController::class)->except(['v1.create', 'edit']); // 모델관리 Route::resource('boms', BomController::class)->except(['v1.create', 'edit']); // BOM관리 + + // Menu API + Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { + Route::get ('/', [MenuController::class, 'index'])->name('v1.menus.index'); + Route::get ('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); + Route::post ('/', [MenuController::class, 'store'])->name('v1.menus.store'); + Route::patch ('/{id}', [MenuController::class, 'update'])->name('v1.menus.update'); + Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy'); + Route::post ('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); + Route::post ('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); + }); + + + // Role API + Route::prefix('roles')->group(function () { + Route::get ('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view + Route::post ('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create + Route::get ('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view + Route::patch ('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update + Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy');// delete + }); + + + // Role Permission API + Route::prefix('roles/{id}/permissions')->group(function () { + Route::get ('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list + Route::post ('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); // grant + Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); // revoke + Route::put ('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync + }); + + + // User Role API + Route::prefix('users/{id}/roles')->group(function () { + Route::get ('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list + Route::post ('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant + Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke + Route::put ('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync + }); + + + // Department API + Route::prefix('departments')->group(function () { + Route::get ('', [DepartmentController::class, 'index'])->name('departments.index'); // 목록 + Route::post ('', [DepartmentController::class, 'store'])->name('departments.store'); // 생성 + Route::get ('/{id}', [DepartmentController::class, 'show'])->name('departments.show'); // 단건 + Route::patch ('/{id}', [DepartmentController::class, 'update'])->name('departments.update'); // 수정 + Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('departments.destroy'); // 삭제(soft) + + // 부서-사용자 + Route::get ('/{id}/users', [DepartmentController::class, 'listUsers'])->name('departments.users.index'); // 부서 사용자 목록 + Route::post ('/{id}/users', [DepartmentController::class, 'attachUser'])->name('departments.users.attach'); // 사용자 배정(주/부서) + Route::delete('/{id}/users/{user}', [DepartmentController::class, 'detachUser'])->name('departments.users.detach'); // 사용자 제거 + Route::patch ('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('departments.users.primary'); // 주부서 설정/해제 + + // 부서-권한 + 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'); // 권한 제거(해당 메뉴 범위까지) + }); + }); }); - - - -/* -|-------------------------------------------------------------------------- -| V1 - Admin 영역 (관리자 전용 사용자 관리) -|-------------------------------------------------------------------------- -| Swagger: AdminApi.php -| - GET /api/v1/admin/users -| - POST /api/v1/admin/users -| - GET /api/v1/admin/users/{id} -| - PUT /api/v1/admin/users/{id} -| - PATCH /api/v1/admin/users/{id}/status -| - DELETE /api/v1/admin/users/{id} -| - POST /api/v1/admin/users/{id}/restore -| - POST /api/v1/admin/users/{id}/roles -| - DELETE /api/v1/admin/users/{id}/roles/{role} -| - POST /api/v1/admin/users/{id}/reset-password -*/ -Route::prefix('v1_DEV/admin') - ->middleware(['apikey', 'auth:sanctum', 'can:admin']) // 예: 'can:admin' 또는 커스텀 'is_admin' - ->group(function () { - - - });