Files
sam-manage/app/Services/PermissionAnalyzeService.php
hskwon 1fc530bca2 feat(mng): 권한 분석 페이지 구현
- 메뉴별 권한 분석 기능 (접근 가능/불가 사용자 목록)
- 사용자 역추적 기능 (역할/부서/개인별 권한 추적)
- CSV 내보내기 기능
- 트리 구조 시각화 (└─ 연결선, 폴더/문서 아이콘)
- 중복 메뉴 표시 문제 해결 (테넌트별 메뉴만 표시)
2025-11-26 21:42:51 +09:00

526 lines
19 KiB
PHP

<?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;
}
}