feat(mng): 권한 분석 페이지 구현
- 메뉴별 권한 분석 기능 (접근 가능/불가 사용자 목록) - 사용자 역추적 기능 (역할/부서/개인별 권한 추적) - CSV 내보내기 기능 - 트리 구조 시각화 (└─ 연결선, 폴더/문서 아이콘) - 중복 메뉴 표시 문제 해결 (테넌트별 메뉴만 표시)
This commit is contained in:
142
app/Http/Controllers/Api/Admin/PermissionAnalyzeController.php
Normal file
142
app/Http/Controllers/Api/Admin/PermissionAnalyzeController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\PermissionAnalyzeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PermissionAnalyzeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PermissionAnalyzeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 메뉴 트리 조회 (HTMX용)
|
||||
*/
|
||||
public function menuTree(Request $request): JsonResponse|string
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$search = $request->input('search');
|
||||
|
||||
$menuTree = $this->service->getMenuTree($tenantId, $search);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$html = view('permission-analyze.partials.menu-tree', [
|
||||
'menuTree' => $menuTree,
|
||||
])->render();
|
||||
|
||||
return response()->json(['html' => $html]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $menuTree,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메뉴의 권한 분석
|
||||
*/
|
||||
public function analyzeMenu(Request $request): JsonResponse|string
|
||||
{
|
||||
$menuId = $request->input('menu_id');
|
||||
$permissionType = $request->input('permission_type', 'view');
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $menuId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '메뉴를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$analysis = $this->service->analyzeMenuPermission($menuId, $permissionType, $tenantId);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$html = view('permission-analyze.partials.analysis-result', [
|
||||
'analysis' => $analysis,
|
||||
'permissionType' => $permissionType,
|
||||
])->render();
|
||||
|
||||
return response()->json(['html' => $html]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $analysis,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역추적
|
||||
*/
|
||||
public function traceUsers(Request $request): JsonResponse|string
|
||||
{
|
||||
$menuId = $request->input('menu_id');
|
||||
$permissionType = $request->input('permission_type', 'view');
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $menuId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '메뉴를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$trace = $this->service->traceUsersWithPermission($menuId, $permissionType, $tenantId);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$html = view('permission-analyze.partials.trace-result', [
|
||||
'trace' => $trace,
|
||||
'permissionType' => $permissionType,
|
||||
])->render();
|
||||
|
||||
return response()->json(['html' => $html]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $trace,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기
|
||||
*/
|
||||
public function exportCsv(Request $request): Response
|
||||
{
|
||||
$menuId = $request->input('menu_id');
|
||||
$permissionType = $request->input('permission_type', 'view');
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $menuId) {
|
||||
return response('메뉴를 선택해주세요.', 400);
|
||||
}
|
||||
|
||||
$csv = $this->service->exportToCsv($menuId, $permissionType, $tenantId);
|
||||
|
||||
$filename = "permission_analysis_{$menuId}_{$permissionType}_".date('Ymd_His').'.csv';
|
||||
|
||||
return response($csv)
|
||||
->header('Content-Type', 'text/csv; charset=UTF-8')
|
||||
->header('Content-Disposition', "attachment; filename=\"{$filename}\"")
|
||||
->header('Content-Transfer-Encoding', 'binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 재계산 (캐시 무효화)
|
||||
*/
|
||||
public function recalculate(Request $request): JsonResponse
|
||||
{
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '권한이 재계산되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/PermissionAnalyzeController.php
Normal file
27
app/Http/Controllers/PermissionAnalyzeController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PermissionAnalyzeService;
|
||||
|
||||
class PermissionAnalyzeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PermissionAnalyzeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 권한 분석 페이지
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$menuTree = $this->service->getMenuTree($tenantId);
|
||||
$permissionTypes = $this->service->getPermissionTypes();
|
||||
|
||||
return view('permission-analyze.index', [
|
||||
'menuTree' => $menuTree,
|
||||
'permissionTypes' => $permissionTypes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
525
app/Services/PermissionAnalyzeService.php
Normal file
525
app/Services/PermissionAnalyzeService.php
Normal file
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PermissionAnalyzeService
|
||||
{
|
||||
/**
|
||||
* 권한 유형 목록
|
||||
*/
|
||||
private array $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage'];
|
||||
|
||||
/**
|
||||
* 메뉴 트리 조회 (권한 분석용)
|
||||
*
|
||||
* @param int|null $tenantId 테넌트 ID
|
||||
* @param string|null $search 검색어
|
||||
* @param bool $onlyAccessible 접근 가능한 메뉴만 필터링
|
||||
* @return Collection 메뉴 트리
|
||||
*/
|
||||
public function getMenuTree(?int $tenantId = null, ?string $search = null, bool $onlyAccessible = false): Collection
|
||||
{
|
||||
$query = Menu::with('parent')
|
||||
->where('is_active', 1);
|
||||
|
||||
if ($tenantId) {
|
||||
// 특정 테넌트: 해당 테넌트 메뉴만 (각 테넌트별로 메뉴가 복제되어 있음)
|
||||
$query->where('tenant_id', $tenantId);
|
||||
} else {
|
||||
// 전체 보기: 공통 메뉴만
|
||||
$query->whereNull('tenant_id');
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('url', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$allMenus = $query->orderBy('sort_order', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get();
|
||||
|
||||
return $this->flattenMenuTree($allMenus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
|
||||
*/
|
||||
private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): Collection
|
||||
{
|
||||
$result = collect();
|
||||
$filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order');
|
||||
|
||||
foreach ($filteredMenus as $menu) {
|
||||
$menu->depth = $depth;
|
||||
$menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0;
|
||||
$result->push($menu);
|
||||
|
||||
$children = $this->flattenMenuTree($menus, $menu->id, $depth + 1);
|
||||
$result = $result->merge($children);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메뉴의 권한 분석 (접근 가능/불가능 사용자 목록)
|
||||
*
|
||||
* @param int $menuId 메뉴 ID
|
||||
* @param string $permissionType 권한 유형 (view, create, update, delete, approve)
|
||||
* @param int|null $tenantId 테넌트 ID
|
||||
* @param string $guardName Guard 이름
|
||||
* @return array 분석 결과
|
||||
*/
|
||||
public function analyzeMenuPermission(int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
|
||||
{
|
||||
$menu = Menu::find($menuId);
|
||||
if (! $menu) {
|
||||
return ['error' => '메뉴를 찾을 수 없습니다.'];
|
||||
}
|
||||
|
||||
$permissionName = "menu:{$menuId}.{$permissionType}";
|
||||
|
||||
// 테넌트별 사용자 목록 조회
|
||||
$users = $this->getUsersByTenant($tenantId);
|
||||
|
||||
$accessAllowed = [];
|
||||
$accessDenied = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$analysis = $this->analyzeUserPermission($user->id, $menuId, $permissionType, $tenantId, $guardName);
|
||||
|
||||
$userInfo = [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'departments' => $this->getUserDepartments($user->id, $tenantId),
|
||||
'roles' => $this->getUserRoles($user->id, $tenantId),
|
||||
'personal_override' => $analysis['personal'],
|
||||
'source' => $analysis['source'],
|
||||
'effective' => $analysis['effective'],
|
||||
];
|
||||
|
||||
if ($analysis['effective'] === 'allow') {
|
||||
$accessAllowed[] = $userInfo;
|
||||
} else {
|
||||
$accessDenied[] = $userInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'menu' => [
|
||||
'id' => $menu->id,
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
],
|
||||
'permission_type' => $permissionType,
|
||||
'permission_rule' => 'ALLOW = 부서 OR 역할 OR (개인 ALLOW) → 개인 DENY 최우선',
|
||||
'access_allowed' => $accessAllowed,
|
||||
'access_denied' => $accessDenied,
|
||||
'summary' => [
|
||||
'total_users' => count($users),
|
||||
'allowed_count' => count($accessAllowed),
|
||||
'denied_count' => count($accessDenied),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 사용자의 특정 메뉴 권한 분석
|
||||
*
|
||||
* @param int $userId 사용자 ID
|
||||
* @param int $menuId 메뉴 ID
|
||||
* @param string $permissionType 권한 유형
|
||||
* @param int|null $tenantId 테넌트 ID
|
||||
* @param string $guardName Guard 이름
|
||||
* @return array 분석 결과
|
||||
*/
|
||||
public function analyzeUserPermission(int $userId, int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
|
||||
{
|
||||
$permissionName = "menu:{$menuId}.{$permissionType}";
|
||||
$now = now();
|
||||
|
||||
// 1. 역할 권한 확인
|
||||
$hasRolePermission = $this->checkRolePermission($userId, $permissionName, $guardName);
|
||||
|
||||
// 2. 부서 권한 확인
|
||||
$hasDeptPermission = $this->checkDepartmentPermission($userId, $permissionName, $tenantId, $guardName);
|
||||
|
||||
// 3. 개인 오버라이드 확인
|
||||
$personalOverride = $this->checkPersonalOverride($userId, $permissionName, $tenantId, $guardName);
|
||||
|
||||
// 최종 권한 계산 (AccessService와 동일한 우선순위)
|
||||
// 1) 개인 DENY → 거부
|
||||
// 2) 역할 권한 → 허용
|
||||
// 3) 부서 ALLOW → 허용
|
||||
// 4) 개인 ALLOW → 허용
|
||||
// 5) 기본 → 없음 (거부)
|
||||
|
||||
$effective = null;
|
||||
$source = null;
|
||||
|
||||
if ($personalOverride === 'deny') {
|
||||
$effective = 'deny';
|
||||
$source = 'personal';
|
||||
} elseif ($hasRolePermission) {
|
||||
$effective = 'allow';
|
||||
$source = 'role';
|
||||
} elseif ($hasDeptPermission) {
|
||||
$effective = 'allow';
|
||||
$source = 'department';
|
||||
} elseif ($personalOverride === 'allow') {
|
||||
$effective = 'allow';
|
||||
$source = 'personal';
|
||||
} else {
|
||||
$effective = 'deny';
|
||||
$source = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'effective' => $effective,
|
||||
'source' => $source,
|
||||
'role' => $hasRolePermission,
|
||||
'department' => $hasDeptPermission,
|
||||
'personal' => $personalOverride,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 권한 확인
|
||||
*/
|
||||
private function checkRolePermission(int $userId, string $permissionName, string $guardName): bool
|
||||
{
|
||||
return DB::table('model_has_roles as mhr')
|
||||
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
|
||||
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
|
||||
->where('mhr.model_type', User::class)
|
||||
->where('mhr.model_id', $userId)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 권한 확인
|
||||
*/
|
||||
private function checkDepartmentPermission(int $userId, string $permissionName, ?int $tenantId, string $guardName): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$query = DB::table('department_user as du')
|
||||
->join('permission_overrides as po', function ($j) use ($now) {
|
||||
$j->on('po.model_id', '=', 'du.department_id')
|
||||
->where('po.model_type', Department::class)
|
||||
->whereNull('po.deleted_at')
|
||||
->where('po.effect', 1)
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
|
||||
})
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
|
||||
});
|
||||
})
|
||||
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
||||
->whereNull('du.deleted_at')
|
||||
->where('du.user_id', $userId)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName);
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('du.tenant_id', $tenantId)
|
||||
->where('po.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개인 오버라이드 확인
|
||||
*/
|
||||
private function checkPersonalOverride(int $userId, string $permissionName, ?int $tenantId, string $guardName): ?string
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$query = DB::table('permission_overrides as po')
|
||||
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
||||
->select('po.effect')
|
||||
->where('po.model_type', User::class)
|
||||
->where('po.model_id', $userId)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->whereNull('po.deleted_at')
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
|
||||
})
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
|
||||
});
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('po.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$override = $query->first();
|
||||
|
||||
if (! $override) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $override->effect == 1 ? 'allow' : 'deny';
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 사용자 목록 조회
|
||||
*/
|
||||
private function getUsersByTenant(?int $tenantId): Collection
|
||||
{
|
||||
$query = User::where('is_active', true);
|
||||
|
||||
if ($tenantId) {
|
||||
$query->whereHas('tenants', function ($q) use ($tenantId) {
|
||||
$q->where('tenants.id', $tenantId)
|
||||
->where('user_tenants.is_active', true);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 부서 목록 조회
|
||||
*/
|
||||
private function getUserDepartments(int $userId, ?int $tenantId): array
|
||||
{
|
||||
$query = DB::table('department_user as du')
|
||||
->join('departments as d', 'd.id', '=', 'du.department_id')
|
||||
->select('d.id', 'd.name', 'd.code', 'du.is_primary')
|
||||
->where('du.user_id', $userId)
|
||||
->whereNull('du.deleted_at')
|
||||
->whereNull('d.deleted_at');
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('du.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->get()->map(function ($dept) {
|
||||
return [
|
||||
'id' => $dept->id,
|
||||
'name' => $dept->name,
|
||||
'code' => $dept->code,
|
||||
'is_primary' => $dept->is_primary,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 역할 목록 조회
|
||||
*/
|
||||
private function getUserRoles(int $userId, ?int $tenantId): array
|
||||
{
|
||||
$query = DB::table('user_roles as ur')
|
||||
->join('roles as r', 'r.id', '=', 'ur.role_id')
|
||||
->select('r.id', 'r.name', 'r.display_name')
|
||||
->where('ur.user_id', $userId)
|
||||
->whereNull('ur.deleted_at');
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('ur.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->get()->map(function ($role) {
|
||||
return [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'display_name' => $role->display_name,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역추적 - 특정 권한을 가진 모든 사용자 검색
|
||||
*
|
||||
* @param int $menuId 메뉴 ID
|
||||
* @param string $permissionType 권한 유형
|
||||
* @param int|null $tenantId 테넌트 ID
|
||||
* @param string $guardName Guard 이름
|
||||
* @return array 역추적 결과
|
||||
*/
|
||||
public function traceUsersWithPermission(int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
|
||||
{
|
||||
$permissionName = "menu:{$menuId}.{$permissionType}";
|
||||
|
||||
// 역할로 권한이 있는 사용자
|
||||
$usersFromRole = DB::table('model_has_roles as mhr')
|
||||
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
|
||||
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
|
||||
->join('users as u', 'u.id', '=', 'mhr.model_id')
|
||||
->join('roles as r', 'r.id', '=', 'mhr.role_id')
|
||||
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'r.id as role_id', 'r.name as role_name', 'r.display_name as role_display_name')
|
||||
->where('mhr.model_type', User::class)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->where('u.is_active', true)
|
||||
->get();
|
||||
|
||||
// 부서로 권한이 있는 사용자
|
||||
$now = now();
|
||||
$usersFromDepartment = DB::table('department_user as du')
|
||||
->join('permission_overrides as po', function ($j) use ($now) {
|
||||
$j->on('po.model_id', '=', 'du.department_id')
|
||||
->where('po.model_type', Department::class)
|
||||
->whereNull('po.deleted_at')
|
||||
->where('po.effect', 1)
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
|
||||
})
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
|
||||
});
|
||||
})
|
||||
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
||||
->join('users as u', 'u.id', '=', 'du.user_id')
|
||||
->join('departments as d', 'd.id', '=', 'du.department_id')
|
||||
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'd.id as department_id', 'd.name as department_name', 'd.code as department_code')
|
||||
->whereNull('du.deleted_at')
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->where('u.is_active', true);
|
||||
|
||||
if ($tenantId) {
|
||||
$usersFromDepartment->where('du.tenant_id', $tenantId)
|
||||
->where('po.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$usersFromDepartment = $usersFromDepartment->get();
|
||||
|
||||
// 개인 ALLOW 오버라이드가 있는 사용자
|
||||
$usersFromPersonal = DB::table('permission_overrides as po')
|
||||
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
||||
->join('users as u', 'u.id', '=', 'po.model_id')
|
||||
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'po.effect')
|
||||
->where('po.model_type', User::class)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->where('po.effect', 1)
|
||||
->whereNull('po.deleted_at')
|
||||
->where('u.is_active', true)
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
|
||||
})
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
|
||||
});
|
||||
|
||||
if ($tenantId) {
|
||||
$usersFromPersonal->where('po.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$usersFromPersonal = $usersFromPersonal->get();
|
||||
|
||||
// 개인 DENY 오버라이드가 있는 사용자
|
||||
$usersWithDeny = DB::table('permission_overrides as po')
|
||||
->join('permissions as p', 'p.id', '=', 'po.permission_id')
|
||||
->join('users as u', 'u.id', '=', 'po.model_id')
|
||||
->select('u.id as user_id', 'u.name as user_name', 'u.email')
|
||||
->where('po.model_type', User::class)
|
||||
->where('p.guard_name', $guardName)
|
||||
->where('p.name', $permissionName)
|
||||
->where('po.effect', 0)
|
||||
->whereNull('po.deleted_at')
|
||||
->where('u.is_active', true)
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
|
||||
})
|
||||
->where(function ($w) use ($now) {
|
||||
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
|
||||
});
|
||||
|
||||
if ($tenantId) {
|
||||
$usersWithDeny->where('po.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$usersWithDeny = $usersWithDeny->get();
|
||||
|
||||
return [
|
||||
'by_role' => $usersFromRole->map(function ($item) {
|
||||
return [
|
||||
'user_id' => $item->user_id,
|
||||
'user_name' => $item->user_name,
|
||||
'email' => $item->email,
|
||||
'role' => [
|
||||
'id' => $item->role_id,
|
||||
'name' => $item->role_name,
|
||||
'display_name' => $item->role_display_name,
|
||||
],
|
||||
];
|
||||
})->toArray(),
|
||||
'by_department' => $usersFromDepartment->map(function ($item) {
|
||||
return [
|
||||
'user_id' => $item->user_id,
|
||||
'user_name' => $item->user_name,
|
||||
'email' => $item->email,
|
||||
'department' => [
|
||||
'id' => $item->department_id,
|
||||
'name' => $item->department_name,
|
||||
'code' => $item->department_code,
|
||||
],
|
||||
];
|
||||
})->toArray(),
|
||||
'by_personal' => $usersFromPersonal->map(function ($item) {
|
||||
return [
|
||||
'user_id' => $item->user_id,
|
||||
'user_name' => $item->user_name,
|
||||
'email' => $item->email,
|
||||
];
|
||||
})->toArray(),
|
||||
'denied_users' => $usersWithDeny->map(function ($item) {
|
||||
return [
|
||||
'user_id' => $item->user_id,
|
||||
'user_name' => $item->user_name,
|
||||
'email' => $item->email,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 유형 목록 반환
|
||||
*/
|
||||
public function getPermissionTypes(): array
|
||||
{
|
||||
return $this->permissionTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기용 데이터 생성
|
||||
*/
|
||||
public function exportToCsv(int $menuId, string $permissionType = 'view', ?int $tenantId = null): string
|
||||
{
|
||||
$analysis = $this->analyzeMenuPermission($menuId, $permissionType, $tenantId);
|
||||
|
||||
$csv = "사용자,이메일,부서,역할,개인모드,최종,근거\n";
|
||||
|
||||
foreach ($analysis['access_allowed'] as $user) {
|
||||
$departments = collect($user['departments'])->pluck('name')->join(', ');
|
||||
$roles = collect($user['roles'])->pluck('display_name')->join(', ');
|
||||
$csv .= "\"{$user['name']}\",\"{$user['email']}\",\"{$departments}\",\"{$roles}\",\"{$user['personal_override']}\",\"허용\",\"{$user['source']}\"\n";
|
||||
}
|
||||
|
||||
foreach ($analysis['access_denied'] as $user) {
|
||||
$departments = collect($user['departments'])->pluck('name')->join(', ');
|
||||
$roles = collect($user['roles'])->pluck('display_name')->join(', ');
|
||||
$csv .= "\"{$user['name']}\",\"{$user['email']}\",\"{$departments}\",\"{$roles}\",\"{$user['personal_override']}\",\"거부\",\"{$user['source']}\"\n";
|
||||
}
|
||||
|
||||
return $csv;
|
||||
}
|
||||
}
|
||||
@@ -132,8 +132,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||
<a href="{{ route('permission-analyze.index') }}"
|
||||
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('permission-analyze.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
|
||||
style="padding-left: 2rem;">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
|
||||
285
resources/views/permission-analyze/index.blade.php
Normal file
285
resources/views/permission-analyze/index.blade.php
Normal file
@@ -0,0 +1,285 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '권한 분석')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">권한 분석</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
hx-post="/api/admin/permission-analyze/recalculate"
|
||||
hx-swap="none"
|
||||
onclick="alert('권한이 재계산되었습니다.')"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
권한 재계산
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="exportCsvBtn"
|
||||
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
onclick="exportCsv()"
|
||||
disabled
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
CSV 내보내기
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 권한 유형 선택 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">권한 유형:</label>
|
||||
<select
|
||||
id="permissionType"
|
||||
name="permission_type"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onchange="onPermissionTypeChange()"
|
||||
>
|
||||
@foreach($permissionTypes as $type)
|
||||
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 권한 규칙 표시 -->
|
||||
<div class="flex-1 text-right">
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
|
||||
권한 규칙: ALLOW = 부서 OR 역할 OR (개인 ALLOW) → 개인 DENY 최우선
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단 레이아웃 (고정 높이, 좌우 분할) -->
|
||||
<div class="flex gap-6" style="height: calc(100vh - 220px);">
|
||||
<!-- 좌측: 메뉴 트리 (고정 너비) -->
|
||||
<div class="w-80 flex-shrink-0">
|
||||
<div class="bg-white rounded-lg shadow-sm h-full flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">메뉴 트리</h2>
|
||||
<!-- 검색 -->
|
||||
<input
|
||||
type="text"
|
||||
id="menuSearch"
|
||||
placeholder="메뉴 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
onkeyup="searchMenu(this.value)"
|
||||
>
|
||||
</div>
|
||||
<div id="menu-tree" class="p-2 flex-1 overflow-y-auto">
|
||||
@include('permission-analyze.partials.menu-tree', ['menuTree' => $menuTree])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: 분석 결과 (나머지 공간) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-white rounded-lg shadow-sm h-full flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800" id="selectedMenuTitle">선택된 메뉴</h2>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="border-b border-gray-200 flex-shrink-0">
|
||||
<nav class="flex -mb-px" id="analysisTabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600"
|
||||
data-tab="access-allowed"
|
||||
onclick="switchTab('access-allowed')"
|
||||
>
|
||||
접근 가능
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
data-tab="access-denied"
|
||||
onclick="switchTab('access-denied')"
|
||||
>
|
||||
접근 불가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
data-tab="trace"
|
||||
onclick="switchTab('trace')"
|
||||
>
|
||||
사용자 역추적
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 탭 콘텐츠 -->
|
||||
<div id="analysis-content" class="p-4 flex-1 overflow-y-auto">
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p>좌측에서 분석할 메뉴를 선택해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="selectedMenuId" value="">
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
let currentTab = 'access-allowed';
|
||||
|
||||
// 메뉴 선택
|
||||
function selectMenu(menuId, menuName) {
|
||||
// 모든 메뉴 항목의 선택 상태 제거
|
||||
document.querySelectorAll('.menu-item').forEach(item => {
|
||||
item.classList.remove('bg-blue-100', 'border-blue-500');
|
||||
item.classList.add('hover:bg-gray-50');
|
||||
});
|
||||
|
||||
// 선택된 메뉴 항목 활성화
|
||||
const selectedItem = document.querySelector(`[data-menu-id="${menuId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('bg-blue-100', 'border-blue-500');
|
||||
selectedItem.classList.remove('hover:bg-gray-50');
|
||||
}
|
||||
|
||||
// 상태 저장
|
||||
document.getElementById('selectedMenuId').value = menuId;
|
||||
document.getElementById('selectedMenuTitle').textContent = menuName;
|
||||
|
||||
// CSV 버튼 활성화
|
||||
document.getElementById('exportCsvBtn').disabled = false;
|
||||
|
||||
// 분석 결과 로드
|
||||
loadAnalysis();
|
||||
}
|
||||
|
||||
// 분석 결과 로드
|
||||
function loadAnalysis() {
|
||||
const menuId = document.getElementById('selectedMenuId').value;
|
||||
const permissionType = document.getElementById('permissionType').value;
|
||||
|
||||
if (!menuId) return;
|
||||
|
||||
if (currentTab === 'trace') {
|
||||
loadTrace();
|
||||
} else {
|
||||
fetch(`/api/admin/permission-analyze/analyze?menu_id=${menuId}&permission_type=${permissionType}`, {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.html) {
|
||||
document.getElementById('analysis-content').innerHTML = data.html;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 역추적 결과 로드
|
||||
function loadTrace() {
|
||||
const menuId = document.getElementById('selectedMenuId').value;
|
||||
const permissionType = document.getElementById('permissionType').value;
|
||||
|
||||
if (!menuId) return;
|
||||
|
||||
fetch(`/api/admin/permission-analyze/trace?menu_id=${menuId}&permission_type=${permissionType}`, {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.html) {
|
||||
document.getElementById('analysis-content').innerHTML = data.html;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 탭 전환
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// 탭 버튼 스타일 변경
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-blue-500', 'text-blue-600');
|
||||
btn.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
const activeBtn = document.querySelector(`[data-tab="${tab}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('border-transparent', 'text-gray-500');
|
||||
activeBtn.classList.add('border-blue-500', 'text-blue-600');
|
||||
}
|
||||
|
||||
// 분석 결과 로드
|
||||
loadAnalysis();
|
||||
}
|
||||
|
||||
// 권한 유형 변경
|
||||
function onPermissionTypeChange() {
|
||||
loadAnalysis();
|
||||
}
|
||||
|
||||
// 메뉴 검색
|
||||
let searchTimeout;
|
||||
function searchMenu(query) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const items = document.querySelectorAll('.menu-item');
|
||||
items.forEach(item => {
|
||||
const name = item.getAttribute('data-menu-name').toLowerCase();
|
||||
if (query === '' || name.includes(query.toLowerCase())) {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// CSV 내보내기
|
||||
function exportCsv() {
|
||||
const menuId = document.getElementById('selectedMenuId').value;
|
||||
const permissionType = document.getElementById('permissionType').value;
|
||||
|
||||
if (!menuId) {
|
||||
alert('메뉴를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/api/admin/permission-analyze/export-csv?menu_id=${menuId}&permission_type=${permissionType}`;
|
||||
}
|
||||
|
||||
// HTMX 응답 처리
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'menu-tree') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.html) {
|
||||
event.detail.target.innerHTML = response.html;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,207 @@
|
||||
@php
|
||||
$activeTab = request('tab', 'access-allowed');
|
||||
@endphp
|
||||
|
||||
@if(isset($analysis['error']))
|
||||
<div class="text-center py-12 text-red-500">
|
||||
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p>{{ $analysis['error'] }}</p>
|
||||
</div>
|
||||
@else
|
||||
<!-- 요약 정보 -->
|
||||
<div class="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
전체 사용자: <span class="font-bold text-gray-900">{{ $analysis['summary']['total_users'] ?? 0 }}</span>명
|
||||
</span>
|
||||
<span class="text-sm text-gray-600">
|
||||
접근 가능: <span class="font-bold text-green-600">{{ $analysis['summary']['allowed_count'] ?? 0 }}</span>명
|
||||
</span>
|
||||
<span class="text-sm text-gray-600">
|
||||
접근 불가: <span class="font-bold text-red-600">{{ $analysis['summary']['denied_count'] ?? 0 }}</span>명
|
||||
</span>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
|
||||
{{ strtoupper($permissionType) }} 권한
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 접근 가능 탭 콘텐츠 -->
|
||||
<div id="tab-access-allowed" class="{{ $activeTab !== 'access-allowed' ? 'hidden' : '' }}">
|
||||
@if(count($analysis['access_allowed'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
|
||||
<th class="px-4 py-2 text-center font-medium text-gray-700">개인 설정</th>
|
||||
<th class="px-4 py-2 text-center font-medium text-gray-700">근거</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($analysis['access_allowed'] as $user)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if(count($user['departments']) > 0)
|
||||
@foreach($user['departments'] as $dept)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded mr-1 mb-1">
|
||||
{{ $dept['name'] }}
|
||||
@if($dept['is_primary'])
|
||||
<span class="ml-1 text-blue-600">*</span>
|
||||
@endif
|
||||
</span>
|
||||
@endforeach
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if(count($user['roles']) > 0)
|
||||
@foreach($user['roles'] as $role)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded mr-1 mb-1">
|
||||
{{ $role['display_name'] ?? $role['name'] }}
|
||||
</span>
|
||||
@endforeach
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($user['personal_override'] === 'allow')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded">
|
||||
ALLOW
|
||||
</span>
|
||||
@elseif($user['personal_override'] === 'deny')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
|
||||
DENY
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($user['source'] === 'role')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded">
|
||||
역할
|
||||
</span>
|
||||
@elseif($user['source'] === 'department')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded">
|
||||
부서
|
||||
</span>
|
||||
@elseif($user['source'] === 'personal')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">
|
||||
개인
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>접근 가능한 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 접근 불가 탭 콘텐츠 -->
|
||||
<div id="tab-access-denied" class="{{ $activeTab !== 'access-denied' ? 'hidden' : '' }}">
|
||||
@if(count($analysis['access_denied'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
|
||||
<th class="px-4 py-2 text-center font-medium text-gray-700">개인 설정</th>
|
||||
<th class="px-4 py-2 text-center font-medium text-gray-700">거부 사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($analysis['access_denied'] as $user)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if(count($user['departments']) > 0)
|
||||
@foreach($user['departments'] as $dept)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded mr-1 mb-1">
|
||||
{{ $dept['name'] }}
|
||||
</span>
|
||||
@endforeach
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if(count($user['roles']) > 0)
|
||||
@foreach($user['roles'] as $role)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded mr-1 mb-1">
|
||||
{{ $role['display_name'] ?? $role['name'] }}
|
||||
</span>
|
||||
@endforeach
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($user['personal_override'] === 'deny')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
|
||||
DENY
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($user['source'] === 'personal')
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
|
||||
개인 DENY
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded">
|
||||
권한 없음
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>접근 불가 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// 현재 탭에 따라 콘텐츠 표시
|
||||
document.querySelectorAll('[id^="tab-"]').forEach(tab => {
|
||||
tab.classList.add('hidden');
|
||||
});
|
||||
|
||||
const currentTabContent = document.getElementById('tab-' + (typeof currentTab !== 'undefined' ? currentTab : 'access-allowed'));
|
||||
if (currentTabContent) {
|
||||
currentTabContent.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
@forelse($menuTree as $menu)
|
||||
<div
|
||||
class="menu-item flex items-center gap-2 py-2 rounded-lg border border-transparent cursor-pointer transition-colors hover:bg-gray-50 {{ $menu->depth > 0 ? 'ml-4 border-l-2 border-gray-200' : '' }}"
|
||||
data-menu-id="{{ $menu->id }}"
|
||||
data-menu-name="{{ $menu->name }}"
|
||||
onclick="selectMenu({{ $menu->id }}, '{{ addslashes($menu->name) }}')"
|
||||
style="padding-left: {{ ($menu->depth * 1.5) + 0.75 }}rem;"
|
||||
>
|
||||
{{-- 트리 구조 표시 --}}
|
||||
@if($menu->depth > 0)
|
||||
<span class="text-gray-300 text-xs font-mono flex-shrink-0">└─</span>
|
||||
@endif
|
||||
|
||||
{{-- 폴더/아이템 아이콘 --}}
|
||||
@if($menu->has_children)
|
||||
<svg class="w-4 h-4 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@endif
|
||||
|
||||
{{-- 메뉴 정보 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm {{ $menu->depth === 0 ? 'font-semibold text-gray-900' : 'font-medium text-gray-700' }} truncate">
|
||||
{{ $menu->name }}
|
||||
</span>
|
||||
@if($menu->url)
|
||||
<span class="text-xs text-gray-400 truncate hidden sm:inline">{{ $menu->url }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 메뉴 ID --}}
|
||||
<span class="text-xs text-gray-400 font-mono flex-shrink-0">ID:{{ $menu->id }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>메뉴가 없습니다.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
@@ -0,0 +1,163 @@
|
||||
<!-- 역추적 결과 -->
|
||||
<div class="space-y-6">
|
||||
<!-- 역할로 권한 부여된 사용자 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-purple-100 text-purple-800 rounded-full text-xs font-bold">
|
||||
{{ count($trace['by_role'] ?? []) }}
|
||||
</span>
|
||||
역할로 권한 부여된 사용자
|
||||
</h3>
|
||||
@if(count($trace['by_role'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead class="bg-purple-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($trace['by_role'] as $item)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded">
|
||||
{{ $item['role']['display_name'] ?? $item['role']['name'] }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm">역할로 권한 부여된 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 부서로 권한 부여된 사용자 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-800 rounded-full text-xs font-bold">
|
||||
{{ count($trace['by_department'] ?? []) }}
|
||||
</span>
|
||||
부서로 권한 부여된 사용자
|
||||
</h3>
|
||||
@if(count($trace['by_department'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($trace['by_department'] as $item)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded">
|
||||
{{ $item['department']['name'] }}
|
||||
@if($item['department']['code'])
|
||||
<span class="ml-1 text-blue-600">({{ $item['department']['code'] }})</span>
|
||||
@endif
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm">부서로 권한 부여된 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 개인 ALLOW로 권한 부여된 사용자 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-800 rounded-full text-xs font-bold">
|
||||
{{ count($trace['by_personal'] ?? []) }}
|
||||
</span>
|
||||
개인 ALLOW로 권한 부여된 사용자
|
||||
</h3>
|
||||
@if(count($trace['by_personal'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead class="bg-green-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">이메일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($trace['by_personal'] as $item)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-gray-500">{{ $item['email'] }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm">개인 ALLOW로 권한 부여된 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 개인 DENY로 거부된 사용자 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-red-100 text-red-800 rounded-full text-xs font-bold">
|
||||
{{ count($trace['denied_users'] ?? []) }}
|
||||
</span>
|
||||
개인 DENY로 거부된 사용자
|
||||
</h3>
|
||||
@if(count($trace['denied_users'] ?? []) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead class="bg-red-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-700">이메일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach($trace['denied_users'] as $item)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-gray-500">{{ $item['email'] }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm">개인 DENY로 거부된 사용자가 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,4 +124,13 @@
|
||||
Route::post('/deny-all', [\App\Http\Controllers\Api\Admin\UserPermissionController::class, 'denyAll'])->name('denyAll');
|
||||
Route::post('/reset', [\App\Http\Controllers\Api\Admin\UserPermissionController::class, 'reset'])->name('reset');
|
||||
});
|
||||
|
||||
// 권한 분석 API
|
||||
Route::prefix('permission-analyze')->name('permission-analyze.')->group(function () {
|
||||
Route::get('/menu-tree', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'menuTree'])->name('menuTree');
|
||||
Route::get('/analyze', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'analyzeMenu'])->name('analyze');
|
||||
Route::get('/trace', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'traceUsers'])->name('trace');
|
||||
Route::get('/export-csv', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'exportCsv'])->name('exportCsv');
|
||||
Route::post('/recalculate', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'recalculate'])->name('recalculate');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
// 개인 권한 관리 (Blade 화면만)
|
||||
Route::get('/user-permissions', [\App\Http\Controllers\UserPermissionController::class, 'index'])->name('user-permissions.index');
|
||||
|
||||
// 권한 분석 (Blade 화면만)
|
||||
Route::get('/permission-analyze', [\App\Http\Controllers\PermissionAnalyzeController::class, 'index'])->name('permission-analyze.index');
|
||||
|
||||
// 대시보드
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard.index');
|
||||
|
||||
Reference in New Issue
Block a user