Files
sam-manage/app/Services/PermissionAnalyzeService.php
hskwon fd3d3b5448 fix: permission-analyze에서 user_roles 테이블 조회 추가
- checkRolePermission(): user_roles 테이블 쿼리 추가
- traceUsersWithPermission(): user_roles 기반 역할 사용자 조회 추가
- getUserRoles(): model_has_roles + user_roles 통합 조회로 변경
- 중복 제거 및 결과 병합 처리
2025-12-09 20:30:33 +09:00

609 lines
23 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 = [];
$explicitDeny = [];
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;
} elseif ($analysis['personal'] === 'deny') {
// 명시적 DENY만 별도 목록에 추가
$explicitDeny[] = $userInfo;
}
// 권한이 없는 사용자(no permission)는 목록에 포함하지 않음
}
return [
'menu' => [
'id' => $menu->id,
'name' => $menu->name,
'url' => $menu->url,
],
'permission_type' => $permissionType,
'permission_rule' => 'ALLOW = 부서 OR 역할 OR (개인 ALLOW) → 개인 DENY 최우선',
'access_allowed' => $accessAllowed,
'explicit_deny' => $explicitDeny,
'summary' => [
'total_users' => count($users),
'allowed_count' => count($accessAllowed),
'explicit_deny_count' => count($explicitDeny),
],
];
}
/**
* 특정 사용자의 특정 메뉴 권한 분석
*
* @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, $tenantId);
// 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,
];
}
/**
* 역할 권한 확인 (model_has_roles + user_roles)
*/
private function checkRolePermission(int $userId, string $permissionName, string $guardName, ?int $tenantId = null): bool
{
// 1. Spatie model_has_roles 테이블에서 확인
$hasSpatiePermission = 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();
if ($hasSpatiePermission) {
return true;
}
// 2. user_roles 테이블에서 확인
$userRolesQuery = DB::table('user_roles as ur')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'ur.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->where('p.guard_name', $guardName)
->where('p.name', $permissionName);
if ($tenantId) {
$userRolesQuery->where('ur.tenant_id', $tenantId);
}
return $userRolesQuery->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 (! auth()->user()?->is_super_admin) {
$query->where('is_super_admin', false);
}
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();
}
/**
* 사용자의 역할 목록 조회 (model_has_roles + user_roles)
*/
private function getUserRoles(int $userId, ?int $tenantId): array
{
// 1. Spatie model_has_roles 테이블에서 조회
$spatieQuery = DB::table('model_has_roles as mhr')
->join('roles as r', 'r.id', '=', 'mhr.role_id')
->select('r.id', 'r.name', 'r.description')
->where('mhr.model_type', User::class)
->where('mhr.model_id', $userId);
if ($tenantId) {
$spatieQuery->where('mhr.tenant_id', $tenantId);
}
$spatieRoles = $spatieQuery->get();
// 2. user_roles 테이블에서 조회
$userRolesQuery = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->select('r.id', 'r.name', 'r.description')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at');
if ($tenantId) {
$userRolesQuery->where('ur.tenant_id', $tenantId);
}
$userRoles = $userRolesQuery->get();
// 3. 두 결과 합치기 (중복 제거)
return $spatieRoles->merge($userRoles)->unique('id')->map(function ($role) {
return [
'id' => $role->id,
'name' => $role->name,
'display_name' => $role->description,
];
})->values()->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}";
$excludeSuperAdmin = ! auth()->user()?->is_super_admin;
// 역할로 권한이 있는 사용자 (model_has_roles)
$usersFromSpatieRoleQuery = 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.description as role_display_name')
->where('mhr.model_type', User::class)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('u.is_active', true);
if ($excludeSuperAdmin) {
$usersFromSpatieRoleQuery->where('u.is_super_admin', false);
}
// 역할로 권한이 있는 사용자 (user_roles)
$usersFromUserRolesQuery = DB::table('user_roles as ur')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'ur.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->join('users as u', 'u.id', '=', 'ur.user_id')
->join('roles as r', 'r.id', '=', 'ur.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.description as role_display_name')
->whereNull('ur.deleted_at')
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('u.is_active', true);
if ($excludeSuperAdmin) {
$usersFromUserRolesQuery->where('u.is_super_admin', false);
}
if ($tenantId) {
$usersFromUserRolesQuery->where('ur.tenant_id', $tenantId);
}
// 두 쿼리 결과 합치기
$usersFromRole = $usersFromSpatieRoleQuery->get()->merge($usersFromUserRolesQuery->get())->unique('user_id');
// 부서로 권한이 있는 사용자
$now = now();
$usersFromDepartmentQuery = 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 ($excludeSuperAdmin) {
$usersFromDepartmentQuery->where('u.is_super_admin', false);
}
if ($tenantId) {
$usersFromDepartmentQuery->where('du.tenant_id', $tenantId)
->where('po.tenant_id', $tenantId);
}
$usersFromDepartment = $usersFromDepartmentQuery->get();
// 개인 ALLOW 오버라이드가 있는 사용자
$usersFromPersonalQuery = 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 ($excludeSuperAdmin) {
$usersFromPersonalQuery->where('u.is_super_admin', false);
}
if ($tenantId) {
$usersFromPersonalQuery->where('po.tenant_id', $tenantId);
}
$usersFromPersonal = $usersFromPersonalQuery->get();
// 개인 DENY 오버라이드가 있는 사용자
$usersWithDenyQuery = 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 ($excludeSuperAdmin) {
$usersWithDenyQuery->where('u.is_super_admin', false);
}
if ($tenantId) {
$usersWithDenyQuery->where('po.tenant_id', $tenantId);
}
$usersWithDeny = $usersWithDenyQuery->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['explicit_deny'] 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']}\",\"명시적 DENY\",\"{$user['source']}\"\n";
}
return $csv;
}
}