Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ public SheetImportResponse analyzeAndImportPreMembers(
clubPermissionValidator.validateManagerAccess(clubId, requesterId);

String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl);
// OAuth 미연결이면 건너뛰고 계속 진행한다. Drive 초기화/인증 오류는 예외로 전파한다.
googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
// OAuth 미연결이면 기존 동작대로 검증을 건너뛴다.
// 다만 Drive OAuth가 연결된 경우에는 요청자 계정의 실제 시트 접근 권한을 먼저 검증한다.
googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(
requesterId,
spreadsheetId
);
Comment on lines +28 to +31
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClubSheetIntegratedService의 주석(“OAuth 미연결이면 … 건너뛴다”) 및 PR 설명(Drive OAuth 미연결 사용자 흐름 유지)과 달리, 현재는 validateRequesterAccessAndTryGrantServiceAccountWriterAccess()를 무조건 호출해서 OAuth 계정/refresh token이 없으면 NOT_FOUND_GOOGLE_DRIVE_AUTH 예외로 흐름이 중단됩니다(기존에는 tryGrant...가 false 반환 후 계속 진행). OAuth 미연결 시에는 기존처럼 검증/권한부여를 스킵하고 다음 단계로 진행하도록 분기(예: refresh token 존재 시에만 validate 메서드 호출, 또는 validate 메서드 내부에서 token 없으면 no-op) 중 하나로 정합성을 맞춰주세요.

Suggested change
googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(
requesterId,
spreadsheetId
);
try {
googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(
requesterId,
spreadsheetId
);
} catch (RuntimeException e) {
// OAuth 미연결(예: NOT_FOUND_GOOGLE_DRIVE_AUTH)인 경우에는 검증/권한부여를 스킵하고
// 기존 동작대로 이후 흐름(시트 분석 및 임포트)을 계속 진행한다.
String message = e.getMessage();
if (message == null || !message.contains("NOT_FOUND_GOOGLE_DRIVE_AUTH")) {
// 다른 예외는 기존과 동일하게 전파한다.
throw e;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +31
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClubSheetIntegratedService의 주석이 현재 동작과 불일치합니다. 이제 analyzeAndImportPreMembers()는 OAuth 미연결 시 검증을 ‘건너뛰는’ 것이 아니라 validateRequesterAccess...에서 NOT_FOUND_GOOGLE_DRIVE_AUTH 예외로 즉시 실패합니다. 주석을 실제 정책(미연결 시 실패/재연결 요구)로 수정하거나, 정말로 우회 허용이 목적이라면 호출 로직을 변경해야 합니다.

Copilot uses AI. Check for mistakes.

SheetHeaderMapper.SheetAnalysisResult analysis =
sheetHeaderMapper.analyzeAllSheets(spreadsheetId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ static List<Permission> listAllPermissions(Drive driveService, String fileId) th

do {
Drive.Permissions.List request = driveService.permissions().list(fileId)
.setFields(PERMISSION_FIELDS);
.setFields(PERMISSION_FIELDS)
.setSupportsAllDrives(true);
if (nextPageToken != null) {
request.setPageToken(nextPageToken);
}
Expand Down Expand Up @@ -142,6 +143,7 @@ private static PermissionApplyStatus applyServiceAccountPermission(

userDriveService.permissions().create(fileId, permission)
.setSendNotificationEmail(false)
.setSupportsAllDrives(true)
.execute();
log.info(
"Service account {} access granted. fileId={}, email={}",
Expand All @@ -165,6 +167,7 @@ private static PermissionApplyStatus applyServiceAccountPermission(

Permission updatedPermission = new Permission().setRole(targetRole);
userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission)
.setSupportsAllDrives(true)
.execute();
log.info(
"Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.util.StringUtils;

import com.google.api.services.drive.Drive;
import com.google.api.services.drive.model.File;
import com.google.auth.oauth2.ServiceAccountCredentials;

import gg.agit.konect.domain.user.enums.Provider;
Expand All @@ -23,15 +24,25 @@
public class GoogleSheetPermissionService {

private final ServiceAccountCredentials serviceAccountCredentials;
private final Drive googleDriveService;
private final GoogleSheetsConfig googleSheetsConfig;
private final UserOAuthAccountRepository userOAuthAccountRepository;

public void validateRequesterAccessAndTryGrantServiceAccountWriterAccess(
Integer requesterId,
String spreadsheetId
) {
String refreshToken = requireRefreshToken(requesterId);
Drive userDriveService = buildUserDriveService(refreshToken, requesterId);
validateRequesterSpreadsheetAccess(userDriveService, requesterId, spreadsheetId);
boolean granted = tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId);
if (!granted) {
requireServiceAccountSpreadsheetAccess(spreadsheetId, requesterId);
}
Comment on lines +31 to +41
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateRequesterAccessAndTryGrantServiceAccountWriterAccess()가 requireRefreshToken()을 사용해 OAuth 미연결/blank token을 곧바로 NOT_FOUND_GOOGLE_DRIVE_AUTH로 예외 처리하고 있습니다. 그런데 동일 PR에서 “OAuth 미연결이면 기존 동작 유지(스킵)”를 명시했고, ClubSheetIntegratedService 주석도 스킵을 기대하고 있어 실제 런타임 동작이 설명과 불일치합니다. 이 메서드를 (1) refresh token이 없으면 조용히 return 하도록 변경하거나, (2) ‘반드시 OAuth 연결이 필요’한 API에서만 호출되도록 호출부를 조정하는 등 의도에 맞게 계약을 명확히 해주세요.

Copilot uses AI. Check for mistakes.
}

public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) {
String refreshToken = userOAuthAccountRepository
.findByUserIdAndProvider(requesterId, Provider.GOOGLE)
.map(account -> account.getGoogleDriveRefreshToken())
.filter(StringUtils::hasText)
.orElse(null);
String refreshToken = resolveRefreshToken(requesterId);

if (refreshToken == null) {
log.warn(
Expand All @@ -41,14 +52,35 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp
return false;
}

Drive userDriveService;
try {
userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken);
} catch (IOException | GeneralSecurityException e) {
log.error("Failed to build user Drive service. requesterId={}", requesterId, e);
throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE);
}
Drive userDriveService = buildUserDriveService(refreshToken, requesterId);
return tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId);
}

private String requireRefreshToken(Integer requesterId) {
return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE)
.map(account -> account.getGoogleDriveRefreshToken())
.filter(StringUtils::hasText)
.orElseThrow(() -> {
log.warn(
"Rejecting spreadsheet registration because Google Drive OAuth is not connected. requesterId={}",
requesterId
);
return CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH);
});
}

private String resolveRefreshToken(Integer requesterId) {
return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE)
.map(account -> account.getGoogleDriveRefreshToken())
.filter(StringUtils::hasText)
.orElse(null);
}

private boolean tryGrantServiceAccountWriterAccess(
Drive userDriveService,
Integer requesterId,
String spreadsheetId
) {
try {
GoogleDrivePermissionHelper.ensureServiceAccountPermission(
userDriveService,
Expand Down Expand Up @@ -91,6 +123,105 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp
}
}

private Drive buildUserDriveService(String refreshToken, Integer requesterId) {
try {
return googleSheetsConfig.buildUserDriveService(refreshToken);
} catch (IOException | GeneralSecurityException e) {
log.error("Failed to build user Drive service. requesterId={}", requesterId, e);
throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE);
}
}

private void validateRequesterSpreadsheetAccess(
Drive userDriveService,
Integer requesterId,
String spreadsheetId
) {
try {
File file = userDriveService.files().get(spreadsheetId)
.setFields("id")
.setSupportsAllDrives(true)
.execute();
Comment on lines +141 to +144
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

service_file="$(fd -i 'GoogleSheetPermissionService.java' src | head -n1)"
helper_file="$(fd -i 'GoogleDrivePermissionHelper.java' src | head -n1)"

# 신규 접근 검증 경로에서 supportsAllDrives 사용 여부 확인
rg -n -C2 'files\(\)\.get|setSupportsAllDrives' "$service_file"

# 권한 조회/부여 helper 경로에서도 동일 플래그 사용 여부 확인
if [[ -n "${helper_file:-}" ]]; then
  rg -n -C2 'permissions\(\)\.(list|create|update)|setSupportsAllDrives' "$helper_file"
fi

Repository: BCSDLab/KONECT_BACK_END

Length of output: 909


공유 드라이브 스프레드시트 접근 검증에 setSupportsAllDrives(true) 누락

[LEVEL: high] Line 135-137의 files().get(spreadsheetId) 호출과 이후 권한 조회/부여 API들이 Google Drive API의 공유 드라이브 지원 플래그를 설정하지 않아, 공유 드라이브에 있는 스프레드시트는 요청자가 실제 접근 권한을 가져도 검증이 실패할 수 있습니다. 이로 인해 동아리 문서가 공유 드라이브에 위치할 경우 정상적인 등록 요청도 차단되는 장애가 발생할 수 있습니다. Line 135-137의 files().get(), 96의 permissions().list(), 143의 permissions().create(), 167의 permissions().update() 모두에 .setSupportsAllDrives(true)를 추가하여 shared drive 접근성을 보장하세요.

관련 코드
Line 135-137:
File file = userDriveService.files().get(spreadsheetId)
    .setFields("id")
    .execute();

Line 96:
Drive.Permissions.List request = driveService.permissions().list(fileId)
    .setFields(PERMISSION_FIELDS);

Line 143-145:
userDriveService.permissions().create(fileId, permission)
    .setSendNotificationEmail(false)
    .execute();

Line 167-168:
userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission)
    .execute();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java`
around lines 135 - 137, Add .setSupportsAllDrives(true) to all Drive API calls
that may operate on shared drives to ensure shared-drive spreadsheets are
handled: update userDriveService.files().get(spreadsheetId) call,
driveService.permissions().list(fileId) call,
userDriveService.permissions().create(fileId, permission) call, and
userDriveService.permissions().update(fileId, existingPermission.getId(),
updatedPermission) so each request includes .setSupportsAllDrives(true) before
.execute() (in GoogleSheetPermissionService where these symbols are used).

if (file == null || !StringUtils.hasText(file.getId())) {
throw GoogleSheetApiExceptionHelper.accessDenied();
}
} catch (IOException e) {
if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) {
log.warn(
"Google Drive OAuth token is invalid while validating spreadsheet access. requesterId={}, "
+ "spreadsheetId={}, cause={}",
requesterId,
spreadsheetId,
GoogleSheetApiExceptionHelper.extractDetail(e)
);
throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e);
}

if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) {
log.warn(
"Google Drive OAuth auth failure while validating spreadsheet access. requesterId={}, "
+ "spreadsheetId={}, cause={}",
requesterId,
spreadsheetId,
GoogleSheetApiExceptionHelper.extractDetail(e)
);
throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e);
}

if (GoogleSheetApiExceptionHelper.isAccessDenied(e)
|| GoogleSheetApiExceptionHelper.isNotFound(e)) {
log.warn(
"Requester has no spreadsheet access. requesterId={}, spreadsheetId={}, cause={}",
requesterId,
spreadsheetId,
e.getMessage()
);
throw GoogleSheetApiExceptionHelper.accessDenied();
}

log.error(
"Unexpected error while validating requester spreadsheet access. requesterId={}, spreadsheetId={}",
requesterId,
spreadsheetId,
e
);
throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET);
}
}

private void requireServiceAccountSpreadsheetAccess(String spreadsheetId, Integer requesterId) {
try {
File file = googleDriveService.files().get(spreadsheetId)
.setFields("id")
.setSupportsAllDrives(true)
.execute();
if (file == null || !StringUtils.hasText(file.getId())) {
throw GoogleSheetApiExceptionHelper.accessDenied();
}
} catch (IOException e) {
if (GoogleSheetApiExceptionHelper.isAccessDenied(e)
|| GoogleSheetApiExceptionHelper.isNotFound(e)) {
log.warn(
"Service account has no spreadsheet access after auto-share failed. requesterId={}, "
+ "spreadsheetId={}, cause={}",
requesterId,
spreadsheetId,
e.getMessage()
);
throw GoogleSheetApiExceptionHelper.accessDenied();
}

log.error(
"Unexpected error while re-checking service account spreadsheet access. requesterId={}, "
+ "spreadsheetId={}",
requesterId,
spreadsheetId,
e
);
throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET);
}
}

private String getServiceAccountEmail() {
return serviceAccountCredentials.getClientEmail();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verifyNoInteractions;

Expand Down Expand Up @@ -53,8 +54,6 @@ void analyzeAndImportPreMembersSuccess() {
new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null);
SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn"));

given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId))
.willReturn(true);
given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis);
given(sheetImportService.importPreMembersFromSheet(
clubId,
Expand All @@ -81,7 +80,7 @@ void analyzeAndImportPreMembersSuccess() {
);
inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId);
inOrder.verify(googleSheetPermissionService)
.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId);
inOrder.verify(clubMemberSheetService).updateSheetId(
clubId,
Expand All @@ -99,8 +98,8 @@ void analyzeAndImportPreMembersSuccess() {
}

@Test
@DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다")
void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() {
@DisplayName("구글 시트 권한 검증 후에도 기존 흐름대로 사전 회원 가져오기를 수행한다")
void analyzeAndImportPreMembersContinuesAfterPermissionValidation() {
// given
Integer clubId = 1;
Integer requesterId = 2;
Expand All @@ -111,8 +110,6 @@ void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() {
new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null);
SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of());

given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId))
.willReturn(false);
given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis);
given(sheetImportService.importPreMembersFromSheet(
clubId,
Expand All @@ -139,7 +136,7 @@ void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() {
);
inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId);
inOrder.verify(googleSheetPermissionService)
.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId);
inOrder.verify(clubMemberSheetService).updateSheetId(
clubId,
Expand Down Expand Up @@ -167,8 +164,56 @@ void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() {
String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms";
CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE);

given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId))
.willThrow(expected);
willThrow(expected).given(googleSheetPermissionService)
.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);

// when & then
assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers(
clubId,
requesterId,
spreadsheetUrl
))
.isSameAs(expected);
verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService);
}

@Test
@DisplayName("요청자 계정이 시트 접근 권한이 없으면 후속 시트 작업을 진행하지 않는다")
void analyzeAndImportPreMembersStopsWhenRequesterHasNoSpreadsheetAccess() {
// given
Integer clubId = 1;
Integer requesterId = 2;
String spreadsheetUrl =
"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit";
String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms";
CustomException expected = CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS);

willThrow(expected).given(googleSheetPermissionService)
.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);

// when & then
assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers(
clubId,
requesterId,
spreadsheetUrl
))
.isSameAs(expected);
verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService);
}

@Test
@DisplayName("Drive OAuth가 연결되지 않으면 후속 시트 작업을 진행하지 않는다")
void analyzeAndImportPreMembersStopsWhenGoogleDriveOAuthIsNotConnected() {
// given
Integer clubId = 1;
Integer requesterId = 2;
String spreadsheetUrl =
"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit";
String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms";
CustomException expected = CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH);

willThrow(expected).given(googleSheetPermissionService)
.validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);

// when & then
assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers(
Expand Down
Loading
Loading