Files
Gnuboard7/app/Http/Middleware/PermissionMiddleware.php
T
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

197 lines
7.3 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Models\Role;
use App\Support\GuestRoleResolver;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
/**
* 특정 권한을 가진 사용자 또는 비회원(guest)의 접근을 허용합니다.
*
* 사용법:
* - permission:admin,core.users.read (admin 타입의 권한 체크)
* - permission:user,core.users.read (user 타입의 권한 체크)
* - permission:admin,core.users.read|core.users.create (여러 권한, 기본 AND)
* - permission:admin,core.users.read|core.users.create,false (여러 권한, OR 조건)
* - permission:user,sirsoft-board.{slug}.posts.create (동적 파라미터 사용)
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @param string $type 권한 타입 (admin 또는 user)
* @param string $permission 필요한 권한명 (파이프로 여러 권한 구분)
* @param string|null $requireAll 여러 권한시 모두 필요한지 여부 ('true' 또는 'false')
* @return Response HTTP 응답
*/
public function handle(Request $request, Closure $next, string $type, string $permission, ?string $requireAll = null): Response
{
// Step 1: 권한 타입 유효성 검증
if (! PermissionType::isValid($type)) {
return ResponseHelper::forbidden('auth.invalid_permission_type', [
'type' => $type,
'valid_types' => implode(', ', array_map(fn ($case) => $case->value, PermissionType::cases())),
]);
}
$permissionType = PermissionType::from($type);
// Step 2: 권한 식별자 동적 파라미터 치환
$permissions = explode('|', $permission);
$resolvedPermissions = array_map(
fn ($perm) => $this->resolvePermissionIdentifier($perm, $request),
$permissions
);
// Step 3: 권한 확인
$user = $request->user();
if ($user) {
$hasPermission = count($resolvedPermissions) === 1
? $user->hasPermission($resolvedPermissions[0], $permissionType)
: $user->hasPermissions($resolvedPermissions, filter_var($requireAll ?? 'true', FILTER_VALIDATE_BOOLEAN), $permissionType);
if (! $hasPermission) {
return ResponseHelper::forbidden('auth.permission_denied', [
'required_permissions' => implode(', ', $resolvedPermissions),
]);
}
} else {
$hasPermission = count($resolvedPermissions) === 1
? $this->checkGuestPermission($resolvedPermissions[0], $permissionType)
: collect($resolvedPermissions)->contains(fn ($perm) => $this->checkGuestPermission($perm, $permissionType));
if (! $hasPermission) {
return ResponseHelper::unauthorized('auth.guest_permission_denied', [
'required_permissions' => implode(', ', $resolvedPermissions),
]);
}
}
// Step 4: scope_type 스코프 체크 (인증된 사용자 + 상세 엔드포인트만)
if ($user) {
foreach ($resolvedPermissions as $identifier) {
$resourceRouteKey = PermissionHelper::getResourceRouteKey($identifier);
// resource_route_key가 없으면 스코프 체크 불필요
if (! $resourceRouteKey) {
continue;
}
// 라우트에서 모델 resolve
$model = $request->route($resourceRouteKey);
// 모델이 없으면 목록 엔드포인트 → 스코프 체크 스킵
if (! $model instanceof Model) {
continue;
}
// 스코프 접근 체크
if (! PermissionHelper::checkScopeAccess($model, $identifier, $user)) {
return ResponseHelper::forbidden('auth.scope_denied');
}
}
}
return $next($request);
}
/**
* 비회원(guest) role의 권한을 확인합니다.
*
* @param string $permission 권한 식별자
* @param PermissionType $type 권한 타입
* @return bool 권한 존재 여부
*/
protected function checkGuestPermission(string $permission, PermissionType $type): bool
{
$guestRole = $this->getGuestRole();
if (! $guestRole) {
return false;
}
// 적재해 둔 권한 컬렉션에서 판정한다. permissions() 로 빌더를 다시 열면
// 캐시해 둔 의미가 사라져 판정마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($permission, $type);
}
/**
* guest role을 캐싱하여 조회합니다.
*
* @return Role|null guest 역할
*/
protected function getGuestRole(): ?Role
{
return GuestRoleResolver::resolve();
}
/**
* guest role 캐시를 무효화합니다.
*
* RolePermissionSeeder 실행 후 자동 호출 — 시드 직후 권한 변경이 즉시 반영되도록 보장.
* 운영 환경에서는 권한 재시드 (코어 업데이트 / 확장 install) 시점에 캐시 정합 유지.
* 테스트 환경에서는 RefreshDatabase 트랜잭션 rollback 후 새 시드의 stale id 회귀 방지.
*
* @return void
*/
public static function clearGuestRoleCache(): void
{
GuestRoleResolver::flush();
}
/**
* 권한 식별자의 동적 파라미터를 URL 파라미터 값으로 치환합니다.
*
* 예시: sirsoft-board.{slug}.posts.create + {slug: 'notice'}
* → sirsoft-board.notice.posts.create
*
* @param string $permission 권한 식별자 (예: sirsoft-board.{slug}.posts.create)
* @param Request $request HTTP 요청 객체
* @return string 치환된 권한 식별자
*/
protected function resolvePermissionIdentifier(string $permission, Request $request): string
{
// {slug}, {id} 등의 플레이스홀더 찾기
preg_match_all('/\{(\w+)\}/', $permission, $matches);
if (empty($matches[1])) {
return $permission; // 동적 파라미터 없음
}
$routeParams = $request->route()->parameters();
$resolvedPermission = $permission;
foreach ($matches[1] as $paramName) {
if (! isset($routeParams[$paramName])) {
// URL 파라미터가 없는 경우 경고 로그
Log::warning('Permission parameter not found in route', [
'permission' => $permission,
'param' => $paramName,
'route' => $request->route()->getName(),
'available_params' => array_keys($routeParams),
]);
// 원본 반환 (권한 체크 실패로 이어짐)
return $permission;
}
$resolvedPermission = str_replace(
"{{$paramName}}",
$routeParams[$paramName],
$resolvedPermission
);
}
return $resolvedPermission;
}
}