diff --git a/src/main/java/com/opensource/docgrid/domain/collection/dto/request/AddDocumentRequest.java b/src/main/java/com/opensource/docgrid/domain/collection/dto/request/AddDocumentRequest.java index dfddc716..c4168626 100644 --- a/src/main/java/com/opensource/docgrid/domain/collection/dto/request/AddDocumentRequest.java +++ b/src/main/java/com/opensource/docgrid/domain/collection/dto/request/AddDocumentRequest.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema(description = "컬렉션 문서 추가 요청") public record AddDocumentRequest( @Schema(description = "추가할 문서 ID") @NotNull Long documentId ) { diff --git a/src/main/java/com/opensource/docgrid/domain/collection/dto/request/CreateCollectionRequest.java b/src/main/java/com/opensource/docgrid/domain/collection/dto/request/CreateCollectionRequest.java index 52a5dd24..13a11566 100644 --- a/src/main/java/com/opensource/docgrid/domain/collection/dto/request/CreateCollectionRequest.java +++ b/src/main/java/com/opensource/docgrid/domain/collection/dto/request/CreateCollectionRequest.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "컬렉션 생성 요청") public record CreateCollectionRequest( @Schema(description = "컬렉션 이름") @NotBlank String name, @Schema(description = "컬렉션 설명") String description, diff --git a/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentResponse.java b/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentResponse.java index b9a223d7..cf5056fe 100644 --- a/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentResponse.java +++ b/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionDocumentResponse.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +@Schema(description = "컬렉션 문서 추가 결과") public record CollectionDocumentResponse( @Schema(description = "컬렉션 ID") Long collectionId, @Schema(description = "문서 ID") Long documentId, diff --git a/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionResponse.java b/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionResponse.java index ec79b16d..edd2310c 100644 --- a/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionResponse.java +++ b/src/main/java/com/opensource/docgrid/domain/collection/dto/response/CollectionResponse.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +@Schema(description = "컬렉션 정보") public record CollectionResponse( @Schema(description = "컬렉션 ID") Long collectionId, @Schema(description = "컬렉션 이름") String name, diff --git a/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java b/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java index 512098f9..7b252b82 100644 --- a/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.collection.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.collection.entity.CollectionDocument; @@ -7,4 +9,6 @@ public interface CollectionDocumentRepository extends JpaRepository { boolean existsByCollectionIdAndDocumentId(Long collectionId, Long documentId); + + List findAllByCollectionId(Long collectionId); } diff --git a/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java b/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java new file mode 100644 index 00000000..7a18a856 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/controller/PermissionController.java @@ -0,0 +1,93 @@ +package com.opensource.docgrid.domain.permission.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.permission.dto.request.GrantPermissionRequest; +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; +import com.opensource.docgrid.domain.permission.service.command.CollectionPermissionCommandService; +import com.opensource.docgrid.domain.permission.service.command.DocumentPermissionCommandService; +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Permission", description = "권한 관련 API") +@RestController +@RequestMapping("/permissions") +@RequiredArgsConstructor +public class PermissionController { + + private final CollectionPermissionCommandService collectionPermissionCommandService; + private final DocumentPermissionCommandService documentPermissionCommandService; + + @Operation( + summary = "컬렉션 권한 부여", + description = "컬렉션에 USER/ROLE/DEPARTMENT 단위로 권한을 부여합니다. 컬렉션 소유자(owner)만 가능합니다. " + + "targetType에 맞는 ID 필드(userId/roleId/departmentId) 하나만 입력해야 합니다. " + + "USER 대상인 경우 컬렉션 내 문서에 대한 접근 캐시가 즉시 갱신됩니다." + ) + @PostMapping("/collections/{collectionId}") + public ResponseEntity> grantCollectionPermission( + @PathVariable Long collectionId, + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestBody @Valid GrantPermissionRequest request) { + return ResponseUtils.created( + collectionPermissionCommandService.grantPermission(collectionId, userId, request)); + } + + @Operation( + summary = "컬렉션 권한 회수", + description = "부여된 컬렉션 권한을 회수합니다. 컬렉션 소유자(owner)만 가능합니다. " + + "USER 대상 권한이었다면 접근 캐시도 즉시 무효화됩니다." + ) + @DeleteMapping("/collections/{collectionId}/{permissionId}") + public ResponseEntity> revokeCollectionPermission( + @PathVariable Long collectionId, + @PathVariable Long permissionId, + @Parameter(hidden = true) @CurrentUser Long userId) { + collectionPermissionCommandService.revokePermission(collectionId, permissionId, userId); + return ResponseUtils.noContent(); + } + + @Operation( + summary = "문서 예외 권한 부여", + description = "특정 문서 하나에만 적용되는 예외 권한을 부여합니다. 문서 소유자(owner)만 가능합니다. " + + "기본 권한은 collection_permissions로 관리하고, 이 API는 예외 케이스에만 최소한으로 사용하세요. " + + "USER 대상인 경우 해당 문서의 접근 캐시가 즉시 갱신됩니다." + ) + @PostMapping("/documents/{documentId}") + public ResponseEntity> grantDocumentPermission( + @PathVariable Long documentId, + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestBody @Valid GrantPermissionRequest request) { + return ResponseUtils.created( + documentPermissionCommandService.grantPermission(documentId, userId, request)); + } + + @Operation( + summary = "문서 예외 권한 회수", + description = "부여된 문서 예외 권한을 회수합니다. 문서 소유자(owner)만 가능합니다. " + + "USER 대상 권한이었다면 해당 문서의 접근 캐시도 즉시 무효화됩니다." + ) + @DeleteMapping("/documents/{documentId}/{permissionId}") + public ResponseEntity> revokeDocumentPermission( + @PathVariable Long documentId, + @PathVariable Long permissionId, + @Parameter(hidden = true) @CurrentUser Long userId) { + documentPermissionCommandService.revokePermission(documentId, permissionId, userId); + return ResponseUtils.noContent(); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java b/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java new file mode 100644 index 00000000..0aff1ad6 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/converter/PermissionConverter.java @@ -0,0 +1,48 @@ +package com.opensource.docgrid.domain.permission.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; +import com.opensource.docgrid.domain.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; + +@Component +public class PermissionConverter { + + public CollectionPermissionResponse toCollectionPermissionResponse(CollectionPermission p) { + return new CollectionPermissionResponse( + p.getId(), + p.getCollection().getId(), + p.getTargetType(), + p.getUser() != null ? p.getUser().getId() : null, + p.getRole() != null ? p.getRole().getId() : null, + p.getDepartment() != null ? p.getDepartment().getId() : null, + p.getPermissionType(), + p.isCanRead(), + p.isCanWrite(), + p.isCanAdmin(), + p.getGrantedBy() != null ? p.getGrantedBy().getId() : null, + p.getGrantedAt(), + p.getExpiresAt() + ); + } + + public DocumentPermissionResponse toDocumentPermissionResponse(DocumentPermission p) { + return new DocumentPermissionResponse( + p.getId(), + p.getDocument().getId(), + p.getTargetType(), + p.getUser() != null ? p.getUser().getId() : null, + p.getRole() != null ? p.getRole().getId() : null, + p.getDepartment() != null ? p.getDepartment().getId() : null, + p.getPermissionType(), + p.isCanRead(), + p.isCanWrite(), + p.isCanAdmin(), + p.getGrantedBy() != null ? p.getGrantedBy().getId() : null, + p.getGrantedAt(), + p.getExpiresAt() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/dto/request/GrantPermissionRequest.java b/src/main/java/com/opensource/docgrid/domain/permission/dto/request/GrantPermissionRequest.java new file mode 100644 index 00000000..b186c8ad --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/dto/request/GrantPermissionRequest.java @@ -0,0 +1,20 @@ +package com.opensource.docgrid.domain.permission.dto.request; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "권한 부여 요청") +public record GrantPermissionRequest( + @Schema(description = "권한 부여 대상 타입 (USER / ROLE / DEPARTMENT)") @NotNull PermissionTargetType targetType, + @Schema(description = "대상 사용자 ID (targetType=USER일 때만 입력)") Long userId, + @Schema(description = "대상 역할 ID (targetType=ROLE일 때만 입력)") Long roleId, + @Schema(description = "대상 부서 ID (targetType=DEPARTMENT일 때만 입력)") Long departmentId, + @Schema(description = "권한 종류 (READ / WRITE / ADMIN)") @NotNull PermissionType permissionType, + @Schema(description = "권한 만료 시각 (null이면 만료 없음)") LocalDateTime expiresAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/dto/response/CollectionPermissionResponse.java b/src/main/java/com/opensource/docgrid/domain/permission/dto/response/CollectionPermissionResponse.java new file mode 100644 index 00000000..ec0174e5 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/dto/response/CollectionPermissionResponse.java @@ -0,0 +1,26 @@ +package com.opensource.docgrid.domain.permission.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "컬렉션 권한 부여 결과") +public record CollectionPermissionResponse( + @Schema(description = "권한 ID") Long permissionId, + @Schema(description = "컬렉션 ID") Long collectionId, + @Schema(description = "권한 대상 타입") PermissionTargetType targetType, + @Schema(description = "대상 사용자 ID") Long userId, + @Schema(description = "대상 역할 ID") Long roleId, + @Schema(description = "대상 부서 ID") Long departmentId, + @Schema(description = "권한 종류") PermissionType permissionType, + @Schema(description = "읽기 권한") boolean canRead, + @Schema(description = "쓰기 권한") boolean canWrite, + @Schema(description = "관리 권한") boolean canAdmin, + @Schema(description = "부여한 사용자 ID") Long grantedBy, + @Schema(description = "부여 시각") LocalDateTime grantedAt, + @Schema(description = "만료 시각") LocalDateTime expiresAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/dto/response/DocumentPermissionResponse.java b/src/main/java/com/opensource/docgrid/domain/permission/dto/response/DocumentPermissionResponse.java new file mode 100644 index 00000000..31ab82c6 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/dto/response/DocumentPermissionResponse.java @@ -0,0 +1,26 @@ +package com.opensource.docgrid.domain.permission.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "문서 권한 부여 결과") +public record DocumentPermissionResponse( + @Schema(description = "권한 ID") Long permissionId, + @Schema(description = "문서 ID") Long documentId, + @Schema(description = "권한 대상 타입") PermissionTargetType targetType, + @Schema(description = "대상 사용자 ID") Long userId, + @Schema(description = "대상 역할 ID") Long roleId, + @Schema(description = "대상 부서 ID") Long departmentId, + @Schema(description = "권한 종류") PermissionType permissionType, + @Schema(description = "읽기 권한") boolean canRead, + @Schema(description = "쓰기 권한") boolean canWrite, + @Schema(description = "관리 권한") boolean canAdmin, + @Schema(description = "부여한 사용자 ID") Long grantedBy, + @Schema(description = "부여 시각") LocalDateTime grantedAt, + @Schema(description = "만료 시각") LocalDateTime expiresAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/entity/UserDocumentAccessCache.java b/src/main/java/com/opensource/docgrid/domain/permission/entity/UserDocumentAccessCache.java index 0d0d0a93..76ad5d55 100644 --- a/src/main/java/com/opensource/docgrid/domain/permission/entity/UserDocumentAccessCache.java +++ b/src/main/java/com/opensource/docgrid/domain/permission/entity/UserDocumentAccessCache.java @@ -119,4 +119,17 @@ public UserDocumentAccessCache(User user, Document document, boolean canRead, bo this.invalidatedAt = invalidatedAt; this.expiresAt = expiresAt; } + + public void grant(boolean canRead, boolean canWrite, boolean canAdmin, LocalDateTime expiresAt) { + this.canRead = canRead; + this.canWrite = canWrite; + this.canAdmin = canAdmin; + this.invalidatedAt = null; + this.computedAt = LocalDateTime.now(); + this.expiresAt = expiresAt; + } + + public void invalidate() { + this.invalidatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java b/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java new file mode 100644 index 00000000..23e7076c --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java @@ -0,0 +1,8 @@ +package com.opensource.docgrid.domain.permission.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.permission.entity.CollectionPermission; + +public interface CollectionPermissionRepository extends JpaRepository { +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java b/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java new file mode 100644 index 00000000..6411d100 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/repository/DocumentPermissionRepository.java @@ -0,0 +1,8 @@ +package com.opensource.docgrid.domain.permission.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; + +public interface DocumentPermissionRepository extends JpaRepository { +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java b/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java new file mode 100644 index 00000000..6f9ee52e --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.permission.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.permission.entity.UserDocumentAccessCache; +import com.opensource.docgrid.domain.permission.enums.AccessSourceType; + +public interface UserDocumentAccessCacheRepository extends JpaRepository { + + Optional findByUserIdAndDocumentIdAndSourceTypeAndSourceId( + Long userId, Long documentId, AccessSourceType sourceType, Long sourceId); +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java b/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java new file mode 100644 index 00000000..36ff2fd9 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java @@ -0,0 +1,159 @@ +package com.opensource.docgrid.domain.permission.service.command; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.collection.entity.CollectionDocument; +import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; +import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.request.GrantPermissionRequest; +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +import com.opensource.docgrid.domain.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.enums.AccessSourceType; +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.repository.DepartmentRepository; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Transactional +@Service +@RequiredArgsConstructor +public class CollectionPermissionCommandService { + + private final CollectionRepository collectionRepository; + private final CollectionDocumentRepository collectionDocumentRepository; + private final CollectionPermissionRepository collectionPermissionRepository; + private final UserDocumentAccessCacheService cacheService; + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final DepartmentRepository departmentRepository; + private final PermissionConverter permissionConverter; + + // 컬렉션 권한 부여 + public CollectionPermissionResponse grantPermission(Long collectionId, Long grantorId, + GrantPermissionRequest request) { + DocumentCollection collection = collectionRepository.findById(collectionId) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + + // TODO: Issue 3 PermissionService 완성 후 canAdminDocument() 로 대체 + if (!collection.getOwner().getId().equals(grantorId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + validateTargetType(request); // targetType과 ID 필드 조합 유효성 검사 + + User targetUser = null; + Role targetRole = null; + Department targetDepartment = null; + + if (request.targetType() == PermissionTargetType.USER) { + targetUser = userRepository.findById(request.userId()) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); + } else if (request.targetType() == PermissionTargetType.ROLE) { + targetRole = roleRepository.findById(request.roleId()) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + } else { + targetDepartment = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); + } + + boolean[] permissions = resolvePermissions(request.permissionType()); + User grantor = userRepository.getReferenceById(grantorId); // 권한 부여자 정보 조회 + + CollectionPermission permission = CollectionPermission.builder() + .collection(collection) + .targetType(request.targetType()) + .user(targetUser) + .role(targetRole) + .department(targetDepartment) + .permissionType(request.permissionType()) + .canRead(permissions[0]) + .canWrite(permissions[1]) + .canAdmin(permissions[2]) + .grantedBy(grantor) + .grantedAt(LocalDateTime.now()) + .expiresAt(request.expiresAt()) + .build(); + + collectionPermissionRepository.save(permission); + + if (request.targetType() == PermissionTargetType.USER) { + updateCacheForCollection(collectionId, targetUser, permissions, permission.getId(), request.expiresAt()); + } + + return permissionConverter.toCollectionPermissionResponse(permission); + } + + // 컬렉션 권한 회수 + public void revokePermission(Long collectionId, Long permissionId, Long revokerId) { + CollectionPermission permission = collectionPermissionRepository.findById(permissionId) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_PERMISSION_NOT_FOUND)); + + DocumentCollection collection = permission.getCollection(); + if (!collection.getId().equals(collectionId)) { + throw new DocGridException(ErrorCode.COLLECTION_PERMISSION_NOT_FOUND); + } + + // TODO: Issue 3 PermissionService 완성 후 canAdminDocument() 로 대체 + if (!collection.getOwner().getId().equals(revokerId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + if (permission.getTargetType() == PermissionTargetType.USER) { + Long targetUserId = permission.getUser().getId(); + List docs = collectionDocumentRepository.findAllByCollectionId(collectionId); + for (CollectionDocument cd : docs) { + cacheService.revokeUserPermission(targetUserId, cd.getDocument().getId(), + AccessSourceType.DIRECT_COLLECTION_PERMISSION, permissionId); + } + } + + collectionPermissionRepository.delete(permission); + } + + // targetType과 ID 필드 조합 유효성 검사 + private void validateTargetType(GrantPermissionRequest request) { + boolean valid = switch (request.targetType()) { + case USER -> request.userId() != null && request.roleId() == null && request.departmentId() == null; + case ROLE -> request.roleId() != null && request.userId() == null && request.departmentId() == null; + case DEPARTMENT -> request.departmentId() != null && request.userId() == null && request.roleId() == null; + }; + if (!valid) { + throw new DocGridException(ErrorCode.INVALID_TARGET_TYPE); + } + } + + // PermissionType → canRead/canWrite/canAdmin 변환 (WRITE는 READ 포함, ADMIN은 전체 포함) + private boolean[] resolvePermissions(PermissionType type) { + return switch (type) { + case READ -> new boolean[]{true, false, false}; + case WRITE -> new boolean[]{true, true, false}; + case ADMIN -> new boolean[]{true, true, true}; + }; + } + + // USER 권한 부여 시 컬렉션 내 모든 문서에 캐시 갱신 + private void updateCacheForCollection(Long collectionId, User targetUser, boolean[] permissions, + Long sourceId, LocalDateTime expiresAt) { + List docs = collectionDocumentRepository.findAllByCollectionId(collectionId); + for (CollectionDocument cd : docs) { + cacheService.grantUserPermission(targetUser, cd.getDocument(), + permissions[0], permissions[1], permissions[2], + AccessSourceType.DIRECT_COLLECTION_PERMISSION, sourceId, expiresAt); + } + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java b/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java new file mode 100644 index 00000000..57c8b37e --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java @@ -0,0 +1,141 @@ +package com.opensource.docgrid.domain.permission.service.command; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.request.GrantPermissionRequest; +import com.opensource.docgrid.domain.permission.dto.response.DocumentPermissionResponse; +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; +import com.opensource.docgrid.domain.permission.enums.AccessSourceType; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +import com.opensource.docgrid.domain.permission.repository.DocumentPermissionRepository; +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.repository.DepartmentRepository; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Transactional +@Service +@RequiredArgsConstructor +public class DocumentPermissionCommandService { + + private final DocumentRepository documentRepository; + private final DocumentPermissionRepository documentPermissionRepository; + private final UserDocumentAccessCacheService cacheService; + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final DepartmentRepository departmentRepository; + private final PermissionConverter permissionConverter; + + // 문서 단건 예외 권한 부여 + public DocumentPermissionResponse grantPermission(Long documentId, Long grantorId, + GrantPermissionRequest request) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND)); + + // TODO: Issue 3 PermissionService 완성 후 canAdminDocument() 로 대체 + if (!document.getOwner().getId().equals(grantorId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + validateTargetType(request); + + User targetUser = null; + Role targetRole = null; + Department targetDepartment = null; + + if (request.targetType() == PermissionTargetType.USER) { + targetUser = userRepository.findById(request.userId()) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); + } else if (request.targetType() == PermissionTargetType.ROLE) { + targetRole = roleRepository.findById(request.roleId()) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + } else { + targetDepartment = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); + } + + boolean[] permissions = resolvePermissions(request.permissionType()); + User grantor = userRepository.getReferenceById(grantorId); + + DocumentPermission permission = DocumentPermission.builder() + .document(document) + .targetType(request.targetType()) + .user(targetUser) + .role(targetRole) + .department(targetDepartment) + .permissionType(request.permissionType()) + .canRead(permissions[0]) + .canWrite(permissions[1]) + .canAdmin(permissions[2]) + .grantedBy(grantor) + .grantedAt(LocalDateTime.now()) + .expiresAt(request.expiresAt()) + .build(); + + documentPermissionRepository.save(permission); + + if (request.targetType() == PermissionTargetType.USER) { + cacheService.grantUserPermission(targetUser, document, + permissions[0], permissions[1], permissions[2], + AccessSourceType.DIRECT_DOCUMENT_PERMISSION, permission.getId(), request.expiresAt()); + } + + return permissionConverter.toDocumentPermissionResponse(permission); + } + + // 문서 단건 권한 회수 + public void revokePermission(Long documentId, Long permissionId, Long revokerId) { + DocumentPermission permission = documentPermissionRepository.findById(permissionId) + .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_PERMISSION_NOT_FOUND)); + + if (!permission.getDocument().getId().equals(documentId)) { + throw new DocGridException(ErrorCode.DOCUMENT_PERMISSION_NOT_FOUND); + } + + // TODO: Issue 3 PermissionService 완성 후 canAdminDocument() 로 대체 + if (!permission.getDocument().getOwner().getId().equals(revokerId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + if (permission.getTargetType() == PermissionTargetType.USER) { + cacheService.revokeUserPermission(permission.getUser().getId(), documentId, + AccessSourceType.DIRECT_DOCUMENT_PERMISSION, permissionId); + } + + documentPermissionRepository.delete(permission); + } + + // targetType과 ID 필드 조합 유효성 검사 + private void validateTargetType(GrantPermissionRequest request) { + boolean valid = switch (request.targetType()) { + case USER -> request.userId() != null && request.roleId() == null && request.departmentId() == null; + case ROLE -> request.roleId() != null && request.userId() == null && request.departmentId() == null; + case DEPARTMENT -> request.departmentId() != null && request.userId() == null && request.roleId() == null; + }; + if (!valid) { + throw new DocGridException(ErrorCode.INVALID_TARGET_TYPE); + } + } + + // PermissionType → canRead/canWrite/canAdmin 변환 + private boolean[] resolvePermissions(PermissionType type) { + return switch (type) { + case READ -> new boolean[]{true, false, false}; + case WRITE -> new boolean[]{true, true, false}; + case ADMIN -> new boolean[]{true, true, true}; + }; + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/permission/service/command/UserDocumentAccessCacheService.java b/src/main/java/com/opensource/docgrid/domain/permission/service/command/UserDocumentAccessCacheService.java new file mode 100644 index 00000000..e73632ca --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/permission/service/command/UserDocumentAccessCacheService.java @@ -0,0 +1,58 @@ +package com.opensource.docgrid.domain.permission.service.command; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.permission.entity.UserDocumentAccessCache; +import com.opensource.docgrid.domain.permission.enums.AccessSourceType; +import com.opensource.docgrid.domain.permission.repository.UserDocumentAccessCacheRepository; +import com.opensource.docgrid.domain.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Transactional +@Service +@RequiredArgsConstructor +public class UserDocumentAccessCacheService { + + private final UserDocumentAccessCacheRepository cacheRepository; + + // USER 권한 캐시 저장 (이미 있으면 갱신, 없으면 신규 insert) + public void grantUserPermission(User user, Document document, + boolean canRead, boolean canWrite, boolean canAdmin, + AccessSourceType sourceType, Long sourceId, + LocalDateTime expiresAt) { + cacheRepository.findByUserIdAndDocumentIdAndSourceTypeAndSourceId( + user.getId(), document.getId(), sourceType, sourceId) + .ifPresentOrElse( + cache -> { // 이미 존재하면 갱신 + cache.grant(canRead, canWrite, canAdmin, expiresAt); + }, + () -> { // 존재하지 않으면 신규 insert + UserDocumentAccessCache cache = UserDocumentAccessCache.builder() + .user(user) + .document(document) + .canRead(canRead) + .canWrite(canWrite) + .canAdmin(canAdmin) + .sourceType(sourceType) + .sourceId(sourceId) + .computedAt(LocalDateTime.now()) + .expiresAt(expiresAt) + .build(); + cacheRepository.save(cache); + } + ); + } + + // USER 권한 캐시 무효화 (invalidated_at 설정) + public void revokeUserPermission(Long userId, Long documentId, + AccessSourceType sourceType, Long sourceId) { + cacheRepository.findByUserIdAndDocumentIdAndSourceTypeAndSourceId( + userId, documentId, sourceType, sourceId) + .ifPresent(UserDocumentAccessCache::invalidate); + } +} diff --git a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 4ee5c1c2..784eed4d 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -30,7 +30,7 @@ public enum ErrorCode { // ROLE ROLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "ROLE-001", "존재하지 않는 역할입니다."), - PERMISSION_DENIED(HttpStatus.FORBIDDEN, "ROLE-002", "관리자만 접근할 수 있습니다."), + PERMISSION_DENIED(HttpStatus.FORBIDDEN, "ROLE-002", "접근 권한이 없습니다."), ROLE_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "ROLE-003", "이미 부여된 역할입니다."), // COLLECTION @@ -40,6 +40,11 @@ public enum ErrorCode { // DOCUMENT DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "DOCUMENT-001", "문서를 찾을 수 없습니다."), + // PERMISSION + 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", "문서 권한을 찾을 수 없습니다."), + // EMBEDDING MODEL EMBEDDING_MODEL_NOT_CONFIGURED( HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java b/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java new file mode 100644 index 00000000..775c2d33 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/permission/fixture/PermissionFixture.java @@ -0,0 +1,89 @@ +package com.opensource.docgrid.domain.permission.fixture; + +import java.time.LocalDateTime; + +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.collection.fixture.CollectionFixture; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.permission.entity.CollectionPermission; +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +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.enums.CommonStatus; + +public class PermissionFixture { + + public static final Long PERMISSION_ID = 30L; + public static final Long ROLE_ID = 2L; + public static final Long DEPARTMENT_ID = 3L; + + private PermissionFixture() { + } + + public static Role createRole() { + Role role = Role.builder() + .code("USER") + .name("일반 사용자") + .build(); + ReflectionTestUtils.setField(role, "id", ROLE_ID); + return role; + } + + public static Department createDepartment() { + Department department = Department.builder() + .name("개발팀") + .status(CommonStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(department, "id", DEPARTMENT_ID); + return department; + } + + public static CollectionPermission createCollectionPermission(DocumentCollection collection, User user) { + CollectionPermission permission = CollectionPermission.builder() + .collection(collection) + .targetType(PermissionTargetType.USER) + .user(user) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(user) + .grantedAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(permission, "id", PERMISSION_ID); + return permission; + } + + public static DocumentPermission createDocumentPermission(Document document, User user) { + DocumentPermission permission = DocumentPermission.builder() + .document(document) + .targetType(PermissionTargetType.USER) + .user(user) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(user) + .grantedAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(permission, "id", PERMISSION_ID); + return permission; + } + + public static User createOwner() { + return CollectionFixture.createOwner(); + } + + public static Document createDocument() { + return CollectionFixture.createDocument(createOwner()); + } + + public static DocumentCollection createCollection() { + return CollectionFixture.createCollection(createOwner()); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java new file mode 100644 index 00000000..5305e2bb --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandServiceTest.java @@ -0,0 +1,211 @@ +package com.opensource.docgrid.domain.permission.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.collection.entity.CollectionDocument; +import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.collection.fixture.CollectionFixture; +import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; +import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.request.GrantPermissionRequest; +import com.opensource.docgrid.domain.permission.dto.response.CollectionPermissionResponse; +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.fixture.PermissionFixture; +import com.opensource.docgrid.domain.permission.repository.CollectionPermissionRepository; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +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.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CollectionPermissionCommandService 단위 테스트") +class CollectionPermissionCommandServiceTest { + + @InjectMocks + private CollectionPermissionCommandService service; + + @Mock private CollectionRepository collectionRepository; + @Mock private CollectionDocumentRepository collectionDocumentRepository; + @Mock private CollectionPermissionRepository collectionPermissionRepository; + @Mock private UserDocumentAccessCacheService cacheService; + @Mock private UserRepository userRepository; + @Mock private RoleRepository roleRepository; + @Mock private DepartmentRepository departmentRepository; + @Mock private PermissionConverter permissionConverter; + + // ==================== grantPermission ==================== + + @Test + @DisplayName("USER 대상 권한을 부여하면 캐시도 함께 갱신된다") + void grantPermission_user_updatesCache() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + Document document = CollectionFixture.createDocument(owner); + CollectionDocument cd = buildCollectionDocument(collection, document); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(userRepository.findById(CollectionFixture.USER_ID)).willReturn(Optional.of(owner)); + given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner); + given(collectionDocumentRepository.findAllByCollectionId(CollectionFixture.COLLECTION_ID)) + .willReturn(List.of(cd)); + given(permissionConverter.toCollectionPermissionResponse(any())).willReturn(null); + + service.grantPermission(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request); + + then(collectionPermissionRepository).should().save(any(CollectionPermission.class)); + then(cacheService).should().grantUserPermission(any(), any(), any(boolean.class), + any(boolean.class), any(boolean.class), any(), any(), any()); + } + + @Test + @DisplayName("ROLE 대상 권한을 부여하면 캐시 갱신 없이 저장만 된다") + void grantPermission_role_noCacheUpdate() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + Role role = PermissionFixture.createRole(); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.ROLE, null, PermissionFixture.ROLE_ID, null, PermissionType.READ, null); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + given(roleRepository.findById(PermissionFixture.ROLE_ID)).willReturn(Optional.of(role)); + given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner); + given(permissionConverter.toCollectionPermissionResponse(any())).willReturn(null); + + service.grantPermission(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request); + + then(collectionPermissionRepository).should().save(any(CollectionPermission.class)); + then(cacheService).should(never()).grantUserPermission(any(), any(), any(boolean.class), + any(boolean.class), any(boolean.class), any(), any(), any()); + } + + @Test + @DisplayName("컬렉션이 없으면 COLLECTION_NOT_FOUND 예외가 발생한다") + void grantPermission_throws_when_collectionNotFound() { + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.empty()); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COLLECTION_NOT_FOUND); + } + + @Test + @DisplayName("소유자가 아닌 사용자가 권한을 부여하면 PERMISSION_DENIED 예외가 발생한다") + void grantPermission_throws_when_notOwner() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + Long otherUserId = 99L; + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.COLLECTION_ID, otherUserId, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + @Test + @DisplayName("targetType=USER인데 userId가 null이면 INVALID_TARGET_TYPE 예외가 발생한다") + void grantPermission_throws_when_invalidTargetType() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, null, null, null, PermissionType.READ, null); + + given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(collection)); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_TARGET_TYPE); + } + + // ==================== revokePermission ==================== + + @Test + @DisplayName("USER 권한을 회수하면 캐시도 무효화된다") + void revokePermission_user_invalidatesCache() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + Document document = CollectionFixture.createDocument(owner); + CollectionDocument cd = buildCollectionDocument(collection, document); + CollectionPermission permission = PermissionFixture.createCollectionPermission(collection, owner); + + given(collectionPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.of(permission)); + given(collectionDocumentRepository.findAllByCollectionId(CollectionFixture.COLLECTION_ID)) + .willReturn(List.of(cd)); + + service.revokePermission(CollectionFixture.COLLECTION_ID, PermissionFixture.PERMISSION_ID, + CollectionFixture.USER_ID); + + then(cacheService).should().revokeUserPermission(any(), any(), any(), any()); + then(collectionPermissionRepository).should().delete(permission); + } + + @Test + @DisplayName("권한이 없으면 COLLECTION_PERMISSION_NOT_FOUND 예외가 발생한다") + void revokePermission_throws_when_permissionNotFound() { + given(collectionPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.revokePermission( + CollectionFixture.COLLECTION_ID, PermissionFixture.PERMISSION_ID, CollectionFixture.USER_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COLLECTION_PERMISSION_NOT_FOUND); + } + + @Test + @DisplayName("소유자가 아닌 사용자가 권한을 회수하면 PERMISSION_DENIED 예외가 발생한다") + void revokePermission_throws_when_notOwner() { + User owner = CollectionFixture.createOwner(); + DocumentCollection collection = CollectionFixture.createCollection(owner); + CollectionPermission permission = PermissionFixture.createCollectionPermission(collection, owner); + Long otherUserId = 99L; + + given(collectionPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.of(permission)); + + assertThatThrownBy(() -> service.revokePermission( + CollectionFixture.COLLECTION_ID, PermissionFixture.PERMISSION_ID, otherUserId)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + private CollectionDocument buildCollectionDocument(DocumentCollection collection, Document document) { + return CollectionDocument.builder() + .collection(collection) + .document(document) + .addedBy(collection.getOwner()) + .addedAt(java.time.LocalDateTime.now()) + .build(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java new file mode 100644 index 00000000..3c9791ff --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandServiceTest.java @@ -0,0 +1,183 @@ +package com.opensource.docgrid.domain.permission.service.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.collection.fixture.CollectionFixture; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.permission.converter.PermissionConverter; +import com.opensource.docgrid.domain.permission.dto.request.GrantPermissionRequest; +import com.opensource.docgrid.domain.permission.entity.DocumentPermission; +import com.opensource.docgrid.domain.permission.enums.PermissionTargetType; +import com.opensource.docgrid.domain.permission.enums.PermissionType; +import com.opensource.docgrid.domain.permission.fixture.PermissionFixture; +import com.opensource.docgrid.domain.permission.repository.DocumentPermissionRepository; +import com.opensource.docgrid.domain.user.entity.User; +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.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentPermissionCommandService 단위 테스트") +class DocumentPermissionCommandServiceTest { + + @InjectMocks + private DocumentPermissionCommandService service; + + @Mock private DocumentRepository documentRepository; + @Mock private DocumentPermissionRepository documentPermissionRepository; + @Mock private UserDocumentAccessCacheService cacheService; + @Mock private UserRepository userRepository; + @Mock private RoleRepository roleRepository; + @Mock private DepartmentRepository departmentRepository; + @Mock private PermissionConverter permissionConverter; + + @Test + @DisplayName("USER 대상 문서 권한을 부여하면 캐시도 함께 갱신된다") + void grantPermission_user_updatesCache() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(userRepository.findById(CollectionFixture.USER_ID)).willReturn(Optional.of(owner)); + given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner); + given(permissionConverter.toDocumentPermissionResponse(any())).willReturn(null); + + service.grantPermission(CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request); + + then(documentPermissionRepository).should().save(any(DocumentPermission.class)); + then(cacheService).should().grantUserPermission(any(), any(), any(boolean.class), + any(boolean.class), any(boolean.class), any(), any(), any()); + } + + @Test + @DisplayName("ROLE 대상 문서 권한을 부여하면 캐시 갱신 없이 저장만 된다") + void grantPermission_role_noCacheUpdate() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.ROLE, null, PermissionFixture.ROLE_ID, null, PermissionType.READ, null); + + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + given(roleRepository.findById(PermissionFixture.ROLE_ID)).willReturn(Optional.of(PermissionFixture.createRole())); + given(userRepository.getReferenceById(CollectionFixture.USER_ID)).willReturn(owner); + given(permissionConverter.toDocumentPermissionResponse(any())).willReturn(null); + + service.grantPermission(CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request); + + then(documentPermissionRepository).should().save(any(DocumentPermission.class)); + then(cacheService).should(never()).grantUserPermission(any(), any(), any(boolean.class), + any(boolean.class), any(boolean.class), any(), any(), any()); + } + + @Test + @DisplayName("문서가 없으면 DOCUMENT_NOT_FOUND 예외가 발생한다") + void grantPermission_throws_when_documentNotFound() { + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.empty()); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); + } + + @Test + @DisplayName("소유자가 아닌 사용자가 권한을 부여하면 PERMISSION_DENIED 예외가 발생한다") + void grantPermission_throws_when_notOwner() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + Long otherUserId = 99L; + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.USER, CollectionFixture.USER_ID, null, null, PermissionType.READ, null); + + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.DOCUMENT_ID, otherUserId, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + // ==================== revokePermission ==================== + + @Test + @DisplayName("USER 권한을 회수하면 캐시도 무효화된다") + void revokePermission_user_invalidatesCache() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + DocumentPermission permission = PermissionFixture.createDocumentPermission(document, owner); + + given(documentPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.of(permission)); + + service.revokePermission(CollectionFixture.DOCUMENT_ID, PermissionFixture.PERMISSION_ID, + CollectionFixture.USER_ID); + + then(cacheService).should().revokeUserPermission(any(), any(), any(), any()); + then(documentPermissionRepository).should().delete(permission); + } + + @Test + @DisplayName("권한이 없으면 DOCUMENT_PERMISSION_NOT_FOUND 예외가 발생한다") + void revokePermission_throws_when_permissionNotFound() { + given(documentPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.revokePermission( + CollectionFixture.DOCUMENT_ID, PermissionFixture.PERMISSION_ID, CollectionFixture.USER_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_PERMISSION_NOT_FOUND); + } + + @Test + @DisplayName("소유자가 아닌 사용자가 권한을 회수하면 PERMISSION_DENIED 예외가 발생한다") + void revokePermission_throws_when_notOwner() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + DocumentPermission permission = PermissionFixture.createDocumentPermission(document, owner); + Long otherUserId = 99L; + + given(documentPermissionRepository.findById(PermissionFixture.PERMISSION_ID)) + .willReturn(Optional.of(permission)); + + assertThatThrownBy(() -> service.revokePermission( + CollectionFixture.DOCUMENT_ID, PermissionFixture.PERMISSION_ID, otherUserId)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + @Test + @DisplayName("targetType과 ID 필드 조합이 맞지 않으면 INVALID_TARGET_TYPE 예외가 발생한다") + void grantPermission_throws_when_invalidTargetType() { + User owner = CollectionFixture.createOwner(); + Document document = CollectionFixture.createDocument(owner); + GrantPermissionRequest request = new GrantPermissionRequest( + PermissionTargetType.ROLE, null, null, null, PermissionType.READ, null); + + given(documentRepository.findById(CollectionFixture.DOCUMENT_ID)).willReturn(Optional.of(document)); + + assertThatThrownBy(() -> service.grantPermission( + CollectionFixture.DOCUMENT_ID, CollectionFixture.USER_ID, request)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_TARGET_TYPE); + } +}