fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의 하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로 통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가 서로 다른 강도로 검증하던 부분도 맞췄다. 계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고, 검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다. 설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해 게시판 설정이 실제로 적용되게 했다. 쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로 새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동). 재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다. 공개 이슈: gnuboard/g7
This commit is contained in:
@@ -1199,6 +1199,8 @@ Content-Type: application/json
|
||||
|
||||
기존 메뉴 정보를 수정한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, 전달된 필드만 갱신한다. `slug` 는 여전히 유일해야 하고, `roles` 배열을 보내면 이 메뉴 노출이 허용된 역할 목록을 교체한다. 성공 시 갱신된 메뉴를 관계 eager-load 포함해 `MenuResource` 로 반환하고, 실패 시 `menu.update_failed`(422 검증 오류 포함)를 반환한다. 관리자 메뉴 관리 화면의 메뉴 편집 폼 저장에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.update`.
|
||||
|
||||
`parent_id` 에는 자기 자신이나 자신의 하위 메뉴를 지정할 수 없다. 순환이 만들어지는 요청은 422(`parent_id`)로 거절되며, 순서 변경 엔드포인트(`PUT /api/admin/menus/order`)와 동일한 기준이 적용된다.
|
||||
|
||||
|
||||
### PATCH /api/admin/menus/{menu}/toggle-status
|
||||
<!-- @generated:start:api.admin.menus.toggle-status -->
|
||||
|
||||
@@ -954,7 +954,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 검증된 값을 우선 사용하되, PluginManager 에 등록되지 않아 검증 규칙이 없는 플러그인의 경우 요청 본문 전체(`all()`)를 저장합니다. 저장 실패 시 500 을 반환하고, 성공 시 갱신된 설정 값을 함께 반환합니다.
|
||||
**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다.
|
||||
|
||||
|
||||
### GET /api/admin/plugins/{identifier}/settings/layout
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
- [Repository 클래스](#repository-클래스)
|
||||
- [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters)
|
||||
- [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩)
|
||||
- [중첩 리소스 스코프](#중첩-리소스-스코프)
|
||||
- [설정 기반 한계값](#설정-기반-한계값)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
@@ -1066,6 +1068,62 @@ class ProductService
|
||||
|
||||
---
|
||||
|
||||
## 중첩 리소스 스코프
|
||||
|
||||
라우트에 상위 리소스 ID 가 있는 중첩 엔드포인트(`/posts/{postId}/comments/{id}`,
|
||||
`/products/{productId}/images/{imageId}` 등)에서는 **Repository 의 where 절이 스코프의
|
||||
SSoT** 다. 컨트롤러에서 조회 후 `if ($comment->post_id !== $postId)` 로 비교하는 방식은
|
||||
신규 호출처가 생기면 그대로 무력화된다.
|
||||
|
||||
```php
|
||||
// ❌ 게시판 범위로만 조회 — 다른 게시글의 댓글도 잡힌다
|
||||
public function findOrFail(string $slug, int $id): Comment
|
||||
|
||||
// ✅ 상위 스코프를 선택 파라미터로 받아 where 절에 반영
|
||||
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
|
||||
{
|
||||
return Comment::query()
|
||||
->where('board_id', $board->id)
|
||||
->when($postId !== null, fn ($q) => $q->where('post_id', $postId))
|
||||
->findOrFail($id);
|
||||
}
|
||||
```
|
||||
|
||||
### 규율
|
||||
|
||||
| 항목 | 규칙 |
|
||||
| --- | --- |
|
||||
| 방어 위치 | Repository where 절 (SSoT) + Service 가 상위 ID 를 전달 |
|
||||
| 파라미터 형태 | **선택 파라미터**(`?int $postId = null`) — 필수화는 Interface breaking 이라 이 확장에 의존하는 확장을 전부 흔든다 |
|
||||
| 전달 누락 방지 | 컨트롤러가 라우트 파라미터를 실제로 소비하는지 audit 룰 `controller-unused-route-param` (warn) 이 감시 |
|
||||
| 응답 코드 | 조회 실패이므로 404. 요청 본문 배열 항목의 스코프 위반은 422 (validation.md "배열 항목의 상위 스코프") |
|
||||
| 공통 추상화 | 도입하지 않는다 — 모듈마다 상위 키와 조회 경로가 다르고(`board_id`+`post_id` / `product_id` / `page_id` / `order_id`), 코어 표면이 늘면 확장 버전 제약 동기화가 연쇄된다 |
|
||||
|
||||
---
|
||||
|
||||
## 설정 기반 한계값
|
||||
|
||||
설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 **검증 책임은 검증 계층 단일**이다.
|
||||
Service 에서 리터럴로 다시 클램프하지 않는다.
|
||||
|
||||
```php
|
||||
// ❌ Service 재클램프 — 설정값이 10 이어도 5 를 넘지 못한다
|
||||
$data['depth'] = min(($parent->depth ?? 0) + 1, 5);
|
||||
|
||||
// ✅ 계산만 하고, 상한 검증은 Rule 이 게시판 설정으로 판정
|
||||
$data['depth'] = ($parent->depth ?? 0) + 1;
|
||||
```
|
||||
|
||||
이중 클램프는 두 가지를 동시에 망가뜨린다. 저장값이 설정과 무관하게 고정되고(계층 표시 붕괴),
|
||||
검증 계층의 `depth + 1 > max` 조건이 영원히 거짓이 되어 **깊이 제한 자체가 무력화**된다.
|
||||
값이 상한을 넘으면 조용히 깎지 말고 422 로 거절해야 한다.
|
||||
|
||||
이 패턴은 리터럴과 설정 키를 잇는 의미 추론이 필요해 정적 검출이 어렵다
|
||||
(`coverage.json` 의 `service-hardcoded-limit-vs-settings` = `not-applicable`).
|
||||
코드 리뷰에서 리터럴 상한을 볼 때마다 "이 값을 정하는 설정이 따로 있는가" 를 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
- [사용자 입력 다국어 필드 정규화 (prepareForValidation)](#사용자-입력-다국어-필드-정규화-prepareforvalidation)
|
||||
- [동적 스키마 기반 FormRequest 패턴](#동적-스키마-기반-formrequest-패턴)
|
||||
- [exists/unique 검증 규칙](#existsunique-검증-규칙)
|
||||
- [계층 리소스 순환 참조](#계층-리소스-순환-참조)
|
||||
- [배열 항목의 상위 스코프](#배열-항목의-상위-스코프)
|
||||
- [Custom Rule 개발 체크리스트](#custom-rule-개발-체크리스트)
|
||||
|
||||
---
|
||||
@@ -1026,6 +1028,66 @@ Rule::exists("board_{$slug}_posts", 'id'),
|
||||
|
||||
---
|
||||
|
||||
## 계층 리소스 순환 참조
|
||||
|
||||
`parent_id` 로 자기 자신을 참조하는 계층 리소스(메뉴, 카테고리 등)의 **수정·순서 변경**
|
||||
요청은 `Rule::exists` 만으로 부족하다. `exists` 는 행의 존재만 보장하므로 자기 자신이나
|
||||
자신의 자손을 부모로 지정해 사이클을 만들 수 있고, DB 의 FK 제약도 이를 막지 못한다.
|
||||
|
||||
사이클이 생기면 `path`/`depth` 재계산 재귀가 종료되지 않아 요청이 스택 오버플로로 실패하고,
|
||||
사이클 노드는 루트에서 도달할 수 없어 트리에서 사라진다. 관리자 화면으로는 복구할 수 없다.
|
||||
|
||||
```php
|
||||
// ❌ 존재만 확인 — 자기 자신/자손을 부모로 지정 가능
|
||||
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
|
||||
|
||||
// ✅ 자손 전체를 검사하는 Rule 부착
|
||||
'parent_id' => [
|
||||
'nullable',
|
||||
Rule::exists(Category::class, 'id'),
|
||||
new NotCircularCategoryParent($categoryId),
|
||||
],
|
||||
```
|
||||
|
||||
### 규율
|
||||
|
||||
| 항목 | 규칙 |
|
||||
| --- | --- |
|
||||
| 적용 대상 | `Update*Request` / `Reorder*Request`. 생성 요청은 자기 자신이 아직 없어 순환이 불가하나, 두 엔드포인트의 규칙 구성을 같게 두면 검증 강도가 갈라지지 않는다 |
|
||||
| 검사 범위 | 자기 자신만이 아니라 **자손 전체**. 자기참조만 막는 규칙(`NotSelfParent`)은 `A → B → A` 를 통과시킨다 |
|
||||
| 동일 리소스 일관성 | 한 리소스의 모든 부모 변경 경로(수정 / 순서 변경 / 일괄 이동)가 같은 강도를 가져야 한다. 한 곳만 강화하면 나머지가 우회로가 된다 |
|
||||
| 자손 판정 구현 | `path` 머티리얼라이즈드 컬럼이 있으면 prefix 매칭(쿼리 1회). 없으면 `parent_id` 재귀 — 이때 방문 ID 집합으로 유한 종료를 보장한다 |
|
||||
| Service 2차 방어 | 검증을 우회하는 경로(시더 / 훅 / 기존 오염 데이터)를 위해 재귀 함수에 방문 ID 가드를 둔다. 무한 루프 대신 유한 실패 + 로그 |
|
||||
| 코어 Rule 재사용 | `App\Rules\NotCircularParent` 는 `App\Models\Menu` 하드코딩이다. 다른 모델에는 모듈 로컬 Rule 을 신설한다 (코어 시그니처 변경 = 확장 버전 제약 연쇄) |
|
||||
|
||||
자동 차단: audit 룰 `parent-id-requires-cycle-rule` (error).
|
||||
|
||||
---
|
||||
|
||||
## 배열 항목의 상위 스코프
|
||||
|
||||
요청 본문의 배열 항목이 하위 리소스 ID 를 담고, 라우트에 상위 리소스 ID 가 있다면
|
||||
`Rule::exists` 에 상위 스코프 `where` 절을 붙인다. 붙이지 않으면 A 의 경로로 B 의
|
||||
하위 리소스를 조작할 수 있다.
|
||||
|
||||
```php
|
||||
// ❌ 전역 존재 확인 — 다른 주문의 옵션도 통과
|
||||
'items.*.option_id' => ['required', 'integer', Rule::exists(OrderOption::class, 'id')],
|
||||
|
||||
// ✅ 경로의 상위 리소스로 스코프
|
||||
$orderId = $this->route('order')?->id;
|
||||
'items.*.option_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists(OrderOption::class, 'id')->where('order_id', $orderId),
|
||||
],
|
||||
```
|
||||
|
||||
422 가 의미적으로 옳다 — 입력값이 이 요청 맥락에서 유효하지 않다는 뜻이기 때문이다.
|
||||
Service 에도 동일한 검증을 두어 FormRequest 를 우회한 내부/훅 호출을 막는다.
|
||||
|
||||
---
|
||||
|
||||
## Custom Rule 개발 체크리스트
|
||||
|
||||
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
|
||||
|
||||
Reference in New Issue
Block a user