diff --git a/.claude/rules/java-style.md b/.claude/rules/java-style.md
index e7cc6649..5bc30fc9 100644
--- a/.claude/rules/java-style.md
+++ b/.claude/rules/java-style.md
@@ -4,6 +4,21 @@ globs: "**/*.java"
# Java 코드 스타일
+## 클래스 문서화
+- 새로 만드는 클래스/인터페이스/record에는 클래스 레벨 Javadoc으로 역할·책임·경계(무엇을 하고, 무엇을 하지 않는지)를 설명
+- 기존 파일에 소급 적용하지 않는다 — 새로 작성하는 파일부터 적용 (2026-08-18 도입, 그 이전 파일은 점진적으로 채워나감)
+
+```java
+/**
+ * 역할 목록 조회 API.
+ *
+ *
권한 부여 대상(ROLE) 선택 등에 쓰는 역할 목록을 반환한다.
+ * 실제 조회·변환은 {@link RoleQueryService}에 위임한다.
+ */
+@RestController
+public class RoleController { ... }
+```
+
## 레이어 규칙
- Controller → Service → Repository 단방향
- Entity를 Controller 계층에 노출 금지 — 반드시 DTO 변환
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java
index dc2e025c..02c631bc 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java
@@ -47,19 +47,25 @@ public class CollectionController {
private final CollectionQueryService collectionQueryService;
@Operation(
- summary = "내 컬렉션 목록 조회",
- description = "현재 로그인한 사용자가 소유한 ACTIVE 상태의 컬렉션 목록을 반환합니다."
+ summary = "컬렉션 목록 조회",
+ description = "현재 로그인한 사용자가 읽을 수 있는 ACTIVE 상태의 컬렉션을 최신 생성순으로 페이지 조회합니다. " +
+ "소유한 컬렉션, PUBLIC 컬렉션, 직접·역할·부서 단위로 권한을 부여받은 컬렉션(부모 컬렉션 상속 포함)을 모두 포함합니다. " +
+ "keyword를 입력하면 이름·설명에 포함된 것만 필터링합니다."
)
@GetMapping
- public ResponseEntity>> getMyCollections(
- @Parameter(hidden = true) @CurrentUser Long userId) {
- return ResponseUtils.ok(collectionQueryService.getMyCollections(userId));
+ public ResponseEntity>> getCollections(
+ @Parameter(hidden = true) @CurrentUser Long userId,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ResponseUtils.ok(collectionQueryService.getCollections(userId, keyword, page, size));
}
@Operation(
summary = "컬렉션 삭제",
description = "컬렉션을 soft delete합니다. 소유자(owner)만 가능합니다. " +
- "소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다."
+ "하위 컬렉션 전체와 그 안의 문서 매핑까지 함께 삭제됩니다(cascade). " +
+ "대상 전체의 소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다."
)
@DeleteMapping("/{collectionId}")
public ResponseEntity> deleteCollection(
@@ -105,6 +111,18 @@ public ResponseEntity> getCollection(
return ResponseUtils.ok(collectionQueryService.getCollection(userId, collectionId));
}
+ @Operation(
+ summary = "직계 자식 컬렉션 목록 조회",
+ description = "이 컬렉션 바로 아래에 있는 하위 컬렉션 목록을 반환합니다. 하위 컬렉션 자체까지만 반환하며, " +
+ "더 아래 단계를 보려면 반환된 하위 컬렉션 ID로 이 API를 다시 호출해야 합니다."
+ )
+ @GetMapping("/{collectionId}/children")
+ public ResponseEntity>> getChildren(
+ @PathVariable Long collectionId,
+ @Parameter(hidden = true) @CurrentUser Long userId) {
+ return ResponseUtils.ok(collectionQueryService.getChildren(userId, collectionId));
+ }
+
@Operation(
summary = "컬렉션 문서 목록 조회",
description = "컬렉션을 읽을 수 있는 사용자가 개별 문서 읽기 권한도 가진 항목만 추가 최신순으로 페이지 조회합니다. " +
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java
index f06ae58b..1f5f6c5d 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java
@@ -17,6 +17,9 @@ public interface CollectionDocumentRepository extends JpaRepository findAllByCollectionId(Long collectionId);
+ // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 속한 문서 매핑 전체 조회
+ List findAllByCollectionIdIn(List collectionIds);
+
/**
* 권한 선필터를 통과한 컬렉션 문서를 현재 버전 Metadata와 함께 페이지 조회한다.
*/
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java
index a0a43417..1358ff62 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java
@@ -2,13 +2,121 @@
import java.util.List;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
public interface CollectionRepository extends JpaRepository {
- // 소유자 기준 상태별 컬렉션 목록 조회 (GET /collections)
- List findAllByOwnerIdAndStatus(Long ownerId, CollectionStatus status);
+ /**
+ * 사용자가 읽을 수 있는 컬렉션 ID 전체 (GET /collections pre-filter).
+ * 4가지 접근 경로: OWNER / PUBLIC / USER 직접 권한 / ROLE·DEPARTMENT live(부모 컬렉션 체인 상속 포함).
+ * ACTIVE 상태만 대상으로 하며, keyword가 있으면 이름·설명 부분일치로도 필터링한다(keyword는 null 가능).
+ */
+ @Query(value = """
+ WITH RECURSIVE collection_ancestors AS (
+ SELECT id AS collection_id, id AS ancestor_id FROM collections
+ UNION ALL
+ SELECT ca.collection_id, c.parent_collection_id AS ancestor_id
+ FROM collection_ancestors ca
+ JOIN collections c ON c.id = ca.ancestor_id
+ WHERE c.parent_collection_id IS NOT NULL
+ )
+ SELECT c.id FROM collections c
+ WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ UNION
+ SELECT c.id FROM collections c
+ WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ UNION
+ SELECT c.id FROM collections c
+ JOIN collection_permissions cp ON cp.collection_id = c.id
+ WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true
+ AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
+ AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ UNION
+ SELECT c.id FROM collections c
+ JOIN collection_ancestors ca ON ca.collection_id = c.id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
+ JOIN user_roles ur ON ur.role_id = cp.role_id
+ WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
+ AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
+ AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ UNION
+ SELECT c.id FROM collections c
+ JOIN collection_ancestors ca ON ca.collection_id = c.id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
+ JOIN users u ON u.department_id = cp.department_id
+ WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
+ AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
+ AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ """, nativeQuery = true)
+ List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword);
+
+ // pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당 (GET /collections)
+ @Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids")
+ Page findAllByIdIn(@Param("ids") List ids, Pageable pageable);
+
+ // 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children)
+ List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status);
+
+ /**
+ * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용).
+ * 삭제된 조상도 결과에 포함한다 — 삭제된 컬렉션은 권한이 비어있어 무해하고,
+ * status 필터를 넣으면 중간 조상이 삭제됐을 때 그 위 조상으로 체인이 끊기는 문제가 생긴다.
+ */
+ @Query(value = """
+ WITH RECURSIVE ancestors AS (
+ SELECT id, parent_collection_id FROM collections WHERE id = :collectionId
+ UNION ALL
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ JOIN ancestors a ON c.id = a.parent_collection_id
+ )
+ SELECT id FROM ancestors
+ """, nativeQuery = true)
+ List findAncestorIdsInclusive(@Param("collectionId") Long collectionId);
+
+ /**
+ * 자기 자신 + 모든 후손 컬렉션 ID (cascade 삭제 대상 판단용).
+ */
+ @Query(value = """
+ WITH RECURSIVE descendants AS (
+ SELECT id, parent_collection_id FROM collections WHERE id = :collectionId
+ UNION ALL
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ JOIN descendants d ON c.parent_collection_id = d.id
+ )
+ SELECT id FROM descendants
+ """, nativeQuery = true)
+ List findDescendantIdsInclusive(@Param("collectionId") Long collectionId);
+
+ /**
+ * 문서가 속한 모든 컬렉션(N:M) + 그 컬렉션들 각각의 조상 전체 ID (문서 권한 상속 판단용).
+ */
+ @Query(value = """
+ WITH RECURSIVE ancestors AS (
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ WHERE c.id IN (
+ SELECT DISTINCT cd.collection_id FROM collection_documents cd WHERE cd.document_id = :documentId
+ )
+ UNION ALL
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ JOIN ancestors a ON c.id = a.parent_collection_id
+ )
+ SELECT DISTINCT id FROM ancestors
+ """, nativeQuery = true)
+ List findEffectiveCollectionIdsForDocument(@Param("documentId") Long documentId);
}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java
index bdc7fdd1..4b4dca80 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java
@@ -53,7 +53,11 @@ public CollectionResponse createCollection(Long userId, CreateCollectionRequest
DocumentCollection parentCollection = null; // 상위 폴더 지정은 선택 사항이라 null로 초기화
if (request.parentCollectionId() != null) {
parentCollection = collectionRepository.findById(request.parentCollectionId())
+ .filter(c -> c.getStatus() != CollectionStatus.DELETED)
.orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
+ if (!permissionQueryService.canWriteCollection(userId, parentCollection)) {
+ throw new DocGridException(ErrorCode.PERMISSION_DENIED);
+ }
}
VisibilityType visibility = request.visibility() != null ? request.visibility() : VisibilityType.PRIVATE;
@@ -102,25 +106,34 @@ public CollectionDocumentResponse addDocument(Long collectionId, Long userId, Ad
return collectionConverter.toDocumentResponse(collectionDocument);
}
- // 컬렉션 soft delete — 소유자만 가능
+ // 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다.
+ // owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다
+ // (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐).
public void deleteCollection(Long collectionId, Long userId) {
- DocumentCollection collection = collectionRepository.findById(collectionId)
+ DocumentCollection root = collectionRepository.findById(collectionId)
.filter(c -> c.getStatus() != CollectionStatus.DELETED)
.orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
- if (!collection.getOwner().getId().equals(userId)) {
+ if (!root.getOwner().getId().equals(userId)) {
throw new DocGridException(ErrorCode.PERMISSION_DENIED);
}
- // 폴더에 속한 모든 권한 삭제 및 캐시 무효화
- List permissions = collectionPermissionRepository.findAllByCollectionId(collectionId);
+ List targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함
+
+ // 대상 전체(자기 자신+하위)에 속한 권한 삭제 및 캐시 무효화
+ List permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds);
permissions.stream()
.filter(p -> p.getTargetType() == PermissionTargetType.USER)
// 컬렉션 권한이 USER 대상인 경우에만 캐시 무효화
.forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId()));
collectionPermissionRepository.deleteAll(permissions); // 컬렉션 권한 삭제
- collection.markDeleted(LocalDateTime.now()); // 폴더 상태를 DELETED로 변경
+ // 대상 전체(자기 자신+하위)의 문서 매핑 삭제
+ List mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds);
+ collectionDocumentRepository.deleteAll(mappings);
+
+ LocalDateTime now = LocalDateTime.now();
+ collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now)); // 대상 전체 상태를 DELETED로 변경
}
// 컬렉션에서 문서 제거 — 소유자만 가능
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java
index e789cb1d..662c52e9 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java
@@ -39,6 +39,10 @@ public class CollectionQueryService {
Sort.Order.desc("addedAt"),
Sort.Order.desc("id")
);
+ private static final Sort COLLECTION_SORT = Sort.by(
+ Sort.Order.desc("createdAt"),
+ Sort.Order.desc("id")
+ );
private final CollectionRepository collectionRepository;
private final CollectionDocumentRepository collectionDocumentRepository;
@@ -57,10 +61,37 @@ public CollectionResponse getCollection(Long userId, Long collectionId) {
return collectionConverter.toResponse(collection);
}
- // 내 컬렉션 목록 조회 (ACTIVE 상태만)
- public List getMyCollections(Long userId) {
- return collectionRepository.findAllByOwnerIdAndStatus(userId, CollectionStatus.ACTIVE)
+ /**
+ * 사용자가 읽을 수 있는 컬렉션 목록 페이지 조회 (owner + PUBLIC + 권한부여 + 부모 상속, ACTIVE만).
+ * keyword가 있으면 이름·설명 부분일치로도 필터링한다.
+ */
+ public PageResponse getCollections(Long userId, String keyword, int page, int size) {
+ Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT);
+ List readableIds = collectionRepository.findReadableCollectionIds(userId, keyword);
+ if (readableIds.isEmpty()) {
+ return PageResponse.from(Page.empty(pageable), List.of());
+ }
+
+ Page collections = collectionRepository.findAllByIdIn(readableIds, pageable);
+ List content = collections.getContent().stream()
+ .map(collectionConverter::toResponse)
+ .toList();
+ return PageResponse.from(collections, content);
+ }
+
+ // 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인
+ // (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨)
+ public List getChildren(Long userId, Long collectionId) {
+ DocumentCollection parent = collectionRepository.findById(collectionId)
+ .filter(c -> c.getStatus() != CollectionStatus.DELETED)
+ .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
+ if (!permissionQueryService.canReadCollection(userId, parent)) {
+ throw new DocGridException(ErrorCode.PERMISSION_DENIED);
+ }
+
+ return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE)
.stream()
+ .filter(child -> permissionQueryService.canReadCollection(userId, child))
.map(collectionConverter::toResponse)
.toList();
}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java
index 637c74a1..0c7e0111 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java
@@ -73,8 +73,17 @@ List findDocumentStatus(
// 검색 pre-filter — 사용자가 읽을 수 있는 문서 ID 전체 (컬렉션 미지정)
// 5가지 접근 경로: OWNER / PUBLIC / USER캐시 / ROLE live / DEPT live (문서·컬렉션 권한 모두 포함)
+ // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다.
// statuses는 DocumentStatus.name() 문자열 목록. 검색은 INDEXED만, 문서 목록은 처리 중 상태까지 넘긴다.
@Query(value = """
+ WITH RECURSIVE collection_ancestors AS (
+ SELECT id AS collection_id, id AS ancestor_id FROM collections
+ UNION ALL
+ SELECT ca.collection_id, c.parent_collection_id AS ancestor_id
+ FROM collection_ancestors ca
+ JOIN collections c ON c.id = ca.ancestor_id
+ WHERE c.parent_collection_id IS NOT NULL
+ )
SELECT d.id FROM documents d
WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses)
UNION
@@ -103,7 +112,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses)
UNION
SELECT d.id FROM documents d
JOIN collection_documents cd ON cd.document_id = d.id
- JOIN collection_permissions cp ON cp.collection_id = cd.collection_id
+ JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
JOIN user_roles ur ON ur.role_id = cp.role_id
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
@@ -111,7 +121,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses)
UNION
SELECT d.id FROM documents d
JOIN collection_documents cd ON cd.document_id = d.id
- JOIN collection_permissions cp ON cp.collection_id = cd.collection_id
+ JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
JOIN users u ON u.department_id = cp.department_id
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
@@ -123,7 +134,17 @@ List findReadableDocumentIds(
);
// 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 문서 ID
+ // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다.
+ // 바깥쪽 WHERE는 "이 컬렉션에 직접 속한 문서만" 필터 — 하위 폴더 문서가 상위 폴더 목록에 섞이지 않게 한다.
@Query(value = """
+ WITH RECURSIVE collection_ancestors AS (
+ SELECT id AS collection_id, id AS ancestor_id FROM collections
+ UNION ALL
+ SELECT ca.collection_id, c.parent_collection_id AS ancestor_id
+ FROM collection_ancestors ca
+ JOIN collections c ON c.id = ca.ancestor_id
+ WHERE c.parent_collection_id IS NOT NULL
+ )
SELECT sub.id FROM (
SELECT d.id FROM documents d
WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses)
@@ -153,7 +174,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses)
UNION
SELECT d.id FROM documents d
JOIN collection_documents cd ON cd.document_id = d.id
- JOIN collection_permissions cp ON cp.collection_id = cd.collection_id
+ JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
JOIN user_roles ur ON ur.role_id = cp.role_id
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
@@ -161,7 +183,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses)
UNION
SELECT d.id FROM documents d
JOIN collection_documents cd ON cd.document_id = d.id
- JOIN collection_permissions cp ON cp.collection_id = cd.collection_id
+ JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
JOIN users u ON u.department_id = cp.department_id
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java
index 656ecd35..413b803a 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java
@@ -13,6 +13,9 @@ public interface CollectionPermissionRepository extends JpaRepository findAllByCollectionId(Long collectionId);
+ // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 걸린 권한 전체 조회
+ List findAllByCollectionIdIn(List collectionIds);
+
/**
* 컬렉션에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다.
*/
@@ -211,4 +214,73 @@ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
""")
boolean existsDeptAdminPermissionForCollection(@Param("userId") Long userId, @Param("collectionId") Long collectionId);
+
+ // 컬렉션 트리 상속용 — 컬렉션 ID 목록(자기 자신+조상 또는 문서가 속한 컬렉션+조상) 중
+ // 하나라도 ROLE/DEPARTMENT 권한이 있으면 true. 기존 단일-ID 메서드는 그대로 두고 추가로 병행한다.
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN UserRole ur ON ur.role = cp.role
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE
+ AND ur.user.id = :userId
+ AND cp.canRead = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsRoleReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN UserRole ur ON ur.role = cp.role
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE
+ AND ur.user.id = :userId
+ AND cp.canWrite = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsRoleWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN UserRole ur ON ur.role = cp.role
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE
+ AND ur.user.id = :userId
+ AND cp.canAdmin = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsRoleAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN User u ON u.department = cp.department
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT
+ AND u.id = :userId
+ AND cp.canRead = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsDeptReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN User u ON u.department = cp.department
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT
+ AND u.id = :userId
+ AND cp.canWrite = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsDeptWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
+
+ @Query("""
+ SELECT COUNT(cp) > 0 FROM CollectionPermission cp
+ JOIN User u ON u.department = cp.department
+ WHERE cp.collection.id IN :collectionIds
+ AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT
+ AND u.id = :userId
+ AND cp.canAdmin = true
+ AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP)
+ """)
+ boolean existsDeptAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds);
}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java
index 8d8d4765..5760c2ec 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java
@@ -73,6 +73,10 @@ public CollectionPermissionResponse grantPermission(Long collectionId, Long gran
} else if (request.targetType() == PermissionTargetType.ROLE) {
targetRole = roleRepository.findById(request.roleId())
.orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND));
+ // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다.
+ if ("USER".equals(targetRole.getCode())) {
+ throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE);
+ }
} else {
targetDepartment = departmentRepository.findById(request.departmentId())
.orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND));
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java
index 25113094..61ec58fa 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java
@@ -66,6 +66,10 @@ public DocumentPermissionResponse grantPermission(Long documentId, Long grantorI
} else if (request.targetType() == PermissionTargetType.ROLE) {
targetRole = roleRepository.findById(request.roleId())
.orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND));
+ // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다.
+ if ("USER".equals(targetRole.getCode())) {
+ throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE);
+ }
} else {
targetDepartment = departmentRepository.findById(request.departmentId())
.orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND));
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java
index 99abedfc..44662774 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java
@@ -120,6 +120,15 @@ public boolean canReadDocument(Long userId, Long documentId) {
return true;
}
+ // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+ if (!effectiveCollectionIds.isEmpty()
+ && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds)
+ || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) {
+ log.info("[PERM] canRead inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start));
+ return true;
+ }
+
double step5Ms = (System.nanoTime() - t5) / 1_000_000.0;
log.info("[PERM] canRead denied doc={} user={} step5={}ms elapsed={}ms",
documentId, userId, step5Ms, ms(start));
@@ -164,6 +173,15 @@ public boolean canWriteDocument(Long userId, Long documentId) {
return true;
}
+ // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+ if (!effectiveCollectionIds.isEmpty()
+ && (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds)
+ || collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds))) {
+ log.info("[PERM] canWrite inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start));
+ return true;
+ }
+
double step4Ms = (System.nanoTime() - t4) / 1_000_000.0;
log.info("[PERM] canWrite denied doc={} user={} step4={}ms elapsed={}ms",
documentId, userId, step4Ms, ms(start));
@@ -208,6 +226,15 @@ public boolean canAdminDocument(Long userId, Long documentId) {
return true;
}
+ // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+ if (!effectiveCollectionIds.isEmpty()
+ && (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds)
+ || collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds))) {
+ log.info("[PERM] canAdmin inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start));
+ return true;
+ }
+
double step4Ms = (System.nanoTime() - t4) / 1_000_000.0;
log.info("[PERM] canAdmin denied doc={} user={} step4={}ms elapsed={}ms",
documentId, userId, step4Ms, ms(start));
@@ -275,6 +302,26 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo
if (deptAdmin) canAdmin = true;
}
+ // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) — 기존 ROLE/DEPARTMENT 출처 값을 그대로 재사용한다
+ List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+ if (!effectiveCollectionIds.isEmpty()) {
+ boolean inheritedRoleRead = collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds);
+ boolean inheritedRoleWrite = collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds);
+ boolean inheritedRoleAdmin = collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds);
+ boolean inheritedDeptRead = collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds);
+ boolean inheritedDeptWrite = collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds);
+ boolean inheritedDeptAdmin = collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds);
+ if ((inheritedRoleRead || inheritedRoleWrite || inheritedRoleAdmin) && !sources.contains(PermissionSourceType.ROLE)) {
+ sources.add(PermissionSourceType.ROLE);
+ }
+ if ((inheritedDeptRead || inheritedDeptWrite || inheritedDeptAdmin) && !sources.contains(PermissionSourceType.DEPARTMENT)) {
+ sources.add(PermissionSourceType.DEPARTMENT);
+ }
+ if (inheritedRoleRead || inheritedDeptRead) canRead = true;
+ if (inheritedRoleWrite || inheritedDeptWrite) canWrite = true;
+ if (inheritedRoleAdmin || inheritedDeptAdmin) canAdmin = true;
+ }
+
log.info("[PERM] checkDoc doc={} user={} canRead={} canWrite={} canAdmin={} sources={} elapsed={}ms",
documentId, userId, canRead, canWrite, canAdmin, sources, ms(start));
return new DocumentPermissionSummaryResponse(documentId, canRead, canWrite, canAdmin, sources);
@@ -293,7 +340,12 @@ public boolean canReadCollection(Long userId, DocumentCollection collection) {
Long collectionId = collection.getId();
if (collectionPermissionRepository.existsUserReadPermission(userId, collectionId)) return true;
if (collectionPermissionRepository.existsRoleReadPermissionForCollection(userId, collectionId)) return true;
- return collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId);
+ if (collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId)) return true;
+
+ // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId);
+ if (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, ancestorIds)) return true;
+ return collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, ancestorIds);
}
// 컬렉션 쓰기 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한)
@@ -307,7 +359,12 @@ public boolean canWriteCollection(Long userId, DocumentCollection collection) {
Long collectionId = collection.getId();
if (collectionPermissionRepository.existsUserWritePermission(userId, collectionId)) return true;
if (collectionPermissionRepository.existsRoleWritePermissionForCollection(userId, collectionId)) return true;
- return collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId);
+ if (collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId)) return true;
+
+ // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId);
+ if (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, ancestorIds)) return true;
+ return collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, ancestorIds);
}
// 컬렉션 관리 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한)
@@ -321,7 +378,12 @@ public boolean canAdminCollection(Long userId, DocumentCollection collection) {
Long collectionId = collection.getId();
if (collectionPermissionRepository.existsUserAdminPermission(userId, collectionId)) return true;
if (collectionPermissionRepository.existsRoleAdminPermissionForCollection(userId, collectionId)) return true;
- return collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId);
+ if (collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId)) return true;
+
+ // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+ List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId);
+ if (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, ancestorIds)) return true;
+ return collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, ancestorIds);
}
// collectionId로 조회하되, status가 DELETED인 컬렉션은 필터링해서 제외한다 (없는 것으로 취급).
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java
new file mode 100644
index 00000000..11541bd0
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java
@@ -0,0 +1,38 @@
+package com.opensource.docgrid.domain.user.controller;
+
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.opensource.docgrid.domain.user.dto.response.RoleResponse;
+import com.opensource.docgrid.domain.user.service.query.RoleQueryService;
+import com.opensource.docgrid.global.common.response.ApiResponse;
+import com.opensource.docgrid.global.common.response.ResponseUtils;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 역할 목록 조회 API.
+ *
+ *
권한 부여 대상(ROLE) 선택 등 프론트에서 역할 이름이 필요한 화면에 쓰인다.
+ * 실제 조회·변환은 {@link RoleQueryService}에 위임한다.
+ */
+@Tag(name = "Role", description = "역할 관련 API")
+@RestController
+@RequestMapping("/roles")
+@RequiredArgsConstructor
+public class RoleController {
+
+ private final RoleQueryService roleQueryService;
+
+ @Operation(summary = "역할 목록 조회", description = "권한 부여 대상 선택 등에 쓰는 전체 역할 목록을 반환합니다.")
+ @GetMapping
+ public ResponseEntity>> getRoles() {
+ return ResponseUtils.ok(roleQueryService.getRoles());
+ }
+}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java
new file mode 100644
index 00000000..1e6c70a0
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java
@@ -0,0 +1,20 @@
+package com.opensource.docgrid.domain.user.dto.response;
+
+import com.opensource.docgrid.domain.user.entity.Role;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 역할 목록 조회 API 응답 DTO.
+ *
+ *
{@link Role} 엔티티를 Controller 계층에 직접 노출하지 않기 위한 변환 경계다.
+ */
+public record RoleResponse(
+ @Schema(description = "역할 ID") Long id,
+ @Schema(description = "역할명") String name,
+ @Schema(description = "역할 코드") String code
+) {
+ public static RoleResponse from(Role role) {
+ return new RoleResponse(role.getId(), role.getName(), role.getCode());
+ }
+}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java
new file mode 100644
index 00000000..cc404010
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java
@@ -0,0 +1,31 @@
+package com.opensource.docgrid.domain.user.service.query;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.opensource.docgrid.domain.user.dto.response.RoleResponse;
+import com.opensource.docgrid.domain.user.repository.RoleRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 역할 목록 조회 서비스.
+ *
+ *
{@link RoleRepository}에서 전체 역할을 읽어 {@link RoleResponse}로 변환하는 책임만 가진다.
+ */
+@Transactional(readOnly = true)
+@Service
+@RequiredArgsConstructor
+public class RoleQueryService {
+
+ private final RoleRepository roleRepository;
+
+ public List getRoles() {
+ return roleRepository.findAll()
+ .stream()
+ .map(RoleResponse::from)
+ .toList();
+ }
+}
diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
index 31015146..edfcc1d2 100644
--- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
+++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
@@ -173,6 +173,8 @@ public enum ErrorCode {
INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "PERMISSION-001", "target_type과 ID 필드 조합이 올바르지 않습니다."),
COLLECTION_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-002", "컬렉션 권한을 찾을 수 없습니다."),
DOCUMENT_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-003", "문서 권한을 찾을 수 없습니다."),
+ ROLE_NOT_GRANTABLE(HttpStatus.BAD_REQUEST, "PERMISSION-004",
+ "USER role은 모든 사용자가 보유하고 있어 권한 부여 대상으로 지정할 수 없습니다. 전체 공개가 목적이면 visibility를 PUBLIC으로 설정하세요."),
// WORKER
// Claim 요청의 Worker 식별자가 등록된 실행 인스턴스와 연결되지 않은 경우 사용한다.
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java
index b07138f2..ce70706a 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/controller/CollectionControllerTest.java
@@ -19,6 +19,8 @@
import org.springframework.test.web.servlet.MockMvc;
import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse;
+import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse;
+import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
import com.opensource.docgrid.domain.collection.service.command.CollectionCommandService;
import com.opensource.docgrid.domain.collection.service.query.CollectionQueryService;
import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse;
@@ -36,6 +38,8 @@
class CollectionControllerTest {
private static final String DOCUMENTS_URL = "/collections/{collectionId}/documents";
+ private static final String CHILDREN_URL = "/collections/{collectionId}/children";
+ private static final String COLLECTIONS_URL = "/collections";
@Autowired private MockMvc mockMvc;
@@ -89,6 +93,39 @@ void getCollectionDocuments_returnsBadRequest_whenPageInputIsInvalid() throws Ex
.andExpect(jsonPath("$.code").value("COMMON-002"));
}
+ @Test
+ @DisplayName("인증된 사용자가 직계 자식 컬렉션 목록을 조회한다")
+ void getChildren_returnsChildCollections() throws Exception {
+ CollectionResponse child = new CollectionResponse(
+ 2L, "하위 컬렉션", null, 10L, 1L, VisibilityType.PRIVATE, CollectionStatus.ACTIVE,
+ LocalDateTime.of(2026, 8, 1, 10, 0)
+ );
+ given(collectionQueryService.getChildren(10L, 1L)).willReturn(List.of(child));
+
+ mockMvc.perform(get(CHILDREN_URL, 1L)
+ .with(authentication(authenticationWithUserId(10L))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].collectionId").value(2))
+ .andExpect(jsonPath("$.data[0].parentCollectionId").value(1));
+ }
+
+ @Test
+ @DisplayName("인증된 사용자가 읽을 수 있는 컬렉션을 페이지 조회한다")
+ void getCollections_returnsReadableCollectionPage() throws Exception {
+ CollectionResponse collection = new CollectionResponse(
+ 1L, "인사팀", null, 10L, null, VisibilityType.PRIVATE, CollectionStatus.ACTIVE,
+ LocalDateTime.of(2026, 8, 1, 10, 0)
+ );
+ given(collectionQueryService.getCollections(10L, null, 0, 20))
+ .willReturn(new PageResponse<>(List.of(collection), 0, 20, 1, 1, true, true));
+
+ mockMvc.perform(get(COLLECTIONS_URL)
+ .with(authentication(authenticationWithUserId(10L))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.content[0].collectionId").value(1))
+ .andExpect(jsonPath("$.data.totalElements").value(1));
+ }
+
private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) {
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
"user",
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java
index febdb76a..4067efb2 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/fixture/CollectionFixture.java
@@ -66,6 +66,18 @@ public static DocumentCollection createCollection() {
return createCollection(createOwner());
}
+ public static DocumentCollection createChildCollection(User owner, DocumentCollection parent, Long childId) {
+ DocumentCollection child = DocumentCollection.builder()
+ .owner(owner)
+ .parentCollection(parent)
+ .name("하위 컬렉션")
+ .visibility(VisibilityType.PRIVATE)
+ .status(CollectionStatus.ACTIVE)
+ .build();
+ ReflectionTestUtils.setField(child, "id", childId);
+ return child;
+ }
+
public static Document createDocument(User owner) {
Document document = Document.builder()
.owner(owner)
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java
new file mode 100644
index 00000000..0c389cab
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java
@@ -0,0 +1,309 @@
+package com.opensource.docgrid.domain.collection.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import com.opensource.docgrid.domain.collection.entity.CollectionDocument;
+import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
+import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
+import com.opensource.docgrid.domain.document.entity.Document;
+import com.opensource.docgrid.domain.document.enums.DocumentSourceType;
+import com.opensource.docgrid.domain.document.enums.DocumentStatus;
+import com.opensource.docgrid.domain.document.enums.DocumentType;
+import com.opensource.docgrid.domain.document.enums.VisibilityType;
+import com.opensource.docgrid.domain.document.repository.DocumentRepository;
+import com.opensource.docgrid.domain.permission.entity.CollectionPermission;
+import com.opensource.docgrid.domain.permission.enums.PermissionTargetType;
+import com.opensource.docgrid.domain.permission.enums.PermissionType;
+import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository;
+import com.opensource.docgrid.domain.user.entity.Department;
+import com.opensource.docgrid.domain.user.entity.Role;
+import com.opensource.docgrid.domain.user.entity.User;
+import com.opensource.docgrid.domain.user.entity.UserRole;
+import com.opensource.docgrid.domain.user.enums.CommonStatus;
+import com.opensource.docgrid.domain.user.enums.UserStatus;
+import com.opensource.docgrid.domain.user.repository.DepartmentRepository;
+import com.opensource.docgrid.domain.user.repository.RoleRepository;
+import com.opensource.docgrid.domain.user.repository.UserRepository;
+import com.opensource.docgrid.domain.user.repository.UserRoleRepository;
+
+import jakarta.persistence.EntityManager;
+
+/**
+ * 컬렉션 트리(부모-자식) 재귀 쿼리와 직계 자식 조회를 3단 트리(root→child→grandchild)로 검증한다.
+ * findReadableCollectionIds의 owner/PUBLIC 노출, keyword 필터, 부모 컬렉션으로부터의 DEPARTMENT 권한
+ * 상속도 함께 검증한다.
+ * 이동/수정 API가 없어 순환 참조가 API상 불가능하므로 순환 참조 케이스는 검증하지 않는다.
+ */
+@DataJpaTest
+@ActiveProfiles("test")
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@DisplayName("컬렉션 트리 Repository 테스트")
+class CollectionTreeRepositoryTest {
+
+ @Autowired private CollectionRepository collectionRepository;
+ @Autowired private CollectionDocumentRepository collectionDocumentRepository;
+ @Autowired private CollectionPermissionRepository collectionPermissionRepository;
+ @Autowired private DocumentRepository documentRepository;
+ @Autowired private UserRepository userRepository;
+ @Autowired private DepartmentRepository departmentRepository;
+ @Autowired private RoleRepository roleRepository;
+ @Autowired private UserRoleRepository userRoleRepository;
+ @Autowired private EntityManager entityManager;
+
+ @Test
+ @DisplayName("findAncestorIdsInclusive는 자기 자신부터 최상위 조상까지 전부 반환한다")
+ void findAncestorIdsInclusive_returnsSelfAndAllAncestors() {
+ User owner = saveOwner();
+ DocumentCollection root = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, root);
+ DocumentCollection grandchild = saveCollection(owner, child);
+ flushAndClear();
+
+ List ancestorsOfGrandchild = collectionRepository.findAncestorIdsInclusive(grandchild.getId());
+ List ancestorsOfRoot = collectionRepository.findAncestorIdsInclusive(root.getId());
+
+ assertThat(ancestorsOfGrandchild).containsExactlyInAnyOrder(root.getId(), child.getId(), grandchild.getId());
+ assertThat(ancestorsOfRoot).containsExactly(root.getId());
+ }
+
+ @Test
+ @DisplayName("findDescendantIdsInclusive는 자기 자신부터 모든 후손까지 전부 반환한다")
+ void findDescendantIdsInclusive_returnsSelfAndAllDescendants() {
+ User owner = saveOwner();
+ DocumentCollection root = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, root);
+ DocumentCollection grandchild = saveCollection(owner, child);
+ flushAndClear();
+
+ List descendantsOfRoot = collectionRepository.findDescendantIdsInclusive(root.getId());
+ List descendantsOfGrandchild = collectionRepository.findDescendantIdsInclusive(grandchild.getId());
+
+ assertThat(descendantsOfRoot).containsExactlyInAnyOrder(root.getId(), child.getId(), grandchild.getId());
+ assertThat(descendantsOfGrandchild).containsExactly(grandchild.getId());
+ }
+
+ @Test
+ @DisplayName("findEffectiveCollectionIdsForDocument는 문서가 속한 컬렉션과 그 조상 전체를 반환한다")
+ void findEffectiveCollectionIdsForDocument_returnsContainingCollectionsAndAncestors() {
+ User owner = saveOwner();
+ DocumentCollection root = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, root);
+ Document document = saveDocument(owner);
+ addToCollection(child, document, owner);
+ flushAndClear();
+
+ List effectiveIds = collectionRepository.findEffectiveCollectionIdsForDocument(document.getId());
+
+ assertThat(effectiveIds).containsExactlyInAnyOrder(root.getId(), child.getId());
+ }
+
+ @Test
+ @DisplayName("findEffectiveCollectionIdsForDocument는 문서가 어느 컬렉션에도 속하지 않으면 빈 목록을 반환한다")
+ void findEffectiveCollectionIdsForDocument_returnsEmpty_whenDocumentInNoCollection() {
+ User owner = saveOwner();
+ Document document = saveDocument(owner);
+ flushAndClear();
+
+ List effectiveIds = collectionRepository.findEffectiveCollectionIdsForDocument(document.getId());
+
+ assertThat(effectiveIds).isEmpty();
+ }
+
+ @Test
+ @DisplayName("findAllByParentCollectionIdAndStatus는 직계 자식만 반환하고 손자는 포함하지 않는다")
+ void findAllByParentCollectionIdAndStatus_returnsDirectChildrenOnly() {
+ User owner = saveOwner();
+ DocumentCollection root = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, root);
+ DocumentCollection grandchild = saveCollection(owner, child);
+ flushAndClear();
+
+ List children = collectionRepository.findAllByParentCollectionIdAndStatus(
+ root.getId(), CollectionStatus.ACTIVE
+ );
+
+ assertThat(children).extracting(DocumentCollection::getId).containsExactly(child.getId());
+ assertThat(children).extracting(DocumentCollection::getId).doesNotContain(grandchild.getId());
+ }
+
+ @Test
+ @DisplayName("findReadableCollectionIds는 owner의 PRIVATE 컬렉션은 owner에게만, PUBLIC 컬렉션은 누구에게나 보여준다")
+ void findReadableCollectionIds_ownerAndPublic() {
+ User owner = saveOwner();
+ User stranger = saveOwner();
+ DocumentCollection privateCollection = saveCollection(owner, null);
+ DocumentCollection publicCollection = collectionRepository.save(
+ DocumentCollection.builder()
+ .owner(owner)
+ .name("공개 컬렉션")
+ .visibility(VisibilityType.PUBLIC)
+ .build()
+ );
+ flushAndClear();
+
+ List ownerReadable = collectionRepository.findReadableCollectionIds(owner.getId(), null);
+ List strangerReadable = collectionRepository.findReadableCollectionIds(stranger.getId(), null);
+
+ assertThat(ownerReadable).contains(privateCollection.getId(), publicCollection.getId());
+ assertThat(strangerReadable).contains(publicCollection.getId());
+ assertThat(strangerReadable).doesNotContain(privateCollection.getId());
+ }
+
+ @Test
+ @DisplayName("findReadableCollectionIds는 keyword가 있으면 이름·설명에 부분일치하는 컬렉션만 반환한다")
+ void findReadableCollectionIds_filtersByKeyword() {
+ User owner = saveOwner();
+ DocumentCollection matching = collectionRepository.save(
+ DocumentCollection.builder().owner(owner).name("개발 문서").description("백엔드 관련").visibility(VisibilityType.PRIVATE).build()
+ );
+ DocumentCollection nonMatching = collectionRepository.save(
+ DocumentCollection.builder().owner(owner).name("디자인 자료").description("UI 관련").visibility(VisibilityType.PRIVATE).build()
+ );
+ flushAndClear();
+
+ List result = collectionRepository.findReadableCollectionIds(owner.getId(), "개발");
+
+ assertThat(result).contains(matching.getId());
+ assertThat(result).doesNotContain(nonMatching.getId());
+ }
+
+ @Test
+ @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 DEPARTMENT 권한을 자식 컬렉션까지 상속해서 보여준다")
+ void findReadableCollectionIds_inheritsDepartmentPermissionFromParent() {
+ Department department = departmentRepository.save(
+ Department.builder().name("컬렉션목록 테스트 부서").code("CL-DEPT-" + UUID.randomUUID()).status(CommonStatus.ACTIVE).build()
+ );
+ User owner = saveOwner();
+ User deptMember = userRepository.save(
+ User.builder()
+ .department(department)
+ .email("collection-tree-dept-" + UUID.randomUUID() + "@test.com")
+ .passwordHash("hash")
+ .name("컬렉션목록 테스트 부서원")
+ .status(UserStatus.ACTIVE)
+ .build()
+ );
+ DocumentCollection parent = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, parent);
+ collectionPermissionRepository.save(
+ CollectionPermission.builder()
+ .collection(parent)
+ .targetType(PermissionTargetType.DEPARTMENT)
+ .department(department)
+ .permissionType(PermissionType.READ)
+ .canRead(true)
+ .canWrite(false)
+ .canAdmin(false)
+ .grantedBy(owner)
+ .grantedAt(LocalDateTime.now())
+ .build()
+ );
+ flushAndClear();
+
+ List readable = collectionRepository.findReadableCollectionIds(deptMember.getId(), null);
+
+ assertThat(readable).contains(parent.getId(), child.getId());
+ }
+
+ @Test
+ @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 ROLE 권한을 자식 컬렉션까지 상속해서 보여준다")
+ void findReadableCollectionIds_inheritsRolePermissionFromParent() {
+ Role role = roleRepository.save(
+ Role.builder().name("컬렉션목록 테스트 역할").code("CL-ROLE-" + UUID.randomUUID()).build()
+ );
+ User owner = saveOwner();
+ User roleMember = userRepository.save(
+ User.builder()
+ .email("collection-tree-role-" + UUID.randomUUID() + "@test.com")
+ .passwordHash("hash")
+ .name("컬렉션목록 테스트 역할 보유자")
+ .status(UserStatus.ACTIVE)
+ .build()
+ );
+ userRoleRepository.save(
+ UserRole.builder().user(roleMember).role(role).assignedAt(LocalDateTime.now()).build()
+ );
+ DocumentCollection parent = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, parent);
+ collectionPermissionRepository.save(
+ CollectionPermission.builder()
+ .collection(parent)
+ .targetType(PermissionTargetType.ROLE)
+ .role(role)
+ .permissionType(PermissionType.READ)
+ .canRead(true)
+ .canWrite(false)
+ .canAdmin(false)
+ .grantedBy(owner)
+ .grantedAt(LocalDateTime.now())
+ .build()
+ );
+ flushAndClear();
+
+ List readable = collectionRepository.findReadableCollectionIds(roleMember.getId(), null);
+
+ assertThat(readable).contains(parent.getId(), child.getId());
+ }
+
+ private User saveOwner() {
+ return userRepository.save(
+ User.builder()
+ .email("collection-tree-" + UUID.randomUUID() + "@test.com")
+ .passwordHash("hash")
+ .name("컬렉션 트리 테스트 사용자")
+ .status(UserStatus.ACTIVE)
+ .build()
+ );
+ }
+
+ private DocumentCollection saveCollection(User owner, DocumentCollection parent) {
+ return collectionRepository.save(
+ DocumentCollection.builder()
+ .owner(owner)
+ .parentCollection(parent)
+ .name("컬렉션 트리 테스트 컬렉션")
+ .visibility(VisibilityType.PRIVATE)
+ .build()
+ );
+ }
+
+ private Document saveDocument(User owner) {
+ return documentRepository.save(
+ Document.builder()
+ .owner(owner)
+ .title("컬렉션 트리 테스트 문서")
+ .documentType(DocumentType.TXT)
+ .sourceType(DocumentSourceType.UPLOAD)
+ .status(DocumentStatus.INDEXED)
+ .visibility(VisibilityType.PRIVATE)
+ .build()
+ );
+ }
+
+ private void addToCollection(DocumentCollection collection, Document document, User addedBy) {
+ collectionDocumentRepository.save(
+ CollectionDocument.builder()
+ .collection(collection)
+ .document(document)
+ .addedBy(addedBy)
+ .addedAt(LocalDateTime.now())
+ .build()
+ );
+ }
+
+ private void flushAndClear() {
+ entityManager.flush();
+ entityManager.clear();
+ }
+}
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java
index 47ee7c58..454b7cac 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandServiceTest.java
@@ -114,7 +114,7 @@ void createCollection_defaults_visibility_to_private_when_null() {
}
@Test
- @DisplayName("존재하는 상위 컬렉션 ID를 지정하면 parentCollection이 설정된 컬렉션이 생성된다")
+ @DisplayName("존재하는 상위 컬렉션 ID를 지정하고 쓰기 권한이 있으면 parentCollection이 설정된 컬렉션이 생성된다")
void createCollection_succeeds_with_parentCollection() {
User owner = CollectionFixture.createOwner();
DocumentCollection parent = CollectionFixture.createCollection(owner);
@@ -124,12 +124,15 @@ void createCollection_succeeds_with_parentCollection() {
);
given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner);
given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent));
+ given(permissionQueryService.canWriteCollection(CollectionFixture.USER_ID, parent)).willReturn(true);
given(collectionConverter.toResponse(any(DocumentCollection.class))).willReturn(expected);
collectionCommandService.createCollection(CollectionFixture.USER_ID, request);
then(collectionRepository).should().findById(CollectionFixture.COLLECTION_ID);
- then(collectionRepository).should().save(any(DocumentCollection.class));
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentCollection.class);
+ then(collectionRepository).should().save(captor.capture());
+ assertThat(captor.getValue().getParentCollection()).isSameAs(parent);
}
@Test
@@ -147,6 +150,23 @@ void createCollection_throws_when_parentNotFound() {
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.COLLECTION_NOT_FOUND);
}
+ @Test
+ @DisplayName("상위 컬렉션에 쓰기 권한이 없으면 PERMISSION_DENIED 예외가 발생한다")
+ void createCollection_throws_when_noWritePermissionOnParent() {
+ User owner = CollectionFixture.createOwner();
+ DocumentCollection parent = CollectionFixture.createCollection(owner);
+ CreateCollectionRequest request = new CreateCollectionRequest(
+ "하위 컬렉션", null, CollectionFixture.COLLECTION_ID, VisibilityType.PRIVATE
+ );
+ Long otherUserId = 99L;
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent));
+ given(permissionQueryService.canWriteCollection(otherUserId, parent)).willReturn(false);
+
+ assertThatThrownBy(() -> collectionCommandService.createCollection(otherUserId, request))
+ .isInstanceOf(DocGridException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED);
+ }
+
// ==================== addDocument ====================
@Test
@@ -239,15 +259,45 @@ void deleteCollection_succeeds_when_owner() {
User owner = CollectionFixture.createOwner();
DocumentCollection collection = CollectionFixture.createCollection(owner);
CollectionPermission userPermission = PermissionFixture.createCollectionPermission(collection, owner);
+ List targetIds = List.of(CollectionFixture.COLLECTION_ID);
given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection));
- given(collectionPermissionRepository.findAllByCollectionId(CollectionFixture.COLLECTION_ID))
+ given(collectionRepository.findDescendantIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(targetIds);
+ given(collectionPermissionRepository.findAllByCollectionIdIn(targetIds))
.willReturn(List.of(userPermission));
+ given(collectionDocumentRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of());
+ given(collectionRepository.findAllById(targetIds)).willReturn(List.of(collection));
collectionCommandService.deleteCollection(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID);
then(cacheService).should().bulkRevokeBySource(any(), any());
- then(collectionPermissionRepository).should().deleteAll(any());
+ then(collectionPermissionRepository).should().deleteAll(List.of(userPermission));
+ assertThat(collection.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED);
+ }
+
+ @Test
+ @DisplayName("하위 컬렉션이 있으면 삭제 시 하위 컬렉션과 문서 매핑까지 cascade로 함께 삭제된다")
+ void deleteCollection_cascades_to_descendants() {
+ User owner = CollectionFixture.createOwner();
+ DocumentCollection root = CollectionFixture.createCollection(owner);
+ Long childId = 2L;
+ DocumentCollection child = CollectionFixture.createChildCollection(owner, root, childId);
+ List targetIds = List.of(CollectionFixture.COLLECTION_ID, childId);
+ CollectionDocument childMapping = CollectionDocument.builder()
+ .collection(child).document(CollectionFixture.createDocument(owner)).addedBy(owner)
+ .addedAt(java.time.LocalDateTime.now()).build();
+
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(root));
+ given(collectionRepository.findDescendantIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(targetIds);
+ given(collectionPermissionRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of());
+ given(collectionDocumentRepository.findAllByCollectionIdIn(targetIds)).willReturn(List.of(childMapping));
+ given(collectionRepository.findAllById(targetIds)).willReturn(List.of(root, child));
+
+ collectionCommandService.deleteCollection(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID);
+
+ then(collectionDocumentRepository).should().deleteAll(List.of(childMapping));
+ assertThat(root.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED);
+ assertThat(child.getStatus()).isEqualTo(com.opensource.docgrid.domain.collection.enums.CollectionStatus.DELETED);
}
@Test
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java
index 6c6a09ce..1996fddf 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java
@@ -110,6 +110,98 @@ void getCollection_throws_when_noReadPermission() {
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED);
}
+ @Test
+ @DisplayName("읽을 수 있는 컬렉션이 없으면 빈 페이지를 반환한다")
+ void getCollections_returnsEmptyPage_whenNoReadableCollection() {
+ given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(List.of());
+
+ PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20);
+
+ assertThat(result.content()).isEmpty();
+ assertThat(result.totalElements()).isZero();
+ then(collectionRepository).should(never()).findAllByIdIn(org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ @DisplayName("읽을 수 있는 컬렉션 ID로 페이지를 조회해서 응답으로 변환한다")
+ void getCollections_returnsPagedResponses() {
+ DocumentCollection collection = CollectionFixture.createCollection();
+ CollectionResponse expected = CollectionFixture.createCollectionResponse();
+ List readableIds = List.of(collection.getId());
+ given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(readableIds);
+ given(collectionRepository.findAllByIdIn(org.mockito.ArgumentMatchers.eq(readableIds), org.mockito.ArgumentMatchers.any(Pageable.class)))
+ .willReturn(new PageImpl<>(List.of(collection), PageRequest.of(0, 20), 1));
+ given(collectionConverter.toResponse(collection)).willReturn(expected);
+
+ PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20);
+
+ assertThat(result.content()).containsExactly(expected);
+ assertThat(result.totalElements()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("keyword를 그대로 repository에 전달한다")
+ void getCollections_passesKeywordToRepository() {
+ given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, "개발")).willReturn(List.of());
+
+ collectionQueryService.getCollections(CollectionFixture.USER_ID, "개발", 0, 20);
+
+ then(collectionRepository).should().findReadableCollectionIds(CollectionFixture.USER_ID, "개발");
+ }
+
+ @Test
+ @DisplayName("부모 읽기 권한이 있으면 자식 컬렉션 목록을 반환한다")
+ void getChildren_returnsResponses_when_parentIsReadable() {
+ DocumentCollection parent = CollectionFixture.createCollection();
+ DocumentCollection child = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L);
+ CollectionResponse expected = CollectionFixture.createCollectionResponse();
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent));
+ given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true);
+ given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE))
+ .willReturn(List.of(child));
+ given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, child)).willReturn(true);
+ given(collectionConverter.toResponse(child)).willReturn(expected);
+
+ List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID);
+
+ assertThat(result).containsExactly(expected);
+ }
+
+ @Test
+ @DisplayName("부모 읽기 권한이 없으면 PERMISSION_DENIED 예외가 발생한다")
+ void getChildren_throws_when_parentReadIsDenied() {
+ DocumentCollection parent = CollectionFixture.createCollection();
+ Long otherUserId = 99L;
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent));
+ given(permissionQueryService.canReadCollection(otherUserId, parent)).willReturn(false);
+
+ assertThatThrownBy(() -> collectionQueryService.getChildren(otherUserId, CollectionFixture.COLLECTION_ID))
+ .isInstanceOf(DocGridException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED);
+ then(collectionRepository).should(never()).findAllByParentCollectionIdAndStatus(
+ org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ @DisplayName("부모는 읽을 수 있어도 자식은 개별 읽기 권한이 없으면 목록에서 제외된다")
+ void getChildren_excludesChild_when_childReadIsDenied() {
+ DocumentCollection parent = CollectionFixture.createCollection();
+ DocumentCollection readableChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L);
+ DocumentCollection deniedChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 3L);
+ CollectionResponse expected = CollectionFixture.createCollectionResponse();
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent));
+ given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true);
+ given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE))
+ .willReturn(List.of(readableChild, deniedChild));
+ given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, readableChild)).willReturn(true);
+ given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, deniedChild)).willReturn(false);
+ given(collectionConverter.toResponse(readableChild)).willReturn(expected);
+
+ List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID);
+
+ assertThat(result).containsExactly(expected);
+ }
+
@Test
@DisplayName("컬렉션 문서 목록은 읽기 가능한 문서만 최신 추가순으로 페이지 반환한다")
void getCollectionDocuments_returnsOnlyReadableDocuments() {
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java
index 797c9f2f..61805712 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java
@@ -22,9 +22,20 @@
import com.opensource.docgrid.domain.document.enums.DocumentStatus;
import com.opensource.docgrid.domain.document.enums.DocumentType;
import com.opensource.docgrid.domain.document.enums.VisibilityType;
+import com.opensource.docgrid.domain.permission.entity.CollectionPermission;
+import com.opensource.docgrid.domain.permission.enums.PermissionTargetType;
+import com.opensource.docgrid.domain.permission.enums.PermissionType;
+import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository;
+import com.opensource.docgrid.domain.user.entity.Department;
+import com.opensource.docgrid.domain.user.entity.Role;
import com.opensource.docgrid.domain.user.entity.User;
+import com.opensource.docgrid.domain.user.entity.UserRole;
+import com.opensource.docgrid.domain.user.enums.CommonStatus;
import com.opensource.docgrid.domain.user.enums.UserStatus;
+import com.opensource.docgrid.domain.user.repository.DepartmentRepository;
+import com.opensource.docgrid.domain.user.repository.RoleRepository;
import com.opensource.docgrid.domain.user.repository.UserRepository;
+import com.opensource.docgrid.domain.user.repository.UserRoleRepository;
import jakarta.persistence.EntityManager;
@@ -43,7 +54,11 @@ class DocumentReadableIdsRepositoryTest {
@Autowired private DocumentRepository documentRepository;
@Autowired private CollectionRepository collectionRepository;
@Autowired private CollectionDocumentRepository collectionDocumentRepository;
+ @Autowired private CollectionPermissionRepository collectionPermissionRepository;
@Autowired private UserRepository userRepository;
+ @Autowired private DepartmentRepository departmentRepository;
+ @Autowired private RoleRepository roleRepository;
+ @Autowired private UserRoleRepository userRoleRepository;
@Autowired private EntityManager entityManager;
// seed 데이터에 PUBLIC·INDEXED 문서가 있어 모든 사용자에게 조회되므로, 이 테스트가 만든 문서만 검증한다.
@@ -94,7 +109,7 @@ void findReadableDocumentIdsInCollection_appliesStatusFilter() {
User owner = saveOwner();
Document indexed = saveDocument(owner, DocumentStatus.INDEXED);
Document indexing = saveDocument(owner, DocumentStatus.INDEXING);
- DocumentCollection collection = saveCollection(owner);
+ DocumentCollection collection = saveCollection(owner, null);
addToCollection(collection, indexed, owner);
addToCollection(collection, indexing, owner);
flushAndClear();
@@ -110,6 +125,152 @@ void findReadableDocumentIdsInCollection_appliesStatusFilter() {
assertThat(withIndexing).containsExactlyInAnyOrder(indexed.getId(), indexing.getId());
}
+ @Test
+ @DisplayName("부모 컬렉션에 DEPARTMENT 권한이 있으면 자식 컬렉션의 문서도 목록과 컬렉션 조회에서 함께 조회된다 (상속)")
+ void findReadableDocumentIds_includesDocumentInChildCollection_whenParentHasDepartmentPermission() {
+ Department department = saveDepartment();
+ User owner = saveOwner();
+ User deptMember = saveUserInDepartment(department);
+ Document document = saveDocument(owner, DocumentStatus.INDEXED);
+
+ DocumentCollection parent = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, parent);
+ addToCollection(child, document, owner);
+ grantDepartmentReadPermission(parent, department, owner);
+ flushAndClear();
+
+ List readableIds = documentRepository.findReadableDocumentIds(deptMember.getId(), INDEXED_ONLY);
+ List readableIdsInChildCollection = documentRepository.findReadableDocumentIdsInCollection(
+ deptMember.getId(), child.getId(), INDEXED_ONLY
+ );
+
+ assertThat(readableIds).contains(document.getId());
+ assertThat(readableIdsInChildCollection).containsExactly(document.getId());
+ }
+
+ @Test
+ @DisplayName("컬렉션에 직접 부여된 권한만 있으면(부모 없음) 기존과 동일하게 조회된다 (회귀)")
+ void findReadableDocumentIds_includesDocument_whenDirectDepartmentPermission_noParent() {
+ Department department = saveDepartment();
+ User owner = saveOwner();
+ User deptMember = saveUserInDepartment(department);
+ Document document = saveDocument(owner, DocumentStatus.INDEXED);
+
+ DocumentCollection collection = saveCollection(owner, null);
+ addToCollection(collection, document, owner);
+ grantDepartmentReadPermission(collection, department, owner);
+ flushAndClear();
+
+ List readableIds = documentRepository.findReadableDocumentIds(deptMember.getId(), INDEXED_ONLY);
+
+ assertThat(readableIds).contains(document.getId());
+ }
+
+ @Test
+ @DisplayName("부모 컬렉션에 ROLE 권한이 있으면 자식 컬렉션의 문서도 목록과 컬렉션 조회에서 함께 조회된다 (상속)")
+ void findReadableDocumentIds_includesDocumentInChildCollection_whenParentHasRolePermission() {
+ Role role = saveRole();
+ User owner = saveOwner();
+ User roleMember = saveUserWithRole(role);
+ Document document = saveDocument(owner, DocumentStatus.INDEXED);
+
+ DocumentCollection parent = saveCollection(owner, null);
+ DocumentCollection child = saveCollection(owner, parent);
+ addToCollection(child, document, owner);
+ grantRoleReadPermission(parent, role, owner);
+ flushAndClear();
+
+ List readableIds = documentRepository.findReadableDocumentIds(roleMember.getId(), INDEXED_ONLY);
+ List readableIdsInChildCollection = documentRepository.findReadableDocumentIdsInCollection(
+ roleMember.getId(), child.getId(), INDEXED_ONLY
+ );
+
+ assertThat(readableIds).contains(document.getId());
+ assertThat(readableIdsInChildCollection).containsExactly(document.getId());
+ }
+
+ private Department saveDepartment() {
+ return departmentRepository.save(
+ Department.builder()
+ .name("읽기 가능 문서 테스트 부서")
+ .code("RID-DEPT-" + UUID.randomUUID())
+ .status(CommonStatus.ACTIVE)
+ .build()
+ );
+ }
+
+ private User saveUserInDepartment(Department department) {
+ return userRepository.save(
+ User.builder()
+ .department(department)
+ .email("readable-ids-dept-" + UUID.randomUUID() + "@test.com")
+ .passwordHash("hash")
+ .name("읽기 가능 문서 테스트 부서원")
+ .status(UserStatus.ACTIVE)
+ .build()
+ );
+ }
+
+ private void grantDepartmentReadPermission(DocumentCollection collection, Department department, User grantedBy) {
+ collectionPermissionRepository.save(
+ CollectionPermission.builder()
+ .collection(collection)
+ .targetType(PermissionTargetType.DEPARTMENT)
+ .department(department)
+ .permissionType(PermissionType.READ)
+ .canRead(true)
+ .canWrite(false)
+ .canAdmin(false)
+ .grantedBy(grantedBy)
+ .grantedAt(LocalDateTime.now())
+ .build()
+ );
+ }
+
+ private Role saveRole() {
+ return roleRepository.save(
+ Role.builder()
+ .name("읽기 가능 문서 테스트 역할")
+ .code("RID-ROLE-" + UUID.randomUUID())
+ .build()
+ );
+ }
+
+ private User saveUserWithRole(Role role) {
+ User user = userRepository.save(
+ User.builder()
+ .email("readable-ids-role-" + UUID.randomUUID() + "@test.com")
+ .passwordHash("hash")
+ .name("읽기 가능 문서 테스트 역할 보유자")
+ .status(UserStatus.ACTIVE)
+ .build()
+ );
+ userRoleRepository.save(
+ UserRole.builder()
+ .user(user)
+ .role(role)
+ .assignedAt(LocalDateTime.now())
+ .build()
+ );
+ return user;
+ }
+
+ private void grantRoleReadPermission(DocumentCollection collection, Role role, User grantedBy) {
+ collectionPermissionRepository.save(
+ CollectionPermission.builder()
+ .collection(collection)
+ .targetType(PermissionTargetType.ROLE)
+ .role(role)
+ .permissionType(PermissionType.READ)
+ .canRead(true)
+ .canWrite(false)
+ .canAdmin(false)
+ .grantedBy(grantedBy)
+ .grantedAt(LocalDateTime.now())
+ .build()
+ );
+ }
+
private User saveOwner() {
return userRepository.save(
User.builder()
@@ -134,10 +295,11 @@ private Document saveDocument(User owner, DocumentStatus status) {
);
}
- private DocumentCollection saveCollection(User owner) {
+ private DocumentCollection saveCollection(User owner, DocumentCollection parent) {
return collectionRepository.save(
DocumentCollection.builder()
.owner(owner)
+ .parentCollection(parent)
.name("읽기 가능 문서 테스트 컬렉션")
.visibility(VisibilityType.PRIVATE)
.build()
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java
index 775c2d33..2289b9e0 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java
@@ -20,17 +20,29 @@ public class PermissionFixture {
public static final Long PERMISSION_ID = 30L;
public static final Long ROLE_ID = 2L;
+ public static final Long USER_ROLE_ID = 1L;
public static final Long DEPARTMENT_ID = 3L;
private PermissionFixture() {
}
+ // 권한 부여 대상으로 쓸 수 있는 role (ADMIN) — USER role은 전원이 보유해 권한 부여 대상이 될 수 없다.
public static Role createRole() {
+ Role role = Role.builder()
+ .code("ADMIN")
+ .name("관리자")
+ .build();
+ ReflectionTestUtils.setField(role, "id", ROLE_ID);
+ return role;
+ }
+
+ // 권한 부여 거부 케이스 검증용 — 모든 사용자가 기본으로 가진 USER role
+ public static Role createUserRole() {
Role role = Role.builder()
.code("USER")
.name("일반 사용자")
.build();
- ReflectionTestUtils.setField(role, "id", ROLE_ID);
+ ReflectionTestUtils.setField(role, "id", USER_ROLE_ID);
return role;
}
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java
index 8ad004ca..737e0fae 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java
@@ -108,6 +108,25 @@ void grantPermission_role_noCacheUpdate() {
any(boolean.class), any(boolean.class), any(), any(), any());
}
+ @Test
+ @DisplayName("USER role을 대상으로 지정하면 ROLE_NOT_GRANTABLE 예외가 발생한다")
+ void grantPermission_throws_when_targetRoleIsUserRole() {
+ User owner = CollectionFixture.createOwner();
+ DocumentCollection collection = CollectionFixture.createCollection(owner);
+ GrantPermissionRequest request = new GrantPermissionRequest(
+ PermissionTargetType.ROLE, null, PermissionFixture.USER_ROLE_ID, null, PermissionType.READ, null);
+
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection));
+ given(permissionQueryService.canAdminCollection(CollectionFixture.USER_ID, collection)).willReturn(true);
+ given(roleRepository.findById(PermissionFixture.USER_ROLE_ID)).willReturn(Optional.of(PermissionFixture.createUserRole()));
+
+ assertThatThrownBy(() -> service.grantPermission(
+ CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request))
+ .isInstanceOf(DocGridException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_NOT_GRANTABLE);
+ then(collectionPermissionRepository).should(never()).save(any(CollectionPermission.class));
+ }
+
@Test
@DisplayName("컬렉션이 없으면 COLLECTION_NOT_FOUND 예외가 발생한다")
void grantPermission_throws_when_collectionNotFound() {
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java
index 6695a272..842df77a 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java
@@ -93,6 +93,25 @@ void grantPermission_role_noCacheUpdate() {
any(boolean.class), any(boolean.class), any(), any(), any());
}
+ @Test
+ @DisplayName("USER role을 대상으로 지정하면 ROLE_NOT_GRANTABLE 예외가 발생한다")
+ void grantPermission_throws_when_targetRoleIsUserRole() {
+ User owner = CollectionFixture.createOwner();
+ Document document = CollectionFixture.createDocument(owner);
+ GrantPermissionRequest request = new GrantPermissionRequest(
+ PermissionTargetType.ROLE, null, PermissionFixture.USER_ROLE_ID, null, PermissionType.READ, null);
+
+ given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document));
+ given(permissionQueryService.canAdminDocument(CollectionFixture.USER_ID, CollectionFixture.DOCUMENT_ID)).willReturn(true);
+ given(roleRepository.findById(PermissionFixture.USER_ROLE_ID)).willReturn(Optional.of(PermissionFixture.createUserRole()));
+
+ assertThatThrownBy(() -> service.grantPermission(
+ CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request))
+ .isInstanceOf(DocGridException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_NOT_GRANTABLE);
+ then(documentPermissionRepository).should(never()).save(any(DocumentPermission.class));
+ }
+
@Test
@DisplayName("문서가 없으면 DOCUMENT_NOT_FOUND 예외가 발생한다")
void grantPermission_throws_when_documentNotFound() {
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java
index 3fad9a81..8b82b4d0 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryServiceTest.java
@@ -131,6 +131,27 @@ void canReadDocument_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canReadDocument가 true다 (상속)")
+ void canReadDocument_inheritedFromParentCollection_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Document document = CollectionFixture.createDocument(owner);
+ Long otherUserId = 99L;
+ List effectiveCollectionIds = List.of(1L, 2L);
+ given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document));
+ given(cacheRepository.existsValidReadCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsRoleReadPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsRoleReadPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsDeptReadPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptReadPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds);
+ given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true);
+
+ boolean result = service.canReadDocument(otherUserId, CollectionFixture.DOCUMENT_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("모든 단계를 통과하지 못하면 canReadDocument가 false다")
void canReadDocument_noPermission_returnsFalse() {
@@ -222,6 +243,27 @@ void canWriteDocument_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canWriteDocument가 true다 (상속)")
+ void canWriteDocument_inheritedFromParentCollection_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Document document = CollectionFixture.createDocument(owner);
+ Long otherUserId = 99L;
+ List effectiveCollectionIds = List.of(1L, 2L);
+ given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document));
+ given(cacheRepository.existsValidWriteCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsRoleWritePermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsRoleWritePermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsDeptWritePermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptWritePermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds);
+ given(collectionPermissionRepository.existsDeptWritePermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true);
+
+ boolean result = service.canWriteDocument(otherUserId, CollectionFixture.DOCUMENT_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("모든 단계를 통과하지 못하면 canWriteDocument가 false다")
void canWriteDocument_noPermission_returnsFalse() {
@@ -303,6 +345,27 @@ void canAdminDocument_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("부모 컬렉션에만 ROLE/DEPARTMENT 권한이 있어도 canAdminDocument가 true다 (상속)")
+ void canAdminDocument_inheritedFromParentCollection_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Document document = CollectionFixture.createDocument(owner);
+ Long otherUserId = 99L;
+ List effectiveCollectionIds = List.of(1L, 2L);
+ given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document));
+ given(cacheRepository.existsValidAdminCache(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsRoleAdminPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsRoleAdminPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(documentPermissionRepository.existsDeptAdminPermission(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptAdminPermissionForDocument(otherUserId, CollectionFixture.DOCUMENT_ID)).willReturn(false);
+ given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds);
+ given(collectionPermissionRepository.existsDeptAdminPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true);
+
+ boolean result = service.canAdminDocument(otherUserId, CollectionFixture.DOCUMENT_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("모든 단계를 통과하지 못하면 canAdminDocument가 false다")
void canAdminDocument_noPermission_returnsFalse() {
@@ -402,6 +465,29 @@ void canReadCollection_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canReadCollection이 true다 (상속)")
+ void canReadCollection_inheritedFromAncestor_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Long otherUserId = 99L;
+ List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L);
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID))
+ .willReturn(Optional.of(CollectionFixture.createCollection(owner)));
+ given(collectionPermissionRepository.existsUserReadPermission(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsRoleReadPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsDeptReadPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds);
+ given(collectionPermissionRepository.existsRoleReadPermissionForCollections(otherUserId, ancestorIds)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, ancestorIds)).willReturn(true);
+
+ boolean result = service.canReadCollection(otherUserId, CollectionFixture.COLLECTION_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("권한이 없으면 canReadCollection이 false다")
void canReadCollection_noPermission_returnsFalse() {
@@ -522,6 +608,29 @@ void canWriteCollection_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canWriteCollection이 true다 (상속)")
+ void canWriteCollection_inheritedFromAncestor_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Long otherUserId = 99L;
+ List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L);
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID))
+ .willReturn(Optional.of(CollectionFixture.createCollection(owner)));
+ given(collectionPermissionRepository.existsUserWritePermission(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsRoleWritePermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsDeptWritePermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds);
+ given(collectionPermissionRepository.existsRoleWritePermissionForCollections(otherUserId, ancestorIds)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptWritePermissionForCollections(otherUserId, ancestorIds)).willReturn(true);
+
+ boolean result = service.canWriteCollection(otherUserId, CollectionFixture.COLLECTION_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("권한이 없으면 canWriteCollection이 false다")
void canWriteCollection_noPermission_returnsFalse() {
@@ -630,6 +739,24 @@ void checkDocumentPermission_dept_returnsPermissionWithDeptSource() {
assertThat(result.sources()).containsExactly(PermissionSourceType.DEPARTMENT);
}
+ @Test
+ @DisplayName("부모 컬렉션에만 DEPARTMENT 권한이 있어도 canRead가 true이고 sources에 DEPARTMENT가 포함된다 (상속)")
+ void checkDocumentPermission_inheritedFromParentCollection_returnsReadTrueWithDeptSource() {
+ User owner = CollectionFixture.createOwner();
+ Document document = CollectionFixture.createDocument(owner);
+ Long otherUserId = 99L;
+ List effectiveCollectionIds = List.of(1L, 2L);
+ given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document));
+ given(collectionRepository.findEffectiveCollectionIdsForDocument(CollectionFixture.DOCUMENT_ID)).willReturn(effectiveCollectionIds);
+ given(collectionPermissionRepository.existsDeptReadPermissionForCollections(otherUserId, effectiveCollectionIds)).willReturn(true);
+
+ DocumentPermissionSummaryResponse result =
+ service.checkDocumentPermission(otherUserId, CollectionFixture.DOCUMENT_ID);
+
+ assertThat(result.canRead()).isTrue();
+ assertThat(result.sources()).containsExactly(PermissionSourceType.DEPARTMENT);
+ }
+
@Test
@DisplayName("ROLE과 USER_CACHE가 동시에 충족되면 sources에 둘 다 포함된다")
void checkDocumentPermission_multipleSource_returnsBothSources() {
@@ -743,6 +870,29 @@ void canAdminCollection_deptLive_returnsTrue() {
assertThat(result).isTrue();
}
+ @Test
+ @DisplayName("조상 컬렉션에만 DEPARTMENT 권한이 있어도 canAdminCollection이 true다 (상속)")
+ void canAdminCollection_inheritedFromAncestor_returnsTrue() {
+ User owner = CollectionFixture.createOwner();
+ Long otherUserId = 99L;
+ List ancestorIds = List.of(CollectionFixture.COLLECTION_ID, 1L);
+ given(collectionRepository.findById(CollectionFixture.COLLECTION_ID))
+ .willReturn(Optional.of(CollectionFixture.createCollection(owner)));
+ given(collectionPermissionRepository.existsUserAdminPermission(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsRoleAdminPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionPermissionRepository.existsDeptAdminPermissionForCollection(otherUserId, CollectionFixture.COLLECTION_ID))
+ .willReturn(false);
+ given(collectionRepository.findAncestorIdsInclusive(CollectionFixture.COLLECTION_ID)).willReturn(ancestorIds);
+ given(collectionPermissionRepository.existsRoleAdminPermissionForCollections(otherUserId, ancestorIds)).willReturn(false);
+ given(collectionPermissionRepository.existsDeptAdminPermissionForCollections(otherUserId, ancestorIds)).willReturn(true);
+
+ boolean result = service.canAdminCollection(otherUserId, CollectionFixture.COLLECTION_ID);
+
+ assertThat(result).isTrue();
+ }
+
@Test
@DisplayName("권한이 없으면 canAdminCollection이 false다")
void canAdminCollection_noPermission_returnsFalse() {
diff --git a/docs/design/kangcheolung-#16-collection-crud.md b/docs/design/kangcheolung-#16-collection-crud.md
index ef0f7b42..9c422aab 100644
--- a/docs/design/kangcheolung-#16-collection-crud.md
+++ b/docs/design/kangcheolung-#16-collection-crud.md
@@ -24,7 +24,7 @@ CollectionController.createCollection()
│
▼
CollectionCommandService.createCollection()
- ├─ parentCollectionId 있으면 상위 컬렉션 존재 확인
+ ├─ parentCollectionId 있으면 상위 컬렉션 존재 확인 (+ #229부터 쓰기권한 확인도 추가됨)
├─ visibility 미입력 시 PRIVATE 기본값 적용
└─ DocumentCollection 저장 (status=ACTIVE, owner=요청자)
│
@@ -99,7 +99,7 @@ public class DocumentCollection extends BaseEntity {
```
- `visibility`는 `document` 도메인의 `VisibilityType`(`PRIVATE`/`COLLECTION`/`DEPARTMENT`/`PUBLIC`)을 그대로 재사용한다 — 문서와 컬렉션이 같은 공개범위 개념을 공유하므로 별도 enum을 새로 만들지 않았다.
-- `parentCollection`이 self-FK라 컬렉션 트리(폴더 계층) 구조를 표현할 수 있지만, 이번 이슈에서는 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않았다.
+- ~~`parentCollection`이 self-FK라 컬렉션 트리(폴더 계층) 구조를 표현할 수 있지만, 이번 이슈에서는 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않았다.~~ → **#229에서 실제로 구현됨**: `GET /collections/{id}/children`(직계 자식 조회), 생성 시 부모 쓰기권한 체크, 부모→자식 권한 상속, cascade 삭제까지 전부 추가됨. 아래 "이후 업데이트" 절 참고.
- 삭제는 `markDeleted()`로 `status`/`deletedAt`만 바꾸는 soft delete다 (`#29`에서 실제로 호출).
### 2. `domain/collection/entity/CollectionDocument.java`
@@ -317,6 +317,15 @@ BUILD SUCCESSFUL
- `CollectionStatus.ARCHIVED`는 정의만 되어 있고 전환 로직이 없다.
- ~~`CollectionPermission`/`DocumentPermission` 엔티티의 Javadoc에 이미 명시된 TODO: `target_type`별로 단일 FK만 채워져야 한다는 규칙이 DB CHECK 제약으로 강제되지 않고 애플리케이션 검증(`validateTargetType()`, `#18`)에만 의존한다.~~ → 확인 결과 이미 해결되어 있음: `V11__create_collection_permissions.sql`/`V12__create_document_permissions.sql`에 `CHECK` 제약이 반영되어 있다(엔티티 Javadoc만 갱신되지 않은 상태였음).
+## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리)
+
+수동 QA(시나리오 4) 중 `parentCollectionId`가 스키마에만 있고 실제로 死코드라는 게 재발견되어, 이슈 #229에서 실제 트리 기능으로 완성했다. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. 이 문서와 직접 관련된 변경만 요약:
+
+- `createCollection()`에 부모 컬렉션 **쓰기권한 체크**(`canWriteCollection(parent)`) 추가 — 예전엔 부모 존재 여부만 확인해서, 남의 컬렉션 밑에도 마음대로 자식을 매달 수 있는 버그였다.
+- `CollectionRepository`에 `findAllByParentCollectionIdAndStatus`(직계 자식 조회) 신규.
+- `CollectionQueryService`에 `getChildren()` 신규, `GET /collections/{id}/children` 엔드포인트 추가.
+- 순환 참조 방지 로직은 만들지 않았다 — 컬렉션 이동/수정 API가 없어 생성 시점에만 부모를 지정할 수 있고, 존재하지 않는 컬렉션은 자기 자신의 조상이 될 수 없으므로 현재 API 구조상 순환 참조가 원천적으로 불가능하기 때문(검토 완료).
+
## 다음 단계
-`#18`(권한 부여/회수), `#21`(PermissionQueryService), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API — 목록/삭제/문서 제거)로 이어진다.
+`#18`(권한 부여/회수), `#21`(PermissionQueryService), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API — 목록/삭제/문서 제거)로 이어진다. 이후 `#229`(컬렉션 트리 — 하위 컬렉션 지원)로 이어진다.
diff --git a/docs/design/kangcheolung-#18-permission-grant-revoke.md b/docs/design/kangcheolung-#18-permission-grant-revoke.md
index 988715ac..0111f177 100644
--- a/docs/design/kangcheolung-#18-permission-grant-revoke.md
+++ b/docs/design/kangcheolung-#18-permission-grant-revoke.md
@@ -172,6 +172,18 @@ private void validateTargetType(GrantPermissionRequest request) {
```
`targetType`에 안 맞는 ID 필드 조합(예: `USER`인데 `roleId`도 같이 옴)을 400으로 걸러낸다 — 위에서 언급한 "DB CHECK 제약 대신 애플리케이션 검증"이 바로 이 메서드다.
+**(2026-08-18 추가, 관련 작업)** `validateTargetType()` 통과 후 role을 조회하는 지점에 가드가 하나 더 생겼다:
+```java
+} else if (request.targetType() == PermissionTargetType.ROLE) {
+ targetRole = roleRepository.findById(request.roleId())
+ .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND));
+ if ("USER".equals(targetRole.getCode())) {
+ throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE);
+ }
+}
+```
+`USER` role은 가입 시 전원에게 자동 부여되는 기본 role이라, 이걸 대상으로 권한을 부여하면 그룹핑 의미 없이 사실상 전체 공개(`visibility=PUBLIC`보다도 넓은 범위 — PUBLIC은 READ만 열지만 이 경로로는 WRITE/ADMIN도 전체에 열림)가 되는 위험한 함정이었다. `ErrorCode.ROLE_NOT_GRANTABLE`(`PERMISSION-004`, 400)로 차단한다. 자세한 배경은 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고.
+
```java
// 컬렉션 권한(USER)을 부여하면 컬렉션 소속 문서 전체에 캐시를 일괄 갱신한다 (N+1 방지)
private void updateCacheForCollection(Long collectionId, User targetUser, boolean[] permissions,
@@ -317,6 +329,7 @@ BUILD SUCCESSFUL
| 대상 사용자/역할/부서 없음 | 404/400 | `USER-001` / `ROLE-001` / `DEPT-001` |
| `targetType`-ID 필드 조합 오류 | 400 | `PERMISSION-001` |
| 부여자가 ADMIN 권한 없음 | 403 | `ROLE-002`(`PERMISSION_DENIED`) |
+| (2026-08-18 추가) ROLE 대상이 `USER` role임 | 400 | `PERMISSION-004`(`ROLE_NOT_GRANTABLE`) |
---
@@ -342,6 +355,10 @@ BUILD SUCCESSFUL
- `AccessSourceType.OWNER`가 정의만 되어 있고 실제로 생성되지 않는다(위 "확인된 불일치" 참고) — enum에서 제거하거나, 실제로 OWNER 캐시를 생성하도록 코드를 맞추거나 둘 중 하나로 정리가 필요하다.
- ~~`PermissionController`의 컬렉션/문서 권한 부여·회수 4개 엔드포인트 Swagger description이 "소유자(owner)만 가능"이라고 적혀 있던 문제~~ → 코드리뷰로 발견해 실제 인가 규칙(`canAdminCollection()`/`canAdminDocument()`, ADMIN 위임자도 허용)에 맞게 4곳 모두 "ADMIN 권한 보유자(소유자 포함)"로 수정 완료.
+## 이후 업데이트 (2026-08-18, 이슈 #229 관련 작업)
+
+이슈 #229(컬렉션 트리) QA 중 발견한 별개의 보안 개선 — 위 `validateTargetType()` 절에 이미 반영. 상세 배경은 `docs/design/kangcheolung-#229-collection-tree.md` 참고.
+
## 다음 단계
-`#21`(`PermissionQueryService` — 이 이슈에서 만든 권한 데이터를 실제로 판단하는 서비스), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API)로 이어진다.
+`#21`(`PermissionQueryService` — 이 이슈에서 만든 권한 데이터를 실제로 판단하는 서비스), `#24`(문서 권한 확인 API), `#29`(컬렉션 관리 API)로 이어진다. 이후 `#229`(컬렉션 트리)로 이어진다.
diff --git a/docs/design/kangcheolung-#21-permission-query-service.md b/docs/design/kangcheolung-#21-permission-query-service.md
index 7fc116f5..69aeba66 100644
--- a/docs/design/kangcheolung-#21-permission-query-service.md
+++ b/docs/design/kangcheolung-#21-permission-query-service.md
@@ -36,7 +36,7 @@ DEPT 권한 → 매 요청마다 live 조회 (JOIN 여러 번)
이 이슈의 핵심 파일이며, 6개의 권한 판정 그룹(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`canReadCollection`/`canWriteCollection`/`canAdminCollection`, `canReadCollection`은 이후 리팩토링에서 추가됨 — 아래 참고)을 제공한다. 컬렉션 대상 3종은 각각 ID로 조회하는 버전과, 이미 조회된 `DocumentCollection` 엔티티를 받는 버전 2개씩 오버로드로 제공해서 — 문서 3종(오버로드 없음) + 컬렉션 3종(오버로드 2개씩)으로 공개 메서드 시그니처는 총 9개다. 호출부가 이미 엔티티를 들고 있으면 중복 조회 없이 엔티티 버전을 바로 쓸 수 있다.
-#### `canReadDocument` (5단계)
+#### `canReadDocument` (5단계 → **2026-08-18부터 6단계**, 아래 참고)
```java
public boolean canReadDocument(Long userId, Long documentId) {
@@ -72,9 +72,21 @@ public boolean canReadDocument(Long userId, Long documentId) {
**4/5단계가 매번 OR로 문서 직접 권한과 컬렉션 경유 권한을 같이 조회하는 이유**: 기본 권한은 컬렉션 단위(`#16`)로 부여되므로, "이 문서가 속한 컬렉션에 ROLE 권한이 있는지"도 확인해야 한다. `collectionPermissionRepository.existsRoleReadPermissionForDocument()`가 `CollectionPermission JOIN CollectionDocument`로 이걸 처리한다(아래 리포지토리 절 참고).
-#### `canWriteDocument` / `canAdminDocument` (각 4단계)
+**(2026-08-18 추가, 이슈 #229) 6단계: 부모 컬렉션 체인 상속**. 5단계까지 통과 못 하면, 이 문서가 속한 컬렉션(들)의 **조상 컬렉션**에 ROLE/DEPARTMENT 권한이 있는지까지 확인한다:
+```java
+// 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+if (!effectiveCollectionIds.isEmpty()
+ && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds)
+ || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) {
+ return true;
+}
+```
+`findEffectiveCollectionIdsForDocument()`는 `WITH RECURSIVE`로 "이 문서가 속한 컬렉션 전체(N:M) + 그 각각의 조상 전체"를 한 번에 구하는 native 쿼리다. 상세 설계는 `docs/design/kangcheolung-#229-collection-tree.md` 참고. `canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`(`#24`)에도 동일하게 6단계가 추가됐다 — 기존 5단계 로직은 한 줄도 안 건드리고 끝에 새 블록만 이어붙이는 방식으로 넣었다(기존 `PermissionQueryServiceTest` 무변경 통과 확인됨).
-`canReadDocument`와 거의 같은 구조이지만 **PUBLIC 단계가 없다** — PUBLIC은 읽기만 허용하는 개념이라 쓰기/관리 권한 판단에는 끼어들 자리가 없다. 그래서 1단계(소유자) → 2단계(USER 캐시) → 3단계(ROLE) → 4단계(DEPT), 총 4단계다.
+#### `canWriteDocument` / `canAdminDocument` (각 4단계 → **2026-08-18부터 5단계**)
+
+`canReadDocument`와 거의 같은 구조이지만 **PUBLIC 단계가 없다** — PUBLIC은 읽기만 허용하는 개념이라 쓰기/관리 권한 판단에는 끼어들 자리가 없다. 그래서 1단계(소유자) → 2단계(USER 캐시) → 3단계(ROLE) → 4단계(DEPT), 총 4단계였다. 여기에도 위와 동일한 "5단계: 부모 컬렉션 체인 상속"이 이슈 #229에서 추가됐다(`existsRoleWrite/AdminPermissionForCollections`, `existsDeptWrite/AdminPermissionForCollections` 사용).
#### `canReadCollection` / `canWriteCollection` / `canAdminCollection`
@@ -90,7 +102,12 @@ public boolean canWriteCollection(Long userId, DocumentCollection collection) {
Long collectionId = collection.getId();
if (collectionPermissionRepository.existsUserWritePermission(userId, collectionId)) return true;
if (collectionPermissionRepository.existsRoleWritePermissionForCollection(userId, collectionId)) return true;
- return collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId);
+ if (collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId)) return true;
+
+ // (2026-08-18 추가, 이슈 #229) 부모 컬렉션 체인 상속 — 조상 ID 전체를 대상으로 재검사
+ List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId);
+ if (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, ancestorIds)) return true;
+ return collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, ancestorIds);
}
private DocumentCollection getActiveCollection(Long collectionId) {
@@ -101,7 +118,7 @@ private DocumentCollection getActiveCollection(Long collectionId) {
```
`canReadCollection`은 위와 동일한 구조에 **PUBLIC 단계**만 추가된다(`canReadDocument`의 2단계와 동일하게, `collection.getVisibility() == VisibilityType.PUBLIC`이면 소유자/권한 여부와 무관하게 허용). `canAdminCollection`도 같은 뼈대(대상 권한 종류만 다름)다.
-컬렉션 판단에는 **캐시가 없다** — `user_document_access_cache`는 문서 단위 캐시라 컬렉션 자체에 대한 캐시 개념이 없다. 그래서 OWNER, (READ의 경우 PUBLIC), USER 직접 권한, ROLE, DEPT를 매번 순서대로 live 조회한다. `#16`의 `addDocument()`/`getCollection()`, `#18`의 `grantPermission()`이 엔티티 버전을 호출해 중복 조회 없이 이 메서드들을 재사용한다.
+컬렉션 판단에는 **캐시가 없다** — `user_document_access_cache`는 문서 단위 캐시라 컬렉션 자체에 대한 캐시 개념이 없다. 그래서 OWNER, (READ의 경우 PUBLIC), USER 직접 권한, ROLE, DEPT, (2026-08-18부터) 부모 컬렉션 상속을 매번 순서대로 live 조회한다. `#16`의 `addDocument()`/`getCollection()`, `#18`의 `grantPermission()`이 엔티티 버전을 호출해 중복 조회 없이 이 메서드들을 재사용한다.
### `domain/permission/repository/CollectionPermissionRepository.java` — 문서→컬렉션 경유 JOIN 쿼리
@@ -121,6 +138,8 @@ boolean existsRoleReadPermissionForDocument(@Param("userId") Long userId, @Param
```
`CollectionPermission → CollectionDocument → 대상 문서` 경로를 JOIN 하나로 처리한다 — "이 문서가 속한 어떤 컬렉션에, 이 사용자가 속한 역할에 대한 READ 권한이 있는가"를 SQL 레벨에서 한 번에 판단한다. 문서 판단(`*ForDocument` 접미사가 붙은 메서드)과 컬렉션 자체 판단(접미사 없는 `*ForCollection` 메서드) 총 12개의 `existsXxx` 메서드가 이 리포지토리에 있다.
+**(2026-08-18 추가, 이슈 #229)** 여기에 `existsRole/DeptRead/Write/AdminPermissionFor**Collections**`(복수형, `List collectionIds`를 받는 버전) 6개가 더 생겨서 총 18개가 됐다. 단일 ID(`Long`)를 받는 기존 12개는 시그니처를 그대로 유지했고(치환하지 않음), 신규 6개를 "조상 ID 리스트"를 받는 용도로 추가만 했다 — 기존 12개를 치환했다면 이 메서드들을 스텁하는 기존 테스트(`PermissionQueryServiceTest` 등)가 대량으로 깨졌을 것이라, 추가(append) 방식으로 리스크를 낮췄다.
+
### `domain/permission/repository/DocumentPermissionRepository.java` — 문서 직접 권한 JOIN
`CollectionPermissionRepository`의 `*ForDocument` 메서드들과 대응하지만, 컬렉션 경유 없이 `document_permissions`를 바로 조회한다(ROLE/DEPT 조합 6개).
@@ -172,7 +191,7 @@ if (cacheRepository.existsValidReadCache(...)) { ... }
$ ./gradlew test --tests "*PermissionQueryServiceTest*"
BUILD SUCCESSFUL
```
-`PermissionQueryServiceTest` 44개 모두 통과(현재 기준 재검증) — 이 서비스의 권한 판정 그룹 6개(`canReadDocument`, `canWriteDocument`, `canAdminDocument`, `canReadCollection`, `canWriteCollection`, `canAdminCollection`, 오버로드 포함 공개 메서드 시그니처 9개) 각각의 단계별 분기를 검증하는 테스트가 다수 포함되어 있다.
+`PermissionQueryServiceTest` 54개 모두 통과(2026-08-18 기준 재검증, 이슈 #229에서 부모 컬렉션 상속 케이스 7개 추가돼 47→54) — 이 서비스의 권한 판정 그룹 6개(`canReadDocument`, `canWriteDocument`, `canAdminDocument`, `canReadCollection`, `canWriteCollection`, `canAdminCollection`, 오버로드 포함 공개 메서드 시그니처 9개) 각각의 단계별 분기를 검증하는 테스트가 다수 포함되어 있다.
---
@@ -204,6 +223,17 @@ BUILD SUCCESSFUL
- 나노초 기반 성능 로그가 프로덕션에서도 항상 `log.info()`로 남는다 — 트래픽이 많아지면 로그량 자체가 부담일 수 있어 로그 레벨 조정이나 샘플링이 필요할 수 있다.
- `canReadDocument`류 메서드들 사이에 문서 조회(`documentRepository.findById`)가 메서드마다 중복된다 — 셋 다 필요하면(예: `checkDocumentPermission`처럼) 문서 조회를 한 번만 하고 넘기는 내부 메서드로 리팩터링할 여지가 있다. **(참고)** 컬렉션 쪽(`canReadCollection`/`canWriteCollection`/`canAdminCollection`)은 이미 "ID 버전 + 엔티티 버전" 오버로드로 이 문제를 해결했다 — 문서 쪽도 같은 패턴을 그대로 적용하면 된다(별도 후속 작업으로 분리, 이번 라운드는 컬렉션만 처리).
+## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리)
+
+컬렉션 트리 기능이 추가되면서 6개 권한 판정 그룹 전부에 "부모 컬렉션 체인 상속" 단계가 하나씩 더 생겼다(위 각 절에 인라인으로 반영). 핵심 요약:
+
+- 문서 판단 4종(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`)은 `CollectionRepository.findEffectiveCollectionIdsForDocument()`(문서가 속한 컬렉션+그 조상 전체)를 마지막 단계로 추가.
+- 컬렉션 판단 3종(`canReadCollection`/`canWriteCollection`/`canAdminCollection`)은 `CollectionRepository.findAncestorIdsInclusive()`(자기 자신+조상 전체)를 마지막 단계로 추가.
+- 전부 **기존 로직은 안 건드리고 끝에 새 단계만 이어붙이는 방식**으로 넣었다 — 대규모 기존 테스트(`PermissionQueryServiceTest` 817줄, 원래 47개 케이스)를 한 줄도 안 고치고 그대로 통과시키기 위한 선택.
+- 컬렉션 목록(`GET /collections`)도 이번에 owner-only에서 "읽을 수 있는 전체"로 넓어졌는데, 그건 `PermissionQueryService`가 아니라 `CollectionRepository.findReadableCollectionIds()`라는 별도 native 쿼리로 구현했다 — 6개 판정 그룹과는 별개 경로다.
+
+상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고.
+
## 다음 단계
-`#24`(문서 권한 확인 API — 이 서비스의 `checkDocumentPermission()`을 노출), `#29`(컬렉션 관리 API)로 이어진다. 이후 RAG 블록의 `AccessibleDocumentQueryService`(권한 pre-filter)와 `SearchFacade`(live check)가 이 서비스의 `canReadDocument()`를 그대로 재사용한다.
+`#24`(문서 권한 확인 API — 이 서비스의 `checkDocumentPermission()`을 노출), `#29`(컬렉션 관리 API)로 이어진다. 이후 RAG 블록의 `AccessibleDocumentQueryService`(권한 pre-filter)와 `SearchFacade`(live check)가 이 서비스의 `canReadDocument()`를 그대로 재사용한다. 이후 `#229`(컬렉션 트리)로 이어진다.
diff --git a/docs/design/kangcheolung-#229-collection-tree.md b/docs/design/kangcheolung-#229-collection-tree.md
new file mode 100644
index 00000000..9514c383
--- /dev/null
+++ b/docs/design/kangcheolung-#229-collection-tree.md
@@ -0,0 +1,383 @@
+# #229 컬렉션 트리(하위 컬렉션) 지원
+
+closes #229
+
+---
+
+## 배경
+
+`DocumentCollection.parentCollection`(self-FK)은 `#16`(컬렉션 CRUD) 때부터 스키마·엔티티·DTO에 존재했지만, `#16` 설계 문서에 이미 명시된 대로 "생성 시 상위 컬렉션 존재 확인" 정도만 쓰고 트리 순회 API는 만들지 않은 상태였다(Simplicity First — 당장 필요 없는 API를 미리 만들지 않음).
+
+시나리오 5(권한 부여·회수) 수동 QA를 시작하려면 먼저 컬렉션/문서가 있어야 해서 시나리오 4(컬렉션 CRUD)부터 다시 훑다가, `parentCollectionId`가 실질적으로 死코드라는 걸 재발견했다:
+
+- 자식 컬렉션 조회 API가 없어서, 생성 시 부모를 지정해도 나중에 그 관계를 확인할 방법이 없었다.
+- `createCollection()`이 부모 컬렉션의 **존재 여부만** 확인하고 **권한은 확인하지 않아서**, 남의 컬렉션 밑에도 마음대로 자식을 매달 수 있는 버그가 있었다.
+- 부모 컬렉션에 준 권한이 자식 컬렉션·문서에 상속되지 않았다.
+- 컬렉션 삭제가 자기 자신만 지우고 하위 컬렉션은 고아로 남겼다.
+- 프론트(`CollectionsPage.tsx`)도 생성 시 `parentCollectionId`를 항상 `null`로 고정 전송하고 있어 UI로는 절대 하위 컬렉션을 만들 수 없었다.
+
+`#226`(역할 회수 API)과 같은 패턴 — "QA 중 발견 → 근본 원인 파악 → 실제로 고침" — 으로 이 이슈를 만들어 실제 트리 기능을 완성했다. 목표는 파인더/구글드라이브 폴더와 동일한 동작:
+
+- **탐색**: 파인더 방식 — 클릭(하위 조회 API 호출)해야 그 안이 보임 (전체 트리를 한 번에 안 내려줌)
+- **권한**: 구글드라이브 공유폴더 방식 — 부모 컬렉션에 준 DEPARTMENT/ROLE 권한이 자식 컬렉션·문서까지 자동 상속. **문서 단건 조회뿐 아니라 문서 목록·검색 결과에도 동일하게 반영**되도록 스코프를 넓혔다(아래 "왜 목록/검색까지 포함했나" 참고).
+- **삭제**: 실제 폴더 방식 — 부모를 지우면 하위 전체가 cascade soft delete
+
+---
+
+## 왜 목록/검색까지 포함했나 (스코프 결정)
+
+권한 상속을 문서 단건 조회(`PermissionQueryService`)에만 넣으면, "권한은 있는데 폴더를 열거나 검색하면 안 보이는" 모순이 생긴다 — 문서 ID를 직접 알아야만 접근 가능하고, 사람들이 실제로 문서를 찾는 방식(폴더 탐색, 검색)으로는 못 찾는 상태가 된다. 그래서 `DocumentRepository`의 목록/검색 pre-filter native 쿼리 2개까지 이번 스코프에 포함시켰다(사용자가 명시적으로 선택).
+
+---
+
+## 전체 흐름
+
+```text
+POST /collections (parentCollectionId 지정)
+ │
+ ▼
+CollectionCommandService.createCollection()
+ ├─ 부모 컬렉션 존재 확인 (기존과 동일)
+ └─ (신규) 부모 컬렉션 쓰기권한 확인 → 없으면 403
+ │
+ ▼
+GET /collections/{id}/children (신규)
+ │
+ ▼
+CollectionQueryService.getChildren()
+ ├─ 부모 읽기권한 확인
+ ├─ 직계 자식만 조회 (손자는 안 섞임)
+ └─ 자식마다 개별 읽기권한 재확인 (자식 owner/visibility가 부모와 다를 수 있음)
+
+DELETE /collections/{id} (cascade로 확장)
+ │
+ ▼
+CollectionCommandService.deleteCollection()
+ ├─ root owner 1회만 확인 (하위는 재확인 안 함)
+ ├─ 자기 자신 + 모든 후손 ID 재귀 조회
+ ├─ 대상 전체의 권한 삭제 + USER 캐시 무효화
+ ├─ 대상 전체의 문서 매핑(collection_documents) 삭제 (신규 — 예전엔 안 지웠음)
+ └─ 대상 전체 soft delete
+
+문서/컬렉션 권한 판단 (PermissionQueryService, DocumentRepository)
+ │
+ ▼
+기존 단계 전부 통과 못하면
+ └─ (신규) 마지막 단계: 부모 컬렉션 체인 상속 확인
+```
+
+---
+
+## 신규/변경 파일
+
+### 1. `domain/collection/repository/CollectionRepository.java` — 재귀 쿼리 4개 + 자식 조회
+
+```java
+// 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children)
+List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status);
+
+/**
+ * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용).
+ * 삭제된 조상도 결과에 포함한다 — 삭제된 컬렉션은 권한이 비어있어 무해하고,
+ * status 필터를 넣으면 중간 조상이 삭제됐을 때 그 위 조상으로 체인이 끊기는 문제가 생긴다.
+ */
+@Query(value = """
+ WITH RECURSIVE ancestors AS (
+ SELECT id, parent_collection_id FROM collections WHERE id = :collectionId
+ UNION ALL
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ JOIN ancestors a ON c.id = a.parent_collection_id
+ )
+ SELECT id FROM ancestors
+ """, nativeQuery = true)
+List findAncestorIdsInclusive(@Param("collectionId") Long collectionId);
+
+// 자기 자신 + 모든 후손 컬렉션 ID (cascade 삭제 대상 판단용) — 위와 대칭 구조, parent_collection_id로 아래로 내려감
+List findDescendantIdsInclusive(@Param("collectionId") Long collectionId);
+
+/**
+ * 문서가 속한 모든 컬렉션(N:M) + 그 컬렉션들 각각의 조상 전체 ID (문서 권한 상속 판단용).
+ */
+@Query(value = """
+ WITH RECURSIVE ancestors AS (
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ WHERE c.id IN (
+ SELECT DISTINCT cd.collection_id FROM collection_documents cd WHERE cd.document_id = :documentId
+ )
+ UNION ALL
+ SELECT c.id, c.parent_collection_id
+ FROM collections c
+ JOIN ancestors a ON c.id = a.parent_collection_id
+ )
+ SELECT DISTINCT id FROM ancestors
+ """, nativeQuery = true)
+List findEffectiveCollectionIdsForDocument(@Param("documentId") Long documentId);
+```
+
+네이티브 재귀 쿼리(`WITH RECURSIVE`)가 코드베이스에 이번이 처음이라, 기존 컨벤션(`DocumentRepository.findReadableDocumentIds*` — `@Query(nativeQuery=true)`, snake_case, `List` 반환 후 2차 필터링에 사용)을 그대로 따랐다.
+
+**순환 참조 방지 로직은 만들지 않았다.** 컬렉션 이동/수정 API가 없어서 생성 시점에만 부모를 지정할 수 있고, 아직 존재하지 않는 컬렉션은 자기 자신의 조상이 될 수 없으므로 현재 API 구조상 순환 참조가 원천적으로 불가능하다(검토 완료). 나중에 컬렉션 이동 API가 생기면 그때 재검토해야 한다.
+
+### 2. `domain/collection/service/command/CollectionCommandService.java` — 부모 권한 체크 + cascade 삭제
+
+```java
+// 폴더 생성
+public CollectionResponse createCollection(Long userId, CreateCollectionRequest request) {
+ User owner = userRepository.getReferenceById(userId);
+
+ DocumentCollection parentCollection = null;
+ if (request.parentCollectionId() != null) {
+ parentCollection = collectionRepository.findById(request.parentCollectionId())
+ .filter(c -> c.getStatus() != CollectionStatus.DELETED)
+ .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
+ if (!permissionQueryService.canWriteCollection(userId, parentCollection)) { // 신규
+ throw new DocGridException(ErrorCode.PERMISSION_DENIED);
+ }
+ }
+ // ... 이하 기존과 동일
+}
+
+// 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다.
+// owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다
+// (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐).
+public void deleteCollection(Long collectionId, Long userId) {
+ DocumentCollection root = collectionRepository.findById(collectionId)
+ .filter(c -> c.getStatus() != CollectionStatus.DELETED)
+ .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
+
+ if (!root.getOwner().getId().equals(userId)) {
+ throw new DocGridException(ErrorCode.PERMISSION_DENIED);
+ }
+
+ List targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함
+
+ List permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds);
+ permissions.stream()
+ .filter(p -> p.getTargetType() == PermissionTargetType.USER)
+ .forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId()));
+ collectionPermissionRepository.deleteAll(permissions);
+
+ List mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds);
+ collectionDocumentRepository.deleteAll(mappings); // 신규 — #29 원래 버전은 문서 매핑을 안 지웠음
+
+ LocalDateTime now = LocalDateTime.now();
+ collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now));
+}
+```
+
+`createCollection()`에서 부모의 **엔티티 오버로드**(`canWriteCollection(userId, DocumentCollection)`)를 쓰는 부수 효과로, 내부 `validateActiveCollection()`이 "삭제된 부모 아래 생성 금지"도 자동으로 막아준다(별도 코드 없이 잠재 버그 하나 더 해결).
+
+`deleteCollection()`의 owner 체크가 root 1회뿐이라는 트레이드오프: 부모에 쓰기권한만 있는 사람도 자식 컬렉션을 만들 수 있으므로(위 `createCollection()` 변경), 이론상 자식의 owner가 root owner와 다를 수 있다. 그래도 root owner가 cascade 삭제를 실행할 수 있다 — 구글드라이브에서 상위 폴더 소유자가 하위 폴더(다른 사람이 만든 것 포함)까지 지울 수 있는 것과 같은 모델.
+
+### 3. `domain/collection/service/query/CollectionQueryService.java` / `controller` — `GET /collections/{id}/children`
+
+```java
+// 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인
+// (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨)
+public List getChildren(Long userId, Long collectionId) {
+ DocumentCollection parent = collectionRepository.findById(collectionId)
+ .filter(c -> c.getStatus() != CollectionStatus.DELETED)
+ .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
+ if (!permissionQueryService.canReadCollection(userId, parent)) {
+ throw new DocGridException(ErrorCode.PERMISSION_DENIED);
+ }
+
+ return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE)
+ .stream()
+ .filter(child -> permissionQueryService.canReadCollection(userId, child))
+ .map(collectionConverter::toResponse)
+ .toList();
+}
+```
+자식 개수가 적을 것으로 예상해 N+1(자식마다 `canReadCollection` 호출)을 허용했다 — 문서 목록처럼 대량+SQL prefilter로 짜는 건 지금 스코프에서 오버엔지니어링으로 판단. 페이지네이션도 없이 `List` 반환(`getMyCollections()`와 동일 컨벤션, 자식 수가 적을 거라는 전제).
+
+### 4. `domain/permission/service/query/PermissionQueryService.java` — 6개 판정 그룹 전부에 상속 단계 추가
+
+문서 판단 4종(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`)과 컬렉션 판단 3종(`canReadCollection`/`canWriteCollection`/`canAdminCollection`, 엔티티 오버로드) 전부에 마지막 단계로 상속 체크가 추가됐다. 예시(`canReadDocument`):
+
+```java
+// 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT)
+List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+if (!effectiveCollectionIds.isEmpty()
+ && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds)
+ || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) {
+ return true;
+}
+```
+컬렉션 판단(예: `canReadCollection` 엔티티 오버로드)은 `findAncestorIdsInclusive`를 쓴다는 것만 다르고 구조는 동일:
+```java
+List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId);
+if (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, ancestorIds)) return true;
+return collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, ancestorIds);
+```
+
+**전부 기존 로직 뒤에 append만 했다** (기존 줄은 한 글자도 안 고침) — 대안으로 기존 12개 `existsXxxForCollection`/`existsXxxForDocument` 메서드 시그니처를 `List` 받게 바꾸는 방법도 검토했지만, 그러면 `PermissionQueryServiceTest`(817줄, 기존 47개 케이스가 그 메서드들을 개별 스텁)가 대량으로 깨진다. 대신 `List` 버전 6개(`existsRole/DeptRead/Write/AdminPermissionForCollections`, 복수형)를 **신규 추가**해서 기존 스텁 안 된 메서드는 Mockito가 기본값(`false`/`[]`)을 반환 → 기존 테스트 47개 전부 무변경 통과.
+
+`checkDocumentPermission()`의 6단계는 새 `PermissionSourceType` 값을 만들지 않고 기존 `ROLE`/`DEPARTMENT`를 재사용한다 — API 응답 스키마 불변(프론트 영향 없음), 트레이드오프는 "직접 부여 vs 조상 상속"을 이 응답만으로 구분 못 한다는 것(감사 시 조상까지 직접 추적 필요).
+
+### 5. `domain/document/repository/DocumentRepository.java` — 목록/검색 pre-filter에도 상속 반영
+
+`findReadableDocumentIds`(전체 목록/검색), `findReadableDocumentIdsInCollection`(컬렉션 내 목록) 두 native UNION 쿼리 최상단에 컬렉션 조상 closure CTE를 추가하고, 컬렉션 ROLE/DEPARTMENT 브랜치의 JOIN 조건을 이 closure를 거치도록 바꿨다:
+
+```sql
+WITH RECURSIVE collection_ancestors AS (
+ SELECT id AS collection_id, id AS ancestor_id FROM collections
+ UNION ALL
+ SELECT ca.collection_id, c.parent_collection_id AS ancestor_id
+ FROM collection_ancestors ca
+ JOIN collections c ON c.id = ca.ancestor_id
+ WHERE c.parent_collection_id IS NOT NULL
+)
+SELECT d.id FROM documents d
+ ...
+UNION
+SELECT d.id FROM documents d
+ JOIN collection_documents cd ON cd.document_id = d.id
+ JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id -- 변경 지점
+ JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id -- 변경 지점 (기존: cp.collection_id = cd.collection_id)
+ JOIN user_roles ur ON ur.role_id = cp.role_id
+WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
+ ...
+```
+`ancestor_id`가 자기 자신(distance 0)을 포함하므로 기존 "직접 권한" 케이스도 이 브랜치 하나로 그대로 커버된다 — 별도 브랜치 추가가 아니라 순수 대체. `findReadableDocumentIdsInCollection`의 바깥쪽 `WHERE sub.id IN (SELECT document_id FROM collection_documents WHERE collection_id = :collectionId)`는 그대로 유지 — "이 컬렉션에 직접 속한 문서만 나열"이라는 폴더 탐색 시맨틱(하위 폴더 문서가 상위 목록에 안 섞임)은 안 바뀌어야 한다.
+
+호출부(`CollectionQueryService.getCollectionDocuments`, `DocumentQueryService`, `AccessibleDocumentQueryService`/`SearchFacade`)는 수정 불필요 — 쿼리 결과가 정확해지면 자동 반영된다.
+
+### 6. 프론트 — `CollectionsPage.tsx`
+
+- 생성 모달에 "상위 폴더" 드롭다운 추가 (선택 안 하면 최상위)
+- 컬렉션 상세 페이지에 "하위 컬렉션" 섹션 추가 — `GET /collections/{id}/children` 조회, 클릭하면 그 컬렉션 상세로 이동(파인더처럼 한 단계씩 열람, 트리를 한 번에 펼치지 않음)
+- 삭제 확인 문구를 하위 컬렉션이 있을 때만 cascade 경고로 분기:
+```ts
+const warning = children.length
+ ? "이 컬렉션을 삭제할까요? 하위 컬렉션과 그 안의 문서도 전부 함께 삭제됩니다. 복구 API는 제공되지 않습니다."
+ : "이 컬렉션을 삭제할까요? 복구 API는 제공되지 않습니다.";
+```
+
+---
+
+## 관련 작업 (같은 세션에서 QA 중 발견해서 이어서 구현, #229 본편은 아님)
+
+컬렉션 트리 자체는 아니지만 같은 QA 흐름에서 발견해서 같이 처리한 3건. GitHub 이슈 #229 body에도 "🔗 QA 중 함께 발견·구현한 관련 작업" 섹션으로 반영해뒀다.
+
+### A. ROLE=USER 권한부여 시 전체공개되는 위험 차단
+
+`USER` role은 가입 시 전원에게 자동 부여되는 기본 role이다. `targetType=ROLE`로 권한을 부여할 때 이 role을 대상으로 지정하면, ROLE 판단 쿼리(`JOIN user_roles ur ON ur.role_id = cp.role_id`)가 "이 role_id를 가진 모든 사용자"를 매칭하므로 사실상 전 직원한테 뚫린다 — `visibility=PUBLIC`보다도 넓은 범위(PUBLIC은 READ만 열지만 이 경로는 WRITE/ADMIN도 전체 공개 가능).
+
+```java
+// ErrorCode.java
+ROLE_NOT_GRANTABLE(HttpStatus.BAD_REQUEST, "PERMISSION-004",
+ "USER role은 모든 사용자가 보유하고 있어 권한 부여 대상으로 지정할 수 없습니다. 전체 공개가 목적이면 visibility를 PUBLIC으로 설정하세요."),
+```
+```java
+// DocumentPermissionCommandService / CollectionPermissionCommandService — grantPermission()
+} else if (request.targetType() == PermissionTargetType.ROLE) {
+ targetRole = roleRepository.findById(request.roleId())
+ .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND));
+ if ("USER".equals(targetRole.getCode())) {
+ throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE);
+ }
+}
+```
+ADMIN role은 문서/컬렉션 접근에 아무 특별 취급이 없다(`/admin/**` API만 열어줌 — `SecurityConfig`)는 것도 이번에 재확인했다. "ADMIN이면 다 보이겠지"는 착각이라, ROLE=USER 위험을 막을 별도 안전망이 없다는 근거로 이 가드가 더 중요해졌다.
+
+### B. `GET /roles` 신규 + 권한 부여 폼 이름 드롭다운
+
+권한 부여 폼에서 ROLE/DEPARTMENT 대상 ID를 숫자로 외워서 입력해야 하는 게 비현실적이었다. `DepartmentController`(`GET /departments`)와 동일한 패턴으로 `RoleController`/`RoleQueryService`/`RoleResponse` 신규 추가(`GET /roles`, 인증 필요 — `/departments`는 회원가입 화면용이라 `permitAll`이지만 역할 선택은 로그인 후에만 쓰이므로 기본 인증 유지).
+
+프론트 `PermissionsPage.tsx`의 "권한 부여" 폼에서 `targetType`을 controlled state로 바꾸고, "대상 ID" 필드를 분기:
+- USER: 숫자 input 유지 (전체 사용자 목록 조회는 관리자 전용이라 이름 드롭다운으로 못 바꿈)
+- ROLE: `roles.filter(role => role.code !== "USER")` 이름 드롭다운 — 위 A번 가드와 자연스럽게 맞물려 USER는 UI에서부터 선택 불가
+- DEPARTMENT: `departments` 이름 드롭다운
+
+### C. 컬렉션 목록(`GET /collections`) 권한 반영 + 페이지네이션 + 검색
+
+`CollectionQueryService.getMyCollections()`(owner만, `List` 반환)가 문서 목록(`GET /api/documents`, owner+PUBLIC+권한부여 전부 포함)과 비대칭이라는 게 QA 중 재발견됐다. `getCollections(userId, keyword, page, size)`로 완전히 교체:
+
+```java
+// CollectionRepository — findReadableDocumentIds와 동일한 UNION 패턴, keyword는 nullable
+@Query(value = """
+ WITH RECURSIVE collection_ancestors AS ( ... ) -- 위 5번과 동일한 closure
+ SELECT c.id FROM collections c
+ WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE'
+ AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
+ UNION
+ SELECT c.id FROM collections c
+ WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' AND (:keyword IS NULL OR ...)
+ UNION -- USER 직접 권한
+ UNION -- ROLE (조상 상속 포함)
+ UNION -- DEPARTMENT (조상 상속 포함)
+ """, nativeQuery = true)
+List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword);
+
+@Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids")
+Page findAllByIdIn(@Param("ids") List ids, Pageable pageable);
+```
+`GET /collections?keyword=&page=&size=`. 검색은 문서처럼 임베딩 기반이 아니라 이름/설명 단순 `ILIKE` 필터 — 컬렉션은 구조화된 메타데이터뿐이라 무거운 semantic search가 필요 없다고 판단. 안 쓰이게 된 `findAllByOwnerIdAndStatus()`는 삭제.
+
+프론트 `CollectionsPage.tsx` 메인 목록에 페이지네이션 컨트롤 추가(`CollectionDetailPage`의 기존 패턴 재사용), 검색창은 `AdminPages.tsx`의 기존 `toolbar`/`toolbar-search`+`toQuery` 패턴 재사용. `SearchPage.tsx`/`PermissionsPage.tsx`의 컬렉션 드롭다운도 응답이 `PageResponse`로 바뀐 데 맞춰 `.content` 사용하도록 수정(각각 `size=100`으로 넉넉히 조회).
+
+---
+
+## 로컬 검증
+
+Swagger/프론트 수동 QA는 진행했으나, 항목별 pass/fail 세부 기록은 별도 정리 전. 아래는 자동 테스트 기준.
+
+### 자동 테스트
+
+```bash
+$ ./backend/gradlew -p backend test --tests "com.opensource.docgrid.domain.document.*" \
+ --tests "com.opensource.docgrid.domain.collection.*" --tests "com.opensource.docgrid.domain.permission.*" \
+ --tests "com.opensource.docgrid.domain.search.*" --tests "com.opensource.docgrid.domain.user.*"
+```
+document/collection/permission/search/user 도메인 전체 통과(354개 중 354개, 2026-08-18 재검증분 기준). 실패 9개(`DocumentUploadIntegrationTest`/`DocumentVersionUploadIntegrationTest`/`DocumentStatusRepositoryTest`)는 로컬 임베딩 모델 미설정이라는 **기존 환경 문제** — 이번 변경 전 원본 코드로도 동일하게 재현되는 것을 `git stash`로 직접 확인해 무관함을 검증했다.
+
+신규 `@DataJpaTest`(`CollectionTreeRepositoryTest`)로 실제 로컬 Postgres에 재귀 쿼리·keyword 필터를 직접 실행해 검증:
+- `findAncestorIdsInclusive`/`findDescendantIdsInclusive`/`findEffectiveCollectionIdsForDocument`/`findAllByParentCollectionIdAndStatus` — 3단 트리(root→child→grandchild) 구성해서 검증
+- `findReadableCollectionIds` — owner/PUBLIC 노출 + DEPARTMENT 권한이 부모에서 자식까지 상속되는지 + keyword 필터
+
+기존 `DocumentReadableIdsRepositoryTest`도 확장 — 부모 컬렉션에만 권한 있고 자식 컬렉션 문서를 조회하는 케이스(상속 정상 동작) + 기존 "직접 권한만" 케이스가 회귀 없이 통과하는지 같이 검증.
+
+`PermissionQueryServiceTest`는 47→54개(상속 케이스 7개 신규 + 관련 없음 3개는 없음, 정확히는 6개 판정그룹 각 1개 상속 테스트 + `checkDocumentPermission` 상속 테스트 1개 = 7개)로 늘었고, **기존 47개는 단 한 줄도 안 고쳤는데 그대로 통과**했다(추가 방식 설계가 의도대로 작동함을 증명).
+
+---
+
+## 에러 케이스 정리
+
+| 상황 | HTTP | 코드 |
+|---|---:|---|
+| 상위 컬렉션 없음(생성 시) | 404 | `COLLECTION-001` |
+| 상위 컬렉션에 쓰기권한 없음(생성 시, 신규) | 403 | `ROLE-002`(`PERMISSION_DENIED`) |
+| 자식 컬렉션 조회 시 부모 없음/삭제됨 | 404 | `COLLECTION-001` |
+| 자식 컬렉션 조회 시 부모 읽기권한 없음 | 403 | `ROLE-002`(`PERMISSION_DENIED`) |
+| ROLE 대상이 USER role임(관련 작업 A) | 400 | `PERMISSION-004`(`ROLE_NOT_GRANTABLE`) |
+
+---
+
+## 설계 결정 요약
+
+**기존 권한 판정 메서드는 손대지 않고 전부 append 방식으로 확장**: `PermissionQueryService`의 12개 기존 `existsXxxFor...` 메서드를 시그니처 변경(치환)하면 817줄짜리 기존 테스트가 대량으로 깨진다. 대신 `List` 버전을 신규 추가하고 기존 5단계 로직 끝에 6단계로 이어붙이는 방식을 택해, 인증/인가 critical path의 기존 검증된 로직을 안 건드리면서 상속을 추가했다.
+
+**순환 참조 방지 로직 생략**: 컬렉션 이동 API가 없어 생성 시점에만 부모 지정이 가능하므로, 현재 API 구조상 순환 참조가 원천적으로 불가능하다(검토 완료). 이동 API를 나중에 추가하게 되면 그때 반드시 재검토해야 한다.
+
+**목록/검색 쿼리(`DocumentRepository`)까지 상속 반영 범위에 포함**: 단건 조회만 상속되면 "권한은 있는데 못 찾는" 상태가 되므로, 원래 계획보다 범위를 넓혀 native 쿼리 2개를 같이 고쳤다(사용자가 명시적으로 이 범위까지 선택).
+
+**컬렉션 삭제 cascade는 root owner 1회 체크**: 구글드라이브 공유폴더 삭제와 동일한 멘탈모델. 하위 컬렉션 owner가 root와 다를 수 있다는 트레이드오프를 감수했다(위 신규 파일 2번 참고).
+
+**`checkDocumentPermission()`의 상속 출처는 새 enum 값 없이 기존 `ROLE`/`DEPARTMENT` 재사용**: API 응답 스키마 불변으로 프론트 영향 없음. "직접 부여 vs 상속"을 구분 못 한다는 정보 손실은 감수.
+
+**ROLE=USER 차단은 프론트가 아니라 백엔드에서**: `PermissionsPage.tsx`의 대상 타입 드롭다운을 아무리 잘 막아도 Swagger/curl로 API를 직접 호출하면 우회된다 — 실제 안전장치는 서버 검증(`ROLE_NOT_GRANTABLE`)이고, 프론트 드롭다운 필터링은 UX 보조일 뿐이다.
+
+---
+
+## 남은 이슈 / TODO (백로그, 이번 스코프 아님)
+
+- **컬렉션 상속용 재귀 CTE(`collection_ancestors`)가 매 호출마다 컬렉션 테이블 전체를 스캔한다** — `findAncestorIdsInclusive(collectionId)`처럼 `WHERE id = :collectionId`로 범위를 좁힌 쿼리는 문제없지만, `findReadableDocumentIds`/`findReadableDocumentIdsInCollection`/`findReadableCollectionIds` 안의 closure는 범위 제한이 없다. 검색·문서목록·컬렉션목록처럼 호출 빈도가 높은 화면에 다 걸려있어서, 컬렉션 수가 많아지면 병목 후보 1순위다. 지금 규모(수십~수백 개 추정)에선 무해.
+- `DOCUMENT_MANAGER` role 관련 작업은 이번에도 스코프 제외 (별도 논의 필요).
+- 프론트 트리 탐색 UI는 "클릭해서 한 단계씩 열람"만 구현 — 여러 단계를 한 번에 펼쳐 보여주는 UI는 안 만듦(파인더 방식 그대로).
+
+## 다음 단계
+
+컬렉션/권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`)에 이어지는 후속 이슈로, 이 다섯 문서에도 "이후 업데이트" 절로 교차 반영해뒀다. 커밋은 아직 안 한 상태 — 이 이슈에 관련 작업 A/B/C를 같이 넣을지 별도로 분리할지는 미정.
diff --git a/docs/design/kangcheolung-#24-document-permission-check.md b/docs/design/kangcheolung-#24-document-permission-check.md
index c4c409e5..852fd440 100644
--- a/docs/design/kangcheolung-#24-document-permission-check.md
+++ b/docs/design/kangcheolung-#24-document-permission-check.md
@@ -17,7 +17,7 @@ canReadDocument() → 읽기 권한 여부만, 첫 true에서 즉시 반환(단
canWriteDocument() → 쓰기 권한 여부만, 첫 true에서 즉시 반환
canAdminDocument() → 관리 권한 여부만, 첫 true에서 즉시 반환
```
-이 셋을 그냥 세 번 호출하면 될 것 같지만 안 된다 — **단락 평가가 서로 다른 결과를 감춘다.** 예를 들어 캐시(3단계)에서 `canRead=true`가 나와서 `canReadDocument()`가 거기서 멈춰버리면, 그 아래 ROLE 단계(4단계)에 `canWrite=true`가 있어도 그건 절대 확인되지 않는다(단락 평가는 애초에 "이후 단계를 볼 필요 없음"을 전제로 하니까). 그래서 `checkDocumentPermission()`은 **OWNER가 아닌 이상 단락 평가를 하지 않고 5단계를 전부 끝까지 탐색**한다.
+이 셋을 그냥 세 번 호출하면 될 것 같지만 안 된다 — **단락 평가가 서로 다른 결과를 감춘다.** 예를 들어 캐시(3단계)에서 `canRead=true`가 나와서 `canReadDocument()`가 거기서 멈춰버리면, 그 아래 ROLE 단계(4단계)에 `canWrite=true`가 있어도 그건 절대 확인되지 않는다(단락 평가는 애초에 "이후 단계를 볼 필요 없음"을 전제로 하니까). 그래서 `checkDocumentPermission()`은 **OWNER가 아닌 이상 단락 평가를 하지 않고 전체 단계를 끝까지 탐색**한다(작성 당시 5단계 → 2026-08-18 이슈 #229부터 6단계, 아래 "이후 업데이트" 참고).
```text
checkDocumentPermission()
@@ -91,6 +91,17 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo
// 5단계: DEPARTMENT live — 4단계와 동일 패턴
+ // (2026-08-18 추가, 이슈 #229) 6단계: 부모 컬렉션 체인 상속 — ROLE/DEPARTMENT 값을 그대로 재사용(신규 enum 값 안 만듦)
+ List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId);
+ if (!effectiveCollectionIds.isEmpty()) {
+ boolean inheritedRoleRead = collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds);
+ // ... inheritedRoleWrite, inheritedRoleAdmin, inheritedDeptRead/Write/Admin도 동일 패턴
+ if (inheritedRoleRead /* || ...Write || ...Admin */) sources.add(PermissionSourceType.ROLE);
+ // DEPARTMENT도 동일하게 sources.add(PermissionSourceType.DEPARTMENT)
+ if (inheritedRoleRead /* || inheritedDeptRead */) canRead = true;
+ // canWrite/canAdmin도 동일 패턴
+ }
+
return new DocumentPermissionSummaryResponse(documentId, canRead, canWrite, canAdmin, sources);
}
```
@@ -125,7 +136,7 @@ public ResponseEntity> getMyDocum
## 로컬 검증 (Swagger 수동 테스트 — 실제 수행 기록)
-`docs/test-results/kangcheolung-#21-permission-query-service.md`에 이 API(`GET /permissions/documents/{id}/me`)로 5단계 전체가 실제 시나리오로 확인되어 있다. 발췌:
+`docs/test-results/kangcheolung-#21-permission-query-service.md`에 이 API(`GET /permissions/documents/{id}/me`)로 (당시 기준) 5단계 전체가 실제 시나리오로 확인되어 있다. 발췌:
**OWNER (4.1절)**
```json
@@ -184,6 +195,10 @@ BUILD SUCCESSFUL
- `checkDocumentPermission()`이 `canReadDocument()` 등과 별개로 캐시/ROLE/DEPT 쿼리를 다시 전부 실행한다 — 두 메서드가 같은 문서에 대해 동시에 호출될 일은 지금 없지만, 쿼리 로직 자체가 중복되어 있어(`#21`의 TODO와 동일한 지점) 유지보수 시 두 곳을 같이 고쳐야 하는 부담이 있다.
+## 이후 업데이트 (2026-08-18, 이슈 #229 — 컬렉션 트리)
+
+`checkDocumentPermission()`에 "6단계: 부모 컬렉션 체인 상속"이 추가됐다(위 코드 블록에 인라인 반영). 새 `PermissionSourceType` 값을 만들지 않고 기존 `ROLE`/`DEPARTMENT`를 그대로 재사용하기로 결정했다 — 장점은 API 응답 스키마가 안 바뀌어 프론트 영향이 없다는 것, 트레이드오프는 "직접 부여된 권한인지 조상 컬렉션에서 상속된 것인지"를 이 API 응답만으로는 구분할 수 없다는 것(감사/디버깅 시 조상 컬렉션까지 직접 추적해야 함). 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고.
+
## 다음 단계
-`#29`(컬렉션 관리 API)로 이어진다. 권한 블록(`#16`~`#29`) 전체가 완료된 뒤 RAG 블록(F-SEARCH, F-RAG)이 `PermissionQueryService.canReadDocument()`를 재사용해서 검색 권한 필터링을 구현한다.
+`#29`(컬렉션 관리 API)로 이어진다. 권한 블록(`#16`~`#29`) 전체가 완료된 뒤 RAG 블록(F-SEARCH, F-RAG)이 `PermissionQueryService.canReadDocument()`를 재사용해서 검색 권한 필터링을 구현한다. 이후 `#229`(컬렉션 트리)로 이어진다.
diff --git a/docs/design/kangcheolung-#29-collection-management.md b/docs/design/kangcheolung-#29-collection-management.md
index e96b7a5e..43641e9a 100644
--- a/docs/design/kangcheolung-#29-collection-management.md
+++ b/docs/design/kangcheolung-#29-collection-management.md
@@ -14,7 +14,10 @@ closes #29
```text
GET /collections → 내 컬렉션 목록 (ACTIVE만)
+ (2026-08-18, 이슈 #229부터: owner+PUBLIC+권한부여+상속 전체,
+ 페이지네이션·keyword 검색 포함 — 아래 "이후 업데이트" 참고)
DELETE /collections/{id} → 컬렉션 소유자만, 권한 전부 정리 후 소프트 삭제
+ (2026-08-18부터: 하위 컬렉션 전체까지 cascade)
DELETE /collections/{id}/documents/{docId} → 컬렉션 소유자만, 그 문서에 대한 캐시만 정리 후 링크 삭제
```
@@ -54,6 +57,8 @@ public ResponseEntity> removeDocument(
### `domain/collection/service/query/CollectionQueryService.java` — `getMyCollections()`
+**(2026-08-18, 이슈 #229 관련 작업으로 이 메서드는 `getCollections(userId, keyword, page, size)`로 완전히 교체됨. 아래는 작성 당시 원본 코드 — "이후 업데이트" 절 참고.)**
+
```java
// 내 컬렉션 목록 조회 (ACTIVE 상태만)
public List getMyCollections(Long userId) {
@@ -63,10 +68,12 @@ public List getMyCollections(Long userId) {
.toList();
}
```
-`#16`에 이미 있는 `getCollection(id)`(단건 조회, 권한 체크가 없다는 게 이미 TODO로 기록됨)와 다르게, 이건 `ownerId` 기준으로 리포지토리 쿼리 자체가 필터링한다 — "내가 만든 컬렉션 목록"이라 소유자 필터가 곧 접근 제어라서 서비스 레이어에 별도 권한 체크 코드가 필요 없다.
+`#16`에 이미 있는 `getCollection(id)`(단건 조회, 권한 체크가 없다는 게 이미 TODO로 기록됨)와 다르게, 이건 `ownerId` 기준으로 리포지토리 쿼리 자체가 필터링한다 — "내가 만든 컬렉션 목록"이라 소유자 필터가 곧 접근 제어라서 서비스 레이어에 별도 권한 체크 코드가 필요 없다(작성 당시 기준. 지금은 owner 외에도 권한부여자를 포함하므로 이 필터링 논리 자체가 바뀌었다).
### `domain/collection/service/command/CollectionCommandService.java` — `deleteCollection()`
+**(2026-08-18, 이슈 #229부터 하위 컬렉션 전체까지 cascade하도록 확장됨. 아래는 작성 당시 원본 — "이후 업데이트" 절 참고.)**
+
```java
// 컬렉션 soft delete — 소유자만 가능
public void deleteCollection(Long collectionId, Long userId) {
@@ -87,7 +94,7 @@ public void deleteCollection(Long collectionId, Long userId) {
collection.markDeleted(LocalDateTime.now());
}
```
-순서가 중요하다 — ① 캐시 무효화(`forEach`) → ② 권한 레코드 삭제(`deleteAll`) → ③ 컬렉션 소프트 삭제(`markDeleted`). 반대로 권한 레코드를 먼저 지워버리면 `p.getId()`로 캐시를 찾아 무효화할 근거(`DIRECT_COLLECTION_PERMISSION` + `sourceId`)가 사라진다. ROLE/DEPARTMENT 대상 권한은 캐시에 애초에 안 들어가 있으므로(`#18`의 비대칭 캐싱 설계) `filter(USER)`로 걸러지고, `deleteAll()`에서는 캐시 무효화 없이 같이 삭제되는 것만으로 충분하다.
+순서가 중요하다 — ① 캐시 무효화(`forEach`) → ② 권한 레코드 삭제(`deleteAll`) → ③ 컬렉션 소프트 삭제(`markDeleted`). 반대로 권한 레코드를 먼저 지워버리면 `p.getId()`로 캐시를 찾아 무효화할 근거(`DIRECT_COLLECTION_PERMISSION` + `sourceId`)가 사라진다. ROLE/DEPARTMENT 대상 권한은 캐시에 애초에 안 들어가 있으므로(`#18`의 비대칭 캐싱 설계) `filter(USER)`로 걸러지고, `deleteAll()`에서는 캐시 무효화 없이 같이 삭제되는 것만으로 충분하다. 이 순서 원칙(캐시 무효화 → 권한 삭제 → soft delete)은 cascade로 확장된 뒤에도 그대로 유지된다.
### `domain/collection/service/command/CollectionCommandService.java` — `removeDocument()`
@@ -203,7 +210,7 @@ GET /collections
→ 200 OK
[]
```
-소프트 삭제된 컬렉션이 `getMyCollections()`(ACTIVE 필터)에서 제외됨을 확인.
+소프트 삭제된 컬렉션이 `getMyCollections()`(ACTIVE 필터)에서 제외됨을 확인. (당시 응답이 배열이었던 건 `getMyCollections()` 기준 — 2026-08-18 `getCollections()`로 교체된 뒤로는 `PageResponse` 형태로 바뀌었다. 삭제된 컬렉션이 목록에서 빠진다는 결론 자체는 그대로 유효.)
### 자동 테스트
@@ -239,9 +246,24 @@ BUILD SUCCESSFUL
## 남은 이슈 / TODO
-- `getMyCollections()`가 페이지네이션 없이 전체 목록을 반환한다 — 컬렉션 수가 많아지는 시나리오는 아직 없어 이슈로 등록하지 않음.
+- ~~`getMyCollections()`가 페이지네이션 없이 전체 목록을 반환한다 — 컬렉션 수가 많아지는 시나리오는 아직 없어 이슈로 등록하지 않음.~~ → **2026-08-18 해결됨**: `getCollections(userId, keyword, page, size)`로 교체, `PageResponse` 반환. 아래 "이후 업데이트" 참고.
- ~~`deleteCollection()`/`removeDocument()` 둘 다 `collectionRepository.findById()`로만 컬렉션을 조회한다 — `#16`에서 이미 지적된 것과 같은 이유로, 이미 `status=DELETED`인 컬렉션에 대해서도 (멱등하게) 재호출이 가능하다.~~ → 해결됨: 두 메서드 모두 `findById(...).filter(c -> c.getStatus() != CollectionStatus.DELETED)`로 변경(`#16`과 동일 패턴, `PermissionQueryService`의 `getActiveCollection()` 헬퍼와 동일한 관용구).
+## 이후 업데이트 (2026-08-18, 이슈 #229 및 관련 작업)
+
+수동 QA(시나리오 4) 중 이 문서가 만든 `getMyCollections()`/`deleteCollection()` 둘 다 실질적으로 다시 손보게 됐다. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고.
+
+**`deleteCollection()` — cascade 삭제로 확장 (이슈 #229 본편)**
+- `CollectionRepository.findDescendantIdsInclusive(collectionId)`(자기 자신+모든 후손, `WITH RECURSIVE`)로 대상 전체 ID를 구한 뒤, 권한 삭제·캐시 무효화·soft delete를 전부 그 목록 전체에 대해 수행하도록 확장.
+- **문서 매핑(`collection_documents`)도 이번에 같이 지우도록 범위가 넓어졌다** — 원래는 컬렉션만 지우고 매핑은 안 건드렸는데(위 "DB 변화 예시" 참고), cascade 대상 전체의 `CollectionDocument`도 함께 삭제한다.
+- owner 체크는 **삭제 대상 root 1회만** 하고 하위 각각은 재확인하지 않는다 — 구글드라이브 공유폴더 삭제와 같은 멘탈모델("최상위에 대한 권한으로 하위 전체가 지워짐"). 트레이드오프: 하위 컬렉션의 owner가 root owner와 다를 수 있는데(부모에 쓰기권한만 있으면 자식을 만들 수 있으므로), 그 경우도 root owner가 삭제할 수 있다.
+
+**`getMyCollections()` → `getCollections()` — 권한 반영 + 페이지네이션 + 검색 (관련 작업)**
+- 이름 그대로 "owner 것만"이라 문서 목록(`GET /api/documents`, owner+PUBLIC+권한부여 전부 포함)과 비대칭이었던 게 QA 중 재발견됨.
+- `CollectionRepository.findReadableCollectionIds(userId, keyword)` 신규 — owner+PUBLIC+USER직접권한+ROLE+DEPARTMENT(+부모 컬렉션 상속)를 전부 포함하는 native 쿼리. `DocumentRepository.findReadableDocumentIds`와 동일한 UNION 패턴.
+- `GET /collections?keyword=&page=&size=`로 페이지네이션과 이름/설명 검색까지 같이 추가.
+- 안 쓰이게 된 `findAllByOwnerIdAndStatus()`는 삭제.
+
## 다음 단계
-권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`) 전체 완료. RAG 블록(F-SEARCH, F-RAG)이 이 블록의 `PermissionQueryService.canReadDocument()`를 검색 단계 권한 필터링에 그대로 재사용한다(이미 완료·문서화됨: `docs/design/kangcheolung-#65-*.md` 이하 RAG 문서 시리즈).
+권한 블록(`#16`, `#18`, `#21`, `#24`, `#29`) 전체 완료. RAG 블록(F-SEARCH, F-RAG)이 이 블록의 `PermissionQueryService.canReadDocument()`를 검색 단계 권한 필터링에 그대로 재사용한다(이미 완료·문서화됨: `docs/design/kangcheolung-#65-*.md` 이하 RAG 문서 시리즈). 이후 `#229`(컬렉션 트리)로 이어진다.
diff --git a/frontend/app/features/CollectionsPage.tsx b/frontend/app/features/CollectionsPage.tsx
index 0beb238c..858f5597 100644
--- a/frontend/app/features/CollectionsPage.tsx
+++ b/frontend/app/features/CollectionsPage.tsx
@@ -3,37 +3,68 @@
// vinext production navigation uses full requests because its client router does not complete these catch-all route transitions.
/* eslint-disable @next/next/no-html-link-for-pages */
-import { FormEvent, useCallback, useEffect, useState } from "react";
-import { apiRequest, errorMessage } from "../lib/api";
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
+import { apiRequest, errorMessage, toQuery } from "../lib/api";
import type { Collection, CollectionDocument, DocumentSummary, PageResponse } from "../lib/api-types";
import { EmptyState, ErrorState, LoadingState, PageHeading, StatusPill, formatDate } from "../components/ui";
export function CollectionsPage({ notify }: { notify: (message: string) => void }) {
- const [collections, setCollections] = useState([]);
+ const [collections, setCollections] = useState | null>(null);
+ const [parentCandidates, setParentCandidates] = useState([]);
+ const [keywordInput, setKeywordInput] = useState("");
+ const [keyword, setKeyword] = useState("");
+ const [page, setPage] = useState(0);
const [creating, setCreating] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
+ // 검색어/페이지를 빠르게 바꾸면 먼저 보낸 요청이 나중에 도착해 최신 화면을 덮어쓸 수 있어, 가장 최근 호출의 결과만 반영한다.
+ const loadSeqRef = useRef(0);
const load = useCallback(async () => {
+ const seq = ++loadSeqRef.current;
setLoading(true);
setError("");
- try { setCollections(await apiRequest("/collections")); }
- catch (reason) { setError(errorMessage(reason)); }
- finally { setLoading(false); }
- }, []);
+ try {
+ const result = await apiRequest>(`/collections${toQuery({ keyword, page, size: 20 })}`);
+ if (seq !== loadSeqRef.current) return;
+ setCollections(result);
+ } catch (reason) {
+ if (seq !== loadSeqRef.current) return;
+ setError(errorMessage(reason));
+ } finally {
+ if (seq === loadSeqRef.current) setLoading(false);
+ }
+ }, [keyword, page]);
+
+ function search(event: FormEvent) {
+ event.preventDefault();
+ const nextKeyword = keywordInput.trim();
+ setPage(0);
+ if (nextKeyword === keyword && page === 0) void load();
+ else setKeyword(nextKeyword);
+ }
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
+ async function openCreateModal() {
+ try {
+ // 상위 폴더 후보는 페이지네이션과 무관하게 넉넉히 한 번에 가져온다.
+ setParentCandidates((await apiRequest>("/collections?page=0&size=100")).content);
+ } catch (reason) { setError(errorMessage(reason)); }
+ setModalOpen(true);
+ }
+
async function create(event: FormEvent) {
event.preventDefault();
setCreating(true);
const form = new FormData(event.currentTarget);
+ const parentCollectionId = form.get("parentCollectionId");
try {
- await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: null, visibility: String(form.get("visibility")) } });
+ await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: parentCollectionId ? Number(parentCollectionId) : null, visibility: String(form.get("visibility")) } });
setModalOpen(false);
notify("새 컬렉션을 만들었습니다.");
await load();
@@ -42,17 +73,22 @@ export function CollectionsPage({ notify }: { notify: (message: string) => void
}
return
- setModalOpen(true)}>+ 새 컬렉션} />
+ void openCreateModal()}>+ 새 컬렉션} />
+
{error ? void load()} /> : null}
{loading ? : null}
- {!loading && !error && !collections.length ? : null}
- {!loading && collections.length ?