Google Workspace

다운로드 220
업데이트 2026. 10. 15.

권한 점검 스크립트

수집기나 확장 명령어가 권한 오류로 실패하는데 원인을 찾기 어렵다면, 점검 스크립트로 소나 접속 프로파일의 설정 그대로 구글 API를 직접 호출해 기능별로 사용할 수 있는지 확인할 수 있습니다. 사용할 수 없는 기능은 이유와 조치 방법을 함께 보여 줍니다.

  • 구글 워크스페이스 앱 1.9.2610.0 이상과 같은 방식으로 호출합니다. 서비스 계정 방식과 사용자 계정 OAuth 방식을 모두 지원합니다.
  • 조회만 하며 구글 워크스페이스나 소나의 설정을 바꾸지 않습니다. 항목마다 1건만 조회합니다.
  • 결과는 한국어로 출력하며, -l en 옵션을 주면 영어로 출력합니다.

스크립트 구성

스크립트실행할 서버하는 일
sonar_profile_export.sh소나 웹 콘솔(API)에 접속할 수 있는 서버소나 접속 프로파일의 설정값(고객 ID, 위임된 계정, HTTP 프록시)을 파일로 저장
gws_permission_check.sh인터넷(구글)에 접속할 수 있는 서버저장한 파일과 서비스 키로 구글 API를 호출해 기능별 사용 가능 여부를 점검

소나 서버가 인터넷에 직접 접속할 수 있다면 두 스크립트를 같은 서버에서 실행해도 됩니다.

준비물

  • 리눅스 서버의 bash(4 이상), curl, openssl 명령 (awk, sed 등 기본 명령 외에 별도 설치 불필요)
  • 소나 API 키: 소나 웹 콘솔 계정 > 계정 > (해당 계정) > API 키에서 발급합니다. 점검할 접속 프로파일을 볼 수 있는 계정이어야 합니다.
  • 서비스 키 JSON 파일: 접속 프로파일의 서비스 키에 넣은 것과 같은 내용(서비스 계정 JSON 키 또는 authorized_user JSON)입니다. 소나는 보안을 위해 서비스 키를 API로 제공하지 않으므로 파일이 따로 필요합니다.

단계 1: 스크립트 파일 만들기

이 페이지 맨 아래 스크립트 전문의 두 스크립트를 각각 같은 이름의 파일로 저장합니다. 복사한 내용을 붙여 넣을 때 줄바꿈이 바뀌지 않도록 리눅스 서버에서 직접 편집기로 저장하는 것을 권장합니다.

단계 2: 접속 프로파일 값 추출

소나에 접속할 수 있는 서버에서 실행합니다. 구글 워크스페이스 접속 프로파일의 유형은 google-workspace입니다. 모든 구글 워크스페이스 접속 프로파일을 한 번에 저장하려면 -t google-workspace를 지정합니다.

sh sonar_profile_export.sh -s https://(소나 주소) -t google-workspace

특정 접속 프로파일만 저장하려면 -t 대신 -n에 식별자를 지정합니다. 쉼표로 여러 개를 지정할 수 있습니다.

sh sonar_profile_export.sh -s https://(소나 주소) -n (식별자)

실행하면 소나 API 키를 묻습니다. 입력한 키는 화면에 표시되지 않으며, SONAR_API_KEY 환경 변수로 줄 수도 있습니다. 소나는 보통 사설 인증서를 쓰므로 스크립트는 소나에 접속할 때 인증서를 검증하지 않습니다.

접속 프로파일마다 profile_(식별자)_(실행시각).txt 파일이 만들어집니다. 이 파일에는 서비스 키 같은 보호 항목이 들어 있지 않으며, 실행한 계정만 읽을 수 있습니다.

단계 3: 권한 점검 실행

단계 2에서 만든 파일과 서비스 키 JSON 파일을 인터넷에 접속할 수 있는 서버로 옮긴 뒤 실행합니다. 접속 프로파일이 여러 개라면 파일마다 실행합니다.

sh gws_permission_check.sh -k (서비스 키 JSON 파일) -f (단계 2에서 추출한 파일)
옵션필수 여부설명
-k필수서비스 키 JSON 파일
-f필수단계 2에서 추출한 파일. 고객 ID와 위임된 계정을 이 파일에서 읽습니다
-u선택사용자 계정 OAuth 방식에서 동의한 계정을 지정하면 관리자 여부도 확인합니다. 서비스 계정 방식에서는 파일의 위임된 계정 대신 이 값을 씁니다
-p선택이 서버에서 구글에 접속할 때 HTTP 프록시가 필요하면 IP:PORT로 지정합니다. 접속 프로파일의 프록시는 소나 서버 기준 값이라 자동으로 쓰지 않습니다
-l선택출력 언어. en이면 영어로 출력합니다

점검 결과 해석

스크립트는 아래 순서로 점검합니다.

단계이름점검 내용
[0]프로파일 파일 확인접속 프로파일의 식별자·이름, 고객 ID, 위임된 계정, HTTP 프록시 값
[1]서비스 키 확인인증 방식, 서비스 계정 또는 OAuth 클라이언트 ID, 구글 클라우드 프로젝트
[2]토큰 발급서비스 계정 방식은 앱과 같이 기능마다 필요한 범위만 요청해, 도메인 전체 위임에 빠진 범위를 찾습니다. OAuth 방식은 리프레시 토큰으로 토큰을 받습니다
[2-1]토큰 정보 확인토큰의 클라이언트, 사용자, 범위
[3]계정의 관리자 여부최고 관리자·맞춤 역할 관리자 여부, 계정의 실제 고객 ID
[4]기능별 API 호출감사 로그, 드라이브 로그, 알림 센터, 도메인·사용자·그룹, 드라이브 앱 조회

마지막에 기능별 결과와 요약이 표시됩니다. 기능은 1. 기능별 필요 권한의 표와 같은 단위입니다.

표시의미
사용 가능해당 기능의 API 호출이 성공
사용 불가범위·관리자 권한·API 설정 등이 부족. 요약에 조치 방법 표시
지원 안 함인증 방식이 지원하지 않는 기능 (OAuth 방식의 알림 센터)

필요한 권한만 준비한 경우 사용하지 않는 기능은 사용 불가로 표시됩니다. 사용하지 않는 기능의 항목은 조치하지 않아도 됩니다. 스프레드시트 확장 명령어는 범위만 확인하며, 스프레드시트 공유 여부는 확인하지 않습니다.

요약 예시 (맞춤 역할에 도메인 관리 권한이 빠진 경우):

요약: 조치가 필요한 항목 1건
  (사용하지 않는 기능의 항목은 조치하지 않아도 됩니다)

  1. 사용 불가: domains; users; groups, users (group 옵션)
     -> 조치: 맞춤 역할에 '도메인 관리' 권한을 추가하세요: 관리 콘솔 > 계정 > 관리자 역할 > (해당 역할) > 권한
Note
[2]가 정상이어도 [4]가 실패할 수 있습니다. [2]는 앱이 해당 범위로 토큰을 받을 수 있는지(도메인 전체 위임)를, [4]는 위임된 계정의 관리자 역할에 그 데이터를 볼 권한이 있는지를 확인하기 때문입니다.

결과 전달

결과는 화면과 함께 현재 폴더의 gws_check_result_(실행시각).txt 파일에 저장됩니다. 실패한 호출이 있으면 파일 끝 부록에 구글의 응답 원문이 함께 저장됩니다.

조치 후에도 해결되지 않으면 이 결과 파일을 로그프레소 기술지원에 전달해 주세요. 결과 파일에는 비공개 키, 클라이언트 보안 비밀번호, 리프레시 토큰, 액세스 토큰이 포함되지 않지만, 위임된 계정 이메일과 고객 ID는 포함됩니다.

스크립트 전문

sonar_profile_export.sh

#!/usr/bin/env bash
#
# 로그프레소 소나 접속 프로파일 추출 스크립트 / Logpresso Sonar connect profile export script
#
# 소나 API로 접속 프로파일의 설정값을 읽어 프로파일마다 파일로 저장합니다. 저장한 파일은 외부 통신이 되는
# 서버로 옮겨 앱별 점검 스크립트의 -f 옵션으로 사용합니다. 암호, 서비스 키 같은 보호 항목은 소나가 API로
# 돌려주지 않으므로 파일에 포함되지 않습니다.
#
# 요구 사항 / Requirements: bash, curl, awk (소나 API에 접속할 수 있는 서버에서 실행)
#
# 사용법 / Usage:
#   sh sonar_profile_export.sh -s https://SONAR -t TYPE         유형의 모든 프로파일 저장 / export all of a type
#   sh sonar_profile_export.sh -s https://SONAR -n PROFILE[,..] 지정한 프로파일 저장 / export profiles
#
#   -s  소나 주소 / Sonar URL (e.g. https://192.0.2.10:443)
#   -n  접속 프로파일 식별자 또는 이름, 쉼표로 여러 개 / profile identifier or name, comma separated
#   -t  접속 프로파일 유형 (앱 설치 매뉴얼에 표기) / profile type (see the app install guide)
#   -l  출력 언어 ko(기본) 또는 en / output language ko (default) or en
#
# 소나 API 키(계정 > 계정 > 해당 계정 > API 키)는 실행 중에 입력받습니다 (화면에 표시되지 않음).
# SONAR_API_KEY 환경 변수로도 줄 수 있습니다. / The Sonar API key is prompted, or set SONAR_API_KEY.
# 결과 파일 / Output: profile_<식별자>_<실행시간>.txt (소유자만 읽기 가능 / owner-only)
# 소나는 보통 사설 인증서를 쓰므로 소나 접속 시 인증서 검증은 하지 않습니다.

# sh(dash 등)로 실행해도 bash 로 다시 실행
if [ -z "$BASH_VERSION" ] || case ":$SHELLOPTS:" in *:posix:*) true ;; *) false ;; esac; then
	exec bash "$0" "$@"
fi

# 만드는 파일은 소유자만 읽을 수 있게
umask 077

SONAR=""
NAMES=""
TYPE=""
LANG_OPT=ko

usage() {
	sed -n '11,18p' "$0" | sed 's/^# \{0,1\}//'
	exit 2
}

while getopts "s:n:t:l:h" opt; do
	case "$opt" in
		s) SONAR="${OPTARG%/}" ;;
		n) NAMES="$OPTARG" ;;
		t) TYPE="$OPTARG" ;;
		l) LANG_OPT="$OPTARG" ;;
		*) usage ;;
	esac
done
[ -z "$SONAR" ] && usage
[ -z "$NAMES" ] && [ -z "$TYPE" ] && usage

# 언어별 문구 선택 / pick message by language
t() {
	if [ "$LANG_OPT" = "en" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}

if ! command -v curl >/dev/null 2>&1; then
	t "curl 명령이 필요합니다." "curl is required."; echo
	exit 2
fi

API_KEY="${SONAR_API_KEY:-}"
if [ -z "$API_KEY" ]; then
	printf '%s: ' "$(t "소나 API 키" "Sonar API key")"
	read -r -s API_KEY
	echo ""
fi
[ -z "$API_KEY" ] && usage

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

# curl 설정 파일 값의 따옴표·역슬래시 처리
cfg_esc() {
	printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}

# 프로파일 배열 원소를 한 줄에 하나씩 출력 (문자열 안의 괄호는 무시)
split_profiles() {
	tr -d '\n\r' | awk '
	{
		s = $0; n = length(s); depth = 0; instr = 0; esc = 0; start = 0; arr = 0
		p = index(s, "\"profiles\"")
		if (p == 0) exit
		for (i = p; i <= n; i++) {
			c = substr(s, i, 1)
			if (instr) {
				if (esc) esc = 0
				else if (c == "\\") esc = 1
				else if (c == "\"") instr = 0
				continue
			}
			if (c == "\"") { instr = 1; continue }
			if (!arr) { if (c == "[") arr = 1; continue }
			if (c == "{") { if (depth == 0) start = i; depth++ }
			else if (c == "}") { depth--; if (depth == 0) print substr(s, start, i - start + 1) }
			else if (c == "]" && depth == 0) exit
		}
	}'
}

# 프로파일 객체의 최상위 문자열 필드 값 (code, name, type)
top_str() {
	awk -v key="$1" '
	{
		s = $0; n = length(s); depth = 0; instr = 0; esc = 0; buf = ""; want = 0
		for (i = 1; i <= n; i++) {
			c = substr(s, i, 1)
			if (instr) {
				if (esc) { esc = 0; buf = buf c; continue }
				if (c == "\\") { esc = 1; continue }
				if (c == "\"") {
					instr = 0
					if (want) { print buf; exit }
					if (depth == 1) last = buf
					continue
				}
				buf = buf c
				continue
			}
			if (c == "\"") { instr = 1; buf = ""; continue }
			if (c == "{" || c == "[") depth++
			else if (c == "}" || c == "]") depth--
			else if (c == ":" && depth == 1 && last == key) {
				j = i + 1
				while (substr(s, j, 1) == " ") j++
				if (substr(s, j, 1) == "\"") want = 1
				else exit
			} else if (c == ",") last = ""
		}
	}'
}

# configs 객체를 "키<TAB>값" 줄로 출력. null은 출력하지 않음
configs_lines() {
	awk '
	{
		s = $0; n = length(s)
		p = index(s, "\"configs\"")
		if (p == 0) exit
		i = p + 9
		while (i <= n && substr(s, i, 1) != "{") i++
		i++
		while (i <= n) {
			while (i <= n && index(" ,\t", substr(s, i, 1))) i++
			c = substr(s, i, 1)
			if (c == "}" || i > n) break
			# 키
			i++; key = ""
			while (i <= n) {
				c = substr(s, i, 1)
				if (c == "\\") { key = key substr(s, i + 1, 1); i += 2; continue }
				if (c == "\"") { i++; break }
				key = key c; i++
			}
			while (i <= n && substr(s, i, 1) != ":") i++
			i++
			while (i <= n && substr(s, i, 1) == " ") i++
			c = substr(s, i, 1)
			if (c == "\"") {
				i++; val = ""
				while (i <= n) {
					c = substr(s, i, 1)
					if (c == "\\") {
						d = substr(s, i + 1, 1)
						if (d == "n") val = val " "
						else if (d == "t") val = val " "
						else val = val d
						i += 2; continue
					}
					if (c == "\"") { i++; break }
					val = val c; i++
				}
				print key "\t" val
			} else if (c == "{" || c == "[") {
				depth = 0; st = i
				while (i <= n) {
					c = substr(s, i, 1)
					if (c == "{" || c == "[") depth++
					else if (c == "}" || c == "]") { depth--; if (depth == 0) { i++; break } }
					i++
				}
				print key "\t" substr(s, st, i - st)
			} else {
				st = i
				while (i <= n && !index(",}", substr(s, i, 1))) i++
				val = substr(s, st, i - st)
				gsub(/ +$/, "", val)
				if (val != "null") print key "\t" val
			}
		}
	}'
}

# protected_keys 배열의 값 목록
protected_keys() {
	grep -o '"protected_keys"[[:space:]]*:[[:space:]]*\[[^]]*\]' | grep -o '"[^"]*"' | sed -n '2,$p' | tr -d '"'
}

# 소나 API에서 필요한 프로파일만 조회 (-t: 유형 필터, -n: 이름마다 검색어 필터)
fetch() {
	# API 키가 프로세스 목록에 보이지 않도록 curl 설정을 표준 입력으로 전달
	STATUS=$(printf 'header = "Authorization: Bearer %s"\n' "$(cfg_esc "$API_KEY")" | \
		curl -s -S -k --max-time 30 -K - -o "$WORK_DIR/profiles" -w '%{http_code}' -G \
		--data-urlencode "$1" "$SONAR/api/sonar/connect-profiles" 2>"$WORK_DIR/curl_err")
	if [ "$STATUS" = "000" ] || [ -z "$STATUS" ]; then
		echo "$(t "[실패] 소나에 접속하지 못했습니다" "[FAIL] Cannot connect to Sonar"): $(head -c 300 "$WORK_DIR/curl_err")"
		exit 1
	elif [ "$STATUS" = "401" ] || [ "$STATUS" = "403" ]; then
		echo "[$(t "실패" "FAIL")] HTTP $STATUS - $(t "소나 API 키가 틀렸거나 접속 프로파일 조회 권한이 없습니다." "Invalid Sonar API key or no permission to read connect profiles.")"
		exit 1
	elif [ "$STATUS" != "200" ]; then
		echo "[$(t "실패" "FAIL")] HTTP $STATUS $(head -c 300 "$WORK_DIR/profiles")"
		exit 1
	fi
	split_profiles < "$WORK_DIR/profiles" >> "$WORK_DIR/lines"
}

: > "$WORK_DIR/lines"
[ -n "$TYPE" ] && fetch "type=$TYPE"
if [ -n "$NAMES" ]; then
	IFS=',' read -r -a wanted <<< "$NAMES"
	for w in "${wanted[@]}"; do
		fetch "keywords=$w"
	done
fi
# 여러 조회에 같은 프로파일이 나오면 한 번만 처리
sort -u "$WORK_DIR/lines" -o "$WORK_DIR/lines"

TS=$(date +%Y%m%d_%H%M%S)
COUNT=0
FOUND_NAMES=","
while IFS= read -r line; do
	code=$(printf '%s' "$line" | top_str code)
	pname=$(printf '%s' "$line" | top_str name)
	ptype=$(printf '%s' "$line" | top_str type)
	match=0
	if [ -n "$TYPE" ] && [ "$ptype" = "$TYPE" ]; then
		match=1
	fi
	if [ -n "$NAMES" ]; then
		IFS=',' read -r -a wanted <<< "$NAMES"
		for w in "${wanted[@]}"; do
			if [ "$w" = "$code" ] || [ "$w" = "$pname" ]; then
				match=1
				FOUND_NAMES="$FOUND_NAMES$w,"
			fi
		done
	fi
	[ "$match" -eq 1 ] || continue

	# 파일 이름에는 영문·숫자·_·- 만 사용 (경로 문자 차단)
	safe=$(printf '%s' "$code" | tr -c 'A-Za-z0-9_-' '_')
	out="$(pwd)/profile_${safe}_${TS}.txt"
	{
		echo "# $(t "로그프레소 소나 접속 프로파일" "Logpresso Sonar connect profile") (sonar_profile_export.sh, $(date '+%Y-%m-%d %H:%M:%S'))"
		echo "sonar=$SONAR"
		echo "profile_code=$code"
		echo "profile_name=$pname"
		echo "profile_type=$ptype"
		prot=$(printf '%s' "$line" | protected_keys | paste -sd, -)
		echo "protected_keys=$prot"
		# 보호 항목은 소나가 빈 값으로 주므로 저장하지 않음 (빈 값과 헷갈리지 않게)
		printf '%s' "$line" | configs_lines | while IFS="$(printf '\t')" read -r k v; do
			case ",$prot," in
				*",$k,"*) continue ;;
			esac
			echo "config.$k=$(printf '%s' "$v" | tr -s ' ')"
		done
	} > "$out"
	COUNT=$((COUNT + 1))

	echo ""
	echo "[$COUNT] $(t "식별자" "Identifier"): $code"
	echo "    $(t "이름" "Name"): $pname"
	echo "    $(t "유형" "Type"): $ptype"
	echo "    $(t "설정" "Settings"):"
	grep '^config\.' "$out" | sed 's/^config\./      /'
	echo "    $(t "저장 파일" "Saved file"): $out"
done < "$WORK_DIR/lines"

if [ -n "$NAMES" ]; then
	IFS=',' read -r -a wanted <<< "$NAMES"
	for w in "${wanted[@]}"; do
		case "$FOUND_NAMES" in
			*",$w,"*) ;;
			*) echo ""; echo "[$(t "실패" "FAIL")] $(t "식별자나 이름이 '$w'인 접속 프로파일이 없습니다." "No connect profile with identifier or name '$w'.")" ;;
		esac
	done
fi

echo ""
if [ "$COUNT" -eq 0 ]; then
	echo "$(t "저장한 프로파일이 없습니다. 식별자나 유형을 확인하세요." "No profile exported. Check the identifier or type.")"
	exit 1
fi
echo "$(t "저장한 프로파일" "Exported profiles"): $COUNT"
echo "$(t "보호 항목(암호, 서비스 키 등)은 소나가 제공하지 않아 파일에 포함되지 않습니다." "Protected values (password, service key, etc.) are not provided by Sonar and are not included.")"
echo "$(t "파일을 외부 통신이 되는 서버로 옮겨 앱별 점검 스크립트의 -f 옵션으로 사용하세요." "Copy the files to a server with internet access and pass them to the app check script with -f.")"

gws_permission_check.sh

#!/usr/bin/env bash
#
# 구글 워크스페이스 앱 권한 점검 스크립트 / Google Workspace app permission check script
#
# 로그프레소 구글 워크스페이스 앱(1.9.2610.0 이상)과 같은 방식으로 구글 API를 호출해 보고, 기능별로 사용
# 가능한지와 사용할 수 없으면 그 이유와 조치 방법을 알려줍니다. 서비스 계정 방식과 사용자 계정 OAuth 방식을
# 모두 지원합니다. 데이터를 변경하는 호출은 하지 않으며, 조회는 1건만 요청합니다.
#
# 요구 사항 / Requirements: Linux, bash 4+, curl, openssl (구글에 접속할 수 있는 서버에서 실행)
#
# 사용법 / Usage:
#   sh gws_permission_check.sh -k KEY.json -f profile_FILE.txt [-u ACCOUNT] [-p IP:PORT] [-l en]
#
#   -k  접속 프로파일의 "서비스 키"에 넣은 JSON 파일 (서비스 계정 키 또는 authorized_user)
#   -f  sonar_profile_export.sh 가 만든 프로파일 파일 (고객 ID, 위임된 계정을 읽음) / profile file
#   -u  선택. OAuth 방식: 동의한 계정(관리자 여부 확인), 서비스 계정 방식: 파일 대신 쓸 위임된 계정
#       optional. OAuth: consenting account (admin check), service account: delegated user overriding the file
#   -p  이 서버에서 구글에 접속할 때 쓸 HTTP 프록시 / HTTP proxy for this server
#   -l  출력 언어 ko(기본) 또는 en / output language ko (default) or en
#
# 결과는 화면과 함께 gws_check_result_<실행시간>.txt 파일에 저장됩니다 (소유자만 읽기 가능).
# 비공개 키, 클라이언트 보안 비밀번호, 리프레시 토큰, 액세스 토큰은 출력하거나 저장하지 않습니다.

# sh(dash 등)로 실행해도 bash 로 다시 실행
if [ -z "$BASH_VERSION" ] || case ":$SHELLOPTS:" in *:posix:*) true ;; *) false ;; esac; then
	exec bash "$0" "$@"
fi

# 만드는 파일은 소유자만 읽을 수 있게
umask 077

TOKEN_URL="https://oauth2.googleapis.com/token"
SCOPE_PREFIX="https://www.googleapis.com/auth/"
S_REPORTS="${SCOPE_PREFIX}admin.reports.audit.readonly"
S_DOMAIN="${SCOPE_PREFIX}admin.directory.domain.readonly"
S_USER="${SCOPE_PREFIX}admin.directory.user.readonly"
S_GROUP="${SCOPE_PREFIX}admin.directory.group.readonly"
S_ALERTS="${SCOPE_PREFIX}apps.alerts"
S_DRIVE_APPS="${SCOPE_PREFIX}drive.apps.readonly"
S_SHEETS_RO="${SCOPE_PREFIX}spreadsheets.readonly"
S_SHEETS="${SCOPE_PREFIX}spreadsheets"

KEY_FILE=""
PROFILE_FILE=""
DELEGATED_USER=""
CUSTOMER=""
PROXY=""
LANG_OPT=ko

usage() {
	sed -n '11,19p' "$0" | sed 's/^# \{0,1\}//'
	exit 2
}

while getopts "k:f:u:p:l:h" opt; do
	case "$opt" in
		k) KEY_FILE="$OPTARG" ;;
		f) PROFILE_FILE="$OPTARG" ;;
		u) DELEGATED_USER="$OPTARG" ;;
		p) PROXY="$OPTARG" ;;
		l) LANG_OPT="$OPTARG" ;;
		*) usage ;;
	esac
done
[ -z "$KEY_FILE" ] || [ -z "$PROFILE_FILE" ] && usage

# 언어별 문구 선택 / pick message by language
t() {
	if [ "$LANG_OPT" = "en" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}

for cmd in curl openssl; do
	if ! command -v "$cmd" >/dev/null 2>&1; then
		echo "$cmd $(t "명령이 필요합니다." "is required.")"
		exit 2
	fi
done

RESULT_FILE="$(pwd)/gws_check_result_$(date +%Y%m%d_%H%M%S).txt"
: > "$RESULT_FILE"
WORK_DIR="$(mktemp -d)"
chmod 700 "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT

# 관리 콘솔·구글 클라우드 메뉴 경로
P_ROLE=$(t "관리 콘솔 > 디렉터리 > 사용자 > (위임된 계정) > 관리자 역할 및 권한" "Admin console > Directory > Users > (delegated user) > Admin roles and privileges")
P_PRIV=$(t "관리 콘솔 > 계정 > 관리자 역할 > (해당 역할) > 권한" "Admin console > Account > Admin roles > (role) > Privileges")
P_CUST=$(t "관리 콘솔 > 계정 > 계정 설정 > 고객 ID" "Admin console > Account > Account settings > Customer ID")
P_DWD=$(t "관리 콘솔 > 보안 > 액세스 및 데이터 관리 > API 관리 > 도메인 전체 위임 관리" "Admin console > Security > Access and data control > API controls > Manage domain-wide delegation")
P_LIB=$(t "구글 클라우드 콘솔 > API 및 서비스 > 라이브러리" "Google Cloud console > APIs & Services > Library")

PROBLEMS=()
FIXES=()

log() {
	echo "$*"
	echo "$*" >> "$RESULT_FILE"
}

add_problem() {
	PROBLEMS+=("$1")
	FIXES+=("$2")
}

# 실패한 호출의 응답 원문을 결과 파일 부록용으로 모음 (토큰은 저장하지 않음)
save_raw() {
	{
		echo ""
		echo "### $1"
		echo "$(t "요청" "Request"): $2"
		echo "HTTP: $HTTP_STATUS"
		head -c 3000 "$WORK_DIR/body"
		echo ""
	} >> "$WORK_DIR/appendix"
}

json_str() {
	grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n 1 | sed "s/^\"$1\"[[:space:]]*:[[:space:]]*\"//; s/\"\$//"
}

json_bool() {
	grep -o "\"$1\"[[:space:]]*:[[:space:]]*[a-z]*" | head -n 1 | sed 's/.*:[[:space:]]*//'
}

b64url() {
	openssl base64 -A | tr '+/' '-_' | tr -d '='
}

CURL_OPTS=(-s -S --max-time 30)
[ -n "$PROXY" ] && CURL_OPTS+=(-x "$PROXY")


# HTTP 호출. 첫 인자는 curl 설정 줄(비밀값), 나머지는 일반 인자.
# 키·토큰이 프로세스 목록에 보이지 않도록 비밀값은 curl 설정으로 표준 입력에 넘김
http_call() {
	local cfg="$1"
	shift
	HTTP_STATUS=$(printf '%s\n' "$cfg" | curl "${CURL_OPTS[@]}" -K - -o "$WORK_DIR/body" -w '%{http_code}' "$@" 2>"$WORK_DIR/curl_err")
	if [ "$HTTP_STATUS" = "000" ] || [ -z "$HTTP_STATUS" ]; then
		HTTP_STATUS="000"
		cp "$WORK_DIR/curl_err" "$WORK_DIR/body"
	fi
}

# curl 설정 파일 값의 따옴표·역슬래시 처리
cfg_esc() {
	printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}

# 액세스 토큰 인증 헤더 설정 줄
auth_cfg() {
	printf 'header = "Authorization: Bearer %s"' "$(cfg_esc "$1")"
}

api_msg() {
	if [ "$HTTP_STATUS" = "000" ]; then
		head -c 200 "$WORK_DIR/body"
	else
		json_str message < "$WORK_DIR/body"
	fi
}

# 서비스 계정 토큰 (scope는 공백으로 구분). 성공 시 TOKEN, 실패 시 TOKEN_ERR
sa_token() {
	local scope="$1" now header claims signing_input sig
	now=$(date +%s)
	header=$(printf '{"alg":"RS256","typ":"JWT","kid":"%s"}' "$KEY_ID" | b64url)
	claims=$(printf '{"iss":"%s","sub":"%s","scope":"%s","aud":"%s","iat":%s,"exp":%s}' \
		"$CLIENT_EMAIL" "$DELEGATED_USER" "$scope" "$TOKEN_URL" "$now" "$((now + 3600))" | b64url)
	signing_input="$header.$claims"
	if ! sig=$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$WORK_DIR/key.pem" 2>"$WORK_DIR/ssl_err" | b64url) || [ -z "$sig" ]; then
		TOKEN=""
		TOKEN_ERR="openssl: $(cat "$WORK_DIR/ssl_err")"
		return
	fi
	http_call "data-urlencode = \"assertion=$(cfg_esc "$signing_input.$sig")\"" \
		-X POST "$TOKEN_URL" --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
	token_result "POST $TOKEN_URL (sub=$DELEGATED_USER, scope=$scope)"
}

# OAuth 사용자 인증 정보(authorized_user)로 토큰 발급
oauth_token() {
	http_call "data-urlencode = \"client_secret=$(cfg_esc "$OA_SECRET")\"
data-urlencode = \"refresh_token=$(cfg_esc "$OA_REFRESH")\"" \
		-X POST "$TOKEN_URL" --data-urlencode "grant_type=refresh_token" --data-urlencode "client_id=$OA_CLIENT_ID"
	token_result "POST $TOKEN_URL (grant_type=refresh_token, client_id=$OA_CLIENT_ID)"
}

token_result() {
	TOKEN=$(json_str access_token < "$WORK_DIR/body")
	if [ "$HTTP_STATUS" = "200" ] && [ -n "$TOKEN" ]; then
		TOKEN_ERR=""
		return
	fi
	TOKEN=""
	save_raw "$(t "토큰 발급 실패" "Token request failed")" "$1"
	if [ "$HTTP_STATUS" = "000" ]; then
		TOKEN_ERR="$(t "접속 실패" "connection failed"): $(head -c 200 "$WORK_DIR/body")"
	else
		TOKEN_ERR="HTTP $HTTP_STATUS $(json_str error < "$WORK_DIR/body") $(json_str error_description < "$WORK_DIR/body")"
	fi
}

# 모든 기능에 공통인 토큰 오류면 안내 문구를 출력 (없으면 빈 문자열)
fatal_token_hint() {
	case "$1" in
		*"valid email"*|*"Invalid email"*) t "위임된 계정 주소가 틀렸거나 정지·삭제된 계정입니다. 접속 프로파일의 위임된 계정을 확인하세요." "The delegated user address is wrong, or the account is suspended or deleted. Check the delegated user in the connect profile." ;;
		*Signature*) t "서비스 계정 키가 삭제되었거나 사용 중지되었습니다. 구글 클라우드 콘솔에서 키를 새로 발급해 접속 프로파일에 넣으세요." "The service account key was deleted or disabled. Create a new key in the Google Cloud console and update the connect profile." ;;
		*"Invalid JWT"*) t "서버 시간이 맞지 않을 수 있습니다. 시간 동기화(NTP)를 확인하세요." "The server clock may be wrong. Check time synchronization (NTP)." ;;
		*invalid_client*) t "OAuth 클라이언트 ID 또는 보안 비밀번호가 틀렸습니다. 서비스 키 JSON의 client_id, client_secret을 확인하세요." "The OAuth client ID or secret is wrong. Check client_id and client_secret in the service key JSON." ;;
		*"expired or revoked"*|*invalid_grant*) t "리프레시 토큰이 만료되었거나 철회되었습니다. 리프레시 토큰을 다시 발급해 서비스 키 JSON을 바꾸세요." "The refresh token expired or was revoked. Issue a new refresh token and update the service key JSON." ;;
		*"접속 실패"*|*"connection failed"*|*openssl*) t "이 서버에서 구글(oauth2.googleapis.com)로 HTTPS 통신이 되는지 확인하세요. 프록시가 필요하면 -p 옵션으로 지정하세요." "Check HTTPS access from this server to oauth2.googleapis.com. Use -p if a proxy is required." ;;
	esac
}

# 기능 정의: 아이디|국문 이름|영문 이름|범위|관리자 권한(국문)|관리자 권한(영문)|구글 클라우드 API|서비스 계정 전용
FEATURES=(
	"audit|감사 로그 (Admin Activity·Login·Meet·Chat 수집기, admin·login·meet·chat·oauth-logs)|Audit logs (Admin Activity, Login, Meet, Chat loggers, admin/login/meet/chat/oauth-logs)|$S_REPORTS|보고서|Reports|Admin SDK API|no"
	"drive|드라이브 로그 (Drive Activity 수집기, drive-logs)|Drive logs (Drive Activity logger, drive-logs)|$S_REPORTS $S_DRIVE_APPS|보고서|Reports|Admin SDK API, Google Drive API|no"
	"alerts|알림 센터 (Alert Center 수집기, alerts)|Alert Center (Alert Center logger, alerts)|$S_ALERTS|서비스 > 알림 센터 > 전체 권한 > 보기 액세스|Services > Alert Center > Full access > View access|Google Workspace Alert Center API|yes"
	"domains|domains|domains|$S_DOMAIN|도메인 관리|Domain management|Admin SDK API|no"
	"users|users|users|$S_DOMAIN $S_USER|도메인 관리, 사용자 > 읽기|Domain management, Users > Read|Admin SDK API|no"
	"groups|groups, users (group 옵션)|groups, users (group option)|$S_DOMAIN $S_GROUP|도메인 관리, 그룹스 > 읽기|Domain management, Groups > Read|Admin SDK API|no"
	"driveapps|drive-apps, drive-app|drive-apps, drive-app|$S_DRIVE_APPS|||Google Drive API|no"
	"sheetsr|sheets-values|sheets-values|$S_SHEETS_RO|||Google Sheets API|no"
	"sheetsw|sheets-write-values|sheets-write-values|$S_SHEETS|||Google Sheets API|no"
)
declare -A F_NAME F_SCOPES F_PRIV F_API F_SAONLY F_STATUS F_REASON F_FIX F_KIND F_TOKEN F_DOMSTEP
FEATURE_IDS=()
for f in "${FEATURES[@]}"; do
	IFS='|' read -r id nko nen sc pko pen api sa <<< "$f"
	FEATURE_IDS+=("$id")
	F_NAME[$id]=$(t "$nko" "$nen")
	F_SCOPES[$id]="$sc"
	F_PRIV[$id]=$(t "$pko" "$pen")
	F_API[$id]="$api"
	F_SAONLY[$id]="$sa"
done

# 기능을 사용 불가로 표시. $1=기능, $2=종류(scope|privilege|api|network|other), $3=이유, $4=조치
fail_feature() {
	F_STATUS[$1]=fail
	F_KIND[$1]="$2"
	F_REASON[$1]="$3"
	F_FIX[$1]="$4"
}

finish() {
	log ""
	log "======================================================================"
	log "$(t "기능별 결과" "Result by feature")"
	for id in "${FEATURE_IDS[@]}"; do
		case "${F_STATUS[$id]}" in
			ok) log "  [$(t "사용 가능" "OK")] ${F_NAME[$id]}" ;;
			okscope) log "  [$(t "사용 가능" "OK")] ${F_NAME[$id]} - $(t "범위 확인됨 (스프레드시트 공유 여부는 확인하지 않음)" "scope OK (spreadsheet sharing not checked)")" ;;
			fail) log "  [$(t "사용 불가" "UNAVAILABLE")] ${F_NAME[$id]} - ${F_REASON[$id]}" ;;
			unsupported) log "  [$(t "지원 안 함" "UNSUPPORTED")] ${F_NAME[$id]} - ${F_REASON[$id]}" ;;
			*) log "  [$(t "확인 안 함" "NOT CHECKED")] ${F_NAME[$id]}" ;;
		esac
	done

	# 같은 조치가 필요한 기능은 하나로 묶음
	declare -A FIX_NAMES
	FIX_ORDER=()
	for id in "${FEATURE_IDS[@]}"; do
		[ "${F_STATUS[$id]}" = "fail" ] || continue
		[ "${F_FIX[$id]}" = "ALL_DENIED" ] && continue
		key="${F_FIX[$id]}"
		if [ -z "${FIX_NAMES[$key]+x}" ]; then
			FIX_ORDER+=("$key")
			FIX_NAMES[$key]="${F_NAME[$id]}"
		else
			FIX_NAMES[$key]="${FIX_NAMES[$key]}; ${F_NAME[$id]}"
		fi
	done
	for key in "${FIX_ORDER[@]}"; do
		add_problem "$(t "사용 불가" "Unavailable"): ${FIX_NAMES[$key]}" "$key"
	done

	log ""
	if [ ${#PROBLEMS[@]} -gt 0 ]; then
		log "$(t "요약: 조치가 필요한 항목" "Summary: items that need action") ${#PROBLEMS[@]}$(t "건" "")"
		[ ${#FIX_ORDER[@]} -gt 0 ] && log "$(t "  (사용하지 않는 기능의 항목은 조치하지 않아도 됩니다)" "  (Items for features you do not use can be ignored)")"
		for i in "${!PROBLEMS[@]}"; do
			log ""
			log "  $((i + 1)). ${PROBLEMS[$i]}"
			if [ -n "${FIXES[$i]}" ]; then
				prefix="-> $(t "조치" "Action"):"
				while IFS= read -r line; do
					log "     $prefix $line"
					prefix="        "
				done <<< "${FIXES[$i]}"
			fi
		done
	else
		log "$(t "요약: 모든 점검 통과" "Summary: all checks passed")"
	fi
	log "======================================================================"
	if [ -s "$WORK_DIR/appendix" ]; then
		{
			echo ""
			echo "$(t "부록: 실패한 호출의 구글 응답 원문 (원인 분석용, 로그프레소에 전달)" "Appendix: raw Google responses of failed calls (send to Logpresso for analysis)")"
			echo "----------------------------------------------------------------------"
			cat "$WORK_DIR/appendix"
		} >> "$RESULT_FILE"
		echo ""
		echo "$(t "실패한 호출의 응답 원문은 결과 파일 끝 '부록'에 저장했습니다." "Raw responses of failed calls are saved in the appendix at the end of the result file.")"
	fi
	echo ""
	echo "$(t "결과 파일" "Result file"): $RESULT_FILE"
	[ ${#PROBLEMS[@]} -gt 0 ] && exit 1
	exit 0
}

log "======================================================================"
log "$(t "구글 워크스페이스 앱 권한 점검" "Google Workspace app permission check")  ($(date '+%Y-%m-%d %H:%M:%S'))"
log "======================================================================"

# 0. 프로파일 파일
if [ -n "$PROFILE_FILE" ]; then
	log ""
	log "[0] $(t "프로파일 파일 확인" "Profile file")"
	if [ ! -r "$PROFILE_FILE" ]; then
		log "  [$(t "실패" "FAIL")] $(t "프로파일 파일을 읽을 수 없습니다" "Cannot read profile file"): $PROFILE_FILE"
		add_problem "$(t "프로파일 파일을 읽을 수 없음" "Cannot read profile file")" "$(t "-f 로 준 파일 경로를 확인하세요." "Check the path given with -f.")"
		finish
	fi
	pf_get() {
		grep "^$1=" "$PROFILE_FILE" | head -n 1 | cut -d= -f2-
	}
	pf_has() {
		grep -q "^$1=" "$PROFILE_FILE"
	}
	F_CUSTOMER=$(pf_get config.customer_key)
	F_USER=$(pf_get config.delegated_user)
	F_PROXY=$(pf_get config.http_proxy)
	log "  $(t "파일" "File"): $PROFILE_FILE"
	log "  $(t "소나" "Sonar"): $(pf_get sonar)"
	log "  $(t "식별자" "Identifier"): $(pf_get profile_code)"
	log "  $(t "이름" "Name"): $(pf_get profile_name)"
	log "  $(t "고객 ID" "Customer ID"): ${F_CUSTOMER:-$(t "(비어 있음, my_customer 사용)" "(empty, my_customer used)")}"
	log "  $(t "위임된 계정" "Delegated user"): ${F_USER:-$(t "(비어 있음)" "(empty)")}"
	if pf_has config.http_proxy && [ -z "$F_PROXY" ]; then
		log "  $(t "HTTP 프록시: (빈 값)" "HTTP proxy: (empty string)")"
		add_problem "$(t "접속 프로파일의 HTTP 프록시가 빈 값으로 저장됨" "The connect profile has an empty HTTP proxy value")" \
			"$(t "확장 명령어가 'Invalid HTTP proxy config:' 오류를 내면 로그프레소에 문의하세요." "If commands fail with 'Invalid HTTP proxy config:', contact Logpresso.")"
	else
		log "  $(t "HTTP 프록시" "HTTP proxy"): ${F_PROXY:-$(t "(설정 안 함)" "(not set)")}"
	fi
	if [ -n "$F_PROXY" ] && [ -z "$PROXY" ]; then
		log "  -> $(t "소나 서버는 프록시($F_PROXY)로 구글에 접속합니다. 이 서버도 프록시가 필요하면 -p 옵션으로 지정하세요." "Sonar connects to Google through proxy $F_PROXY. Use -p if this server also needs a proxy.")"
	fi
	[ -z "$DELEGATED_USER" ] && DELEGATED_USER="$F_USER"
	CUSTOMER="$F_CUSTOMER"
fi
[ -z "$CUSTOMER" ] && CUSTOMER="my_customer"

# 1. 서비스 키
log ""
log "[1] $(t "서비스 키 확인" "Service key")"
if [ ! -r "$KEY_FILE" ]; then
	log "  [$(t "실패" "FAIL")] $(t "키 파일을 읽을 수 없습니다" "Cannot read key file"): $KEY_FILE"
	add_problem "$(t "서비스 키 파일을 읽을 수 없음" "Cannot read service key file")" "$(t "-k 로 준 파일 경로와 읽기 권한을 확인하세요." "Check the path and permission of the file given with -k.")"
	finish
fi
KEY_TYPE=$(json_str type < "$KEY_FILE")
case "$KEY_TYPE" in
	service_account)
		AUTH=sa
		CLIENT_EMAIL=$(json_str client_email < "$KEY_FILE")
		CLIENT_ID=$(json_str client_id < "$KEY_FILE")
		PROJECT_ID=$(json_str project_id < "$KEY_FILE")
		KEY_ID=$(json_str private_key_id < "$KEY_FILE")
		(umask 077; printf '%b\n' "$(json_str private_key < "$KEY_FILE")" > "$WORK_DIR/key.pem")
		log "  $(t "인증 방식: 서비스 계정" "Method: service account")"
		log "  $(t "서비스 계정" "Service account"): $CLIENT_EMAIL"
		log "  $(t "클라이언트 ID" "Client ID"): $CLIENT_ID  <- $(t "도메인 전체 위임에 이 ID가 있어야 함" "must be registered in domain-wide delegation")"
		log "  $(t "구글 클라우드 프로젝트" "Google Cloud project"): $PROJECT_ID"
		log "  $(t "위임된 계정" "Delegated user"): ${DELEGATED_USER:-$(t "(없음)" "(none)")}"
		if [ -z "$DELEGATED_USER" ]; then
			add_problem "$(t "서비스 계정 방식인데 위임된 계정이 없음" "Delegated user is missing for the service account method")" \
				"$(t "접속 프로파일에 위임된 계정을 입력하거나 -u 옵션으로 지정하세요." "Enter the delegated user in the connect profile or use -u.")"
			finish
		fi
		;;
	authorized_user)
		AUTH=oauth
		OA_CLIENT_ID=$(json_str client_id < "$KEY_FILE")
		OA_SECRET=$(json_str client_secret < "$KEY_FILE")
		OA_REFRESH=$(json_str refresh_token < "$KEY_FILE")
		CLIENT_ID="$OA_CLIENT_ID"
		log "  $(t "인증 방식: 사용자 계정 OAuth" "Method: user account OAuth")"
		log "  $(t "OAuth 클라이언트 ID" "OAuth client ID"): $OA_CLIENT_ID"
		log "  $(t "동의한 계정" "Consenting account"): ${DELEGATED_USER:-$(t "(지정 안 함, -u 로 지정하면 관리자 여부도 확인)" "(not given; use -u to also check admin status)")}"
		if [ -z "$OA_CLIENT_ID" ] || [ -z "$OA_SECRET" ] || [ -z "$OA_REFRESH" ]; then
			log "  [$(t "실패" "FAIL")] $(t "client_id, client_secret, refresh_token 중 빠진 값이 있습니다." "client_id, client_secret or refresh_token is missing.")"
			add_problem "$(t "OAuth 사용자 인증 정보 JSON 형식 오류" "Invalid OAuth user credential JSON")" \
				"$(t "설치 매뉴얼 '4. 로그프레소 설정'의 형식대로 type, client_id, client_secret, refresh_token을 모두 넣으세요." "Include type, client_id, client_secret and refresh_token as described in '4. Logpresso Setup' of the install guide.")"
			finish
		fi
		;;
	*)
		log "  [$(t "실패" "FAIL")] $(t "서비스 계정 키 또는 authorized_user JSON이 아닙니다" "Not a service account key or authorized_user JSON"). type=$KEY_TYPE"
		add_problem "$(t "서비스 키 형식 오류" "Invalid service key format")" \
			"$(t "구글 클라우드 콘솔에서 내려받은 OAuth 클라이언트 JSON은 형식이 다릅니다. 서비스 계정 JSON 키 또는 authorized_user JSON을 사용하세요." "The OAuth client JSON downloaded from Google Cloud has a different format. Use a service account JSON key or an authorized_user JSON.")"
		finish
		;;
esac
log "  $(t "고객 ID" "Customer ID"): $CUSTOMER"
log "  $(t "HTTP 프록시" "HTTP proxy"): ${PROXY:-$(t "(사용 안 함)" "(none)")}"
log "  $(t "서버 시간(UTC)" "Server time (UTC)"): $(date -u '+%Y-%m-%d %H:%M:%S')"

# 2. 토큰 발급
log ""
if [ "$AUTH" = "sa" ]; then
	log "[2] $(t "기능별 토큰 발급 (앱과 같이 기능마다 필요한 범위만 요청, 도메인 전체 위임 확인)" "Token per feature (only the scopes each feature needs, like the app; checks domain-wide delegation)")"
	declare -A SCOPE_OK
	for id in "${FEATURE_IDS[@]}"; do
		sa_token "${F_SCOPES[$id]}"
		if [ -n "$TOKEN" ]; then
			F_TOKEN[$id]="$TOKEN"
			log "  [$(t "정상" "OK")] ${F_NAME[$id]}"
			continue
		fi
		hint=$(fatal_token_hint "$TOKEN_ERR")
		if [ -n "$hint" ]; then
			log "  [$(t "실패" "FAIL")] $TOKEN_ERR"
			add_problem "$(t "토큰 발급 실패 (모든 기능 사용 불가)" "Token request failed (all features unavailable)"): $TOKEN_ERR" "$hint"
			finish
		fi
		# 범위를 하나씩 요청해 도메인 전체 위임에 빠진 범위를 찾음
		missing=()
		for s in ${F_SCOPES[$id]}; do
			if [ -z "${SCOPE_OK[$s]}" ]; then
				sa_token "$s"
				[ -n "$TOKEN" ] && SCOPE_OK[$s]=yes || SCOPE_OK[$s]=no
			fi
			[ "${SCOPE_OK[$s]}" = "no" ] && missing+=("$s")
		done
		[ ${#missing[@]} -eq 0 ] && missing=(${F_SCOPES[$id]})
		mlist=$(printf '%s, ' "${missing[@]}" | sed 's/, $//')
		log "  [$(t "실패" "FAIL")] ${F_NAME[$id]} - $TOKEN_ERR"
		fail_feature "$id" scope "$(t "도메인 전체 위임에 범위 없음" "scope not in domain-wide delegation"): $mlist" \
			"$(t "$P_DWD 에서 클라이언트 ID $CLIENT_ID 에 범위를 추가하세요" "In $P_DWD, add the scopes to client ID $CLIENT_ID"): $mlist"
	done
	# 어떤 범위로도 토큰을 못 받았으면 범위가 아니라 위임 자체의 문제로 하나로 요약
	ALL_DENIED=yes
	for id in "${FEATURE_IDS[@]}"; do
		[ -n "${F_TOKEN[$id]}" ] && ALL_DENIED=no
	done
	for s in "${!SCOPE_OK[@]}"; do
		[ "${SCOPE_OK[$s]}" = "yes" ] && ALL_DENIED=no
	done
	if [ "$ALL_DENIED" = "yes" ]; then
		for id in "${FEATURE_IDS[@]}"; do
			F_REASON[$id]=$(t "어떤 범위로도 토큰을 받지 못함" "no token for any scope")
			F_FIX[$id]=ALL_DENIED
		done
		add_problem "$(t "모든 기능 사용 불가: 어떤 범위로도 토큰을 받지 못함" "All features unavailable: no token for any scope") (unauthorized_client)" \
			"$(t "범위 하나가 아니라 위임 자체가 동작하지 않습니다. 아래를 확인하세요." "Domain-wide delegation itself is not working, not a single scope. Check the following.")
$(t "1) 도메인 전체 위임에 클라이언트 ID $CLIENT_ID 가 등록되어 있는지" "1) Client ID $CLIENT_ID is registered in domain-wide delegation"): $P_DWD
$(t "2) 위임된 계정($DELEGATED_USER)이 그 위임을 등록한 조직의 계정인지" "2) The delegated user ($DELEGATED_USER) belongs to the organization where the delegation is registered")
$(t "3) 구글 워크스페이스 구독(평가판 포함)이 만료되거나 정지되지 않았는지" "3) The Google Workspace subscription (including trial) has not expired or been suspended")"
	fi
else
	log "[2] $(t "토큰 발급 (리프레시 토큰)" "Token from refresh token")"
	oauth_token
	if [ -z "$TOKEN" ]; then
		log "  [$(t "실패" "FAIL")] $TOKEN_ERR"
		hint=$(fatal_token_hint "$TOKEN_ERR")
		add_problem "$(t "토큰 발급 실패 (모든 기능 사용 불가)" "Token request failed (all features unavailable)"): $TOKEN_ERR" "${hint:-$(t "결과 파일 부록을 로그프레소에 전달하세요." "Send the appendix of the result file to Logpresso.")}"
		finish
	fi
	OA_TOKEN="$TOKEN"
	log "  [$(t "정상" "OK")] $(t "토큰 발급 성공" "Token issued")"
fi

# 2-1. 토큰 정보
ANY_TOKEN="$OA_TOKEN"
if [ -z "$ANY_TOKEN" ]; then
	for id in "${FEATURE_IDS[@]}"; do
		[ -n "${F_TOKEN[$id]}" ] && ANY_TOKEN="${F_TOKEN[$id]}" && break
	done
fi
TOKEN_SUB=""
GRANTED=""
if [ -n "$ANY_TOKEN" ]; then
	log ""
	log "[2-1] $(t "토큰 정보 확인 (tokeninfo)" "Token info (tokeninfo)")"
	http_call "data-urlencode = \"access_token=$(cfg_esc "$ANY_TOKEN")\"" -X POST "https://oauth2.googleapis.com/tokeninfo"
	if [ "$HTTP_STATUS" = "200" ]; then
		TI_AZP=$(json_str azp < "$WORK_DIR/body")
		TOKEN_SUB=$(json_str sub < "$WORK_DIR/body")
		TI_EMAIL=$(json_str email < "$WORK_DIR/body")
		GRANTED=$(json_str scope < "$WORK_DIR/body")
		log "  $(t "발급 대상 클라이언트(azp)" "Client (azp)"): ${TI_AZP:-$(t "(없음)" "(none)")}"
		log "  $(t "토큰 사용자 ID(sub)" "User ID (sub)"): ${TOKEN_SUB:-$(t "(응답에 없음)" "(not in response)")}"
		[ -n "$TI_EMAIL" ] && log "  $(t "토큰 사용자 이메일" "User email"): $TI_EMAIL"
		log "  $(t "범위" "Scopes"): ${GRANTED:-$(t "(없음)" "(none)")}"
		if [ -n "$TI_AZP" ] && [ "$TI_AZP" != "$CLIENT_ID" ]; then
			add_problem "$(t "토큰의 클라이언트 ID가 키 파일과 다름" "Token client ID differs from the key file") ($TI_AZP / $CLIENT_ID)" \
				"$(t "소나 접속 프로파일의 서비스 키와 이 점검에 쓴 키 파일이 같은지 확인하세요." "Check that the key file used here is the same as the service key in the connect profile.")"
		fi
	else
		log "  [$(t "참고" "INFO")] $(t "토큰 정보를 조회하지 못했습니다" "Could not get token info") (HTTP $HTTP_STATUS)"
		save_raw "tokeninfo" "POST https://oauth2.googleapis.com/tokeninfo"
	fi
fi

# OAuth: 동의한 범위에 없는 기능은 사용 불가, 알림 센터는 지원 안 함
if [ "$AUTH" = "oauth" ]; then
	for id in "${FEATURE_IDS[@]}"; do
		if [ "${F_SAONLY[$id]}" = "yes" ]; then
			F_STATUS[$id]=unsupported
			F_REASON[$id]=$(t "서비스 계정 방식만 지원" "service account method only")
			continue
		fi
		missing=()
		for s in ${F_SCOPES[$id]}; do
			case " $GRANTED " in
				*" $s "*) ;;
				*) missing+=("$s") ;;
			esac
		done
		if [ ${#missing[@]} -gt 0 ] && [ -n "$GRANTED" ]; then
			mlist=$(printf '%s, ' "${missing[@]}" | sed 's/, $//')
			fail_feature "$id" scope "$(t "리프레시 토큰에 범위 없음" "scope not in refresh token"): $mlist" \
				"$(t "이 범위를 포함해 리프레시 토큰을 다시 발급하고 서비스 키 JSON을 바꾸세요" "Issue a new refresh token including these scopes and update the service key JSON"): $mlist"
		else
			F_TOKEN[$id]="$OA_TOKEN"
		fi
	done
fi

# 3. 위임된(동의한) 계정
log ""
log "[3] $(t "계정의 관리자 여부" "Admin status of the account")"
ROLE_STATE=unknown
CUSTOMER_OK=unknown
[ "$CUSTOMER" = "my_customer" ] && CUSTOMER_OK=yes
USER_TOKEN=""
if [ "$AUTH" = "sa" ]; then
	sa_token "$S_USER"
	USER_TOKEN="$TOKEN"
elif [ -n "${F_TOKEN[users]}" ]; then
	USER_TOKEN="$OA_TOKEN"
fi
if [ -z "$DELEGATED_USER" ]; then
	log "  $(t "계정을 지정하지 않아 건너뜁니다 (-u 로 동의한 계정 지정)" "Skipped: no account given (use -u for the consenting account)")"
elif [ -z "$USER_TOKEN" ]; then
	log "  $(t "사용자 읽기 범위가 없어 건너뜁니다" "Skipped: no user read scope")"
else
	http_call "$(auth_cfg "$USER_TOKEN")" \
		"https://admin.googleapis.com/admin/directory/v1/users/$DELEGATED_USER?fields=id,primaryEmail,isAdmin,isDelegatedAdmin,suspended,customerId"
	if [ "$HTTP_STATUS" = "200" ]; then
		IS_ADMIN=$(json_bool isAdmin < "$WORK_DIR/body")
		IS_DELEGATED=$(json_bool isDelegatedAdmin < "$WORK_DIR/body")
		REAL_CUSTOMER=$(json_str customerId < "$WORK_DIR/body")
		USER_ID=$(json_str id < "$WORK_DIR/body")
		log "  $(t "계정" "Account"): $DELEGATED_USER (ID $USER_ID)"
		log "  $(t "최고 관리자(isAdmin)" "Super admin (isAdmin)"): ${IS_ADMIN:-false}"
		log "  $(t "위임 관리자(isDelegatedAdmin)" "Delegated admin (isDelegatedAdmin)"): ${IS_DELEGATED:-false}"
		log "  $(t "정지 여부(suspended)" "Suspended"): $(json_bool suspended < "$WORK_DIR/body")"
		log "  $(t "계정의 고객 ID" "Customer ID of the account"): $REAL_CUSTOMER"
		if [ "$IS_ADMIN" = "true" ]; then
			ROLE_STATE=super
		elif [ "$IS_DELEGATED" = "true" ]; then
			ROLE_STATE=custom
			log "  -> $(t "맞춤 역할 관리자입니다. 필요한 권한은 [4]에서 기능별로 확인합니다." "Custom role admin. Required privileges are checked per feature in [4].")"
		else
			ROLE_STATE=none
		fi
		if [ "$CUSTOMER" != "my_customer" ] && [ -n "$REAL_CUSTOMER" ]; then
			if [ "$CUSTOMER" = "$REAL_CUSTOMER" ]; then
				CUSTOMER_OK=yes
			else
				CUSTOMER_OK=no
				log "  -> $(t "고객 ID($CUSTOMER)가 계정의 고객 ID($REAL_CUSTOMER)와 다릅니다." "Customer ID ($CUSTOMER) differs from the account's customer ID ($REAL_CUSTOMER).")"
			fi
		fi
		if [ -n "$TOKEN_SUB" ] && [ -n "$USER_ID" ] && [ "$TOKEN_SUB" != "$USER_ID" ]; then
			add_problem "$(t "토큰이 지정한 계정이 아닌 다른 사용자로 발급됨" "The token belongs to a different user than the given account")" \
				"$(t "계정 주소(별칭 여부 포함)를 확인하세요. 기본 이메일 주소를 사용해야 합니다." "Check the account address. Use the primary email address, not an alias.")"
		fi
	else
		log "  [$(t "실패" "FAIL")] HTTP $HTTP_STATUS $(api_msg)"
		save_raw "$(t "계정 정보 조회" "Account lookup")" "GET admin/directory/v1/users/$DELEGATED_USER"
		[ "$HTTP_STATUS" = "403" ] && log "  -> $(t "관리자 역할이 없거나, 역할에 '사용자 > 읽기' 권한이 없습니다." "No admin role, or the role lacks 'Users > Read'.")"
	fi
fi

# 4. 기능별 API 호출
log ""
log "[4] $(t "기능별 API 호출 (각 1건)" "API call per feature (1 item each)")"

# 오류 응답을 분류해 기능을 사용 불가로 표시. $1=기능, $2=호출 설명
classify_error() {
	local id="$1" what="$2" msg
	msg=$(api_msg)
	save_raw "${F_NAME[$id]}: $what" "$what"
	log "  [$(t "실패" "FAIL")] ${F_NAME[$id]} ($what) HTTP $HTTP_STATUS $msg"
	if grep -q -e SERVICE_DISABLED -e accessNotConfigured -e "has not been used" "$WORK_DIR/body"; then
		fail_feature "$id" api "$(t "구글 클라우드 API 사용 안 함" "Google Cloud API not enabled")" \
			"$(t "$P_LIB 에서 프로젝트 ${PROJECT_ID:-(OAuth 클라이언트의 프로젝트)}에 '${F_API[$id]}' 사용 설정을 하세요." "In $P_LIB, enable '${F_API[$id]}' for project ${PROJECT_ID:-(the project of the OAuth client)}.")"
	elif grep -q -e ACCESS_TOKEN_SCOPE_INSUFFICIENT -e "insufficient authentication scopes" "$WORK_DIR/body"; then
		fail_feature "$id" scope "$(t "토큰에 범위 부족" "insufficient token scopes")" \
			"$(t "필요한 범위" "Required scopes"): ${F_SCOPES[$id]}"
	elif [ "$HTTP_STATUS" = "403" ] && [ -n "${F_PRIV[$id]}" ]; then
		fail_feature "$id" privilege "$(t "관리자 권한 부족" "missing admin privilege") ($msg)" ""
	elif [ "$HTTP_STATUS" = "000" ]; then
		fail_feature "$id" network "$(t "접속 실패" "connection failed")" \
			"$(t "이 서버에서 구글 API 주소로 HTTPS 통신이 되는지 방화벽을 확인하세요. 프록시가 필요하면 -p 옵션으로 지정하세요." "Check that this server can reach Google API hosts over HTTPS. Use -p if a proxy is required.")"
	else
		fail_feature "$id" other "HTTP $HTTP_STATUS $msg" \
			"$(t "결과 파일 부록을 로그프레소에 전달하세요." "Send the appendix of the result file to Logpresso.")"
	fi
}

# 토큰으로 GET. 성공하면 0
call() {
	http_call "$(auth_cfg "${F_TOKEN[$1]}")" "$2"
	[ "$HTTP_STATUS" = "200" ]
}

REPORTS="https://admin.googleapis.com/admin/reports/v1/activity/users/all/applications"
DIR="https://admin.googleapis.com/admin/directory/v1"
CUST_ENC=$(printf '%s' "$CUSTOMER" | sed 's/[^A-Za-z0-9_.-]//g')
MYCUST_DENIED=no

for id in "${FEATURE_IDS[@]}"; do
	[ "${F_STATUS[$id]}" = "fail" ] && { log "  [$(t "건너뜀" "SKIP")] ${F_NAME[$id]} - ${F_REASON[$id]}"; continue; }
	[ "${F_STATUS[$id]}" = "unsupported" ] && { log "  [$(t "건너뜀" "SKIP")] ${F_NAME[$id]} - ${F_REASON[$id]}"; continue; }
	[ -z "${F_TOKEN[$id]}" ] && continue
	case "$id" in
		audit)
			ok=yes
			for app in admin login meet chat token; do
				if ! call "$id" "$REPORTS/$app?maxResults=1"; then
					classify_error "$id" "reports/$app"; ok=no; break
				fi
			done
			[ "$ok" = "yes" ] && F_STATUS[$id]=ok ;;
		drive)
			if ! call "$id" "$REPORTS/drive?maxResults=1"; then
				classify_error "$id" "reports/drive"
			elif ! call "$id" "https://www.googleapis.com/drive/v3/apps"; then
				classify_error "$id" "drive/v3/apps"
			else
				F_STATUS[$id]=ok
			fi ;;
		alerts)
			if call "$id" "https://alertcenter.googleapis.com/v1beta1/alerts?pageSize=1"; then
				F_STATUS[$id]=ok
			else
				classify_error "$id" "alertcenter/alerts"
			fi ;;
		domains|users|groups)
			if ! call "$id" "$DIR/customer/$CUST_ENC/domains"; then
				classify_error "$id" "directory/customer/$CUST_ENC/domains"
				# 도메인 조회 단계에서 막혔으면 빠진 권한은 '도메인 관리'
				F_PRIV[$id]=$(t "도메인 관리" "Domain management")
				F_DOMSTEP[$id]=yes
				# 고객 ID 대신 my_customer 로 다시 조회해 고객 ID 문제인지 구분
				if [ "$id" = "domains" ] && [ "${F_KIND[$id]}" = "privilege" ] && [ "$CUSTOMER" != "my_customer" ]; then
					if call "$id" "$DIR/customer/my_customer/domains"; then
						log "         [$(t "비교" "COMPARE")] $(t "my_customer 로는 성공 -> 고객 ID가 이 테넌트의 것이 아닙니다." "my_customer works -> the customer ID belongs to another tenant.")"
						CUSTOMER_OK=no
						fail_feature "$id" customer "$(t "고객 ID가 이 테넌트의 것이 아님" "customer ID belongs to another tenant")" ""
					else
						log "         [$(t "비교" "COMPARE")] $(t "my_customer 로도 거부 -> 고객 ID가 아니라 관리자 권한 문제입니다." "my_customer is also denied -> admin privilege problem, not the customer ID.")"
						MYCUST_DENIED=yes
					fi
				fi
				continue
			fi
			[ "$id" = "domains" ] && { F_STATUS[$id]=ok; continue; }
			dom=$(json_str domainName < "$WORK_DIR/body")
			[ -z "$dom" ] && dom="${DELEGATED_USER##*@}"
			if [ "$id" = "users" ]; then
				path="users?domain=$dom&maxResults=1"
			else
				path="groups?domain=$dom&maxResults=1"
			fi
			if call "$id" "$DIR/$path"; then
				F_STATUS[$id]=ok
			else
				classify_error "$id" "directory/${path%%\?*}"
			fi ;;
		driveapps)
			if call "$id" "https://www.googleapis.com/drive/v3/apps"; then
				F_STATUS[$id]=ok
			else
				classify_error "$id" "drive/v3/apps"
			fi ;;
		sheetsr|sheetsw)
			F_STATUS[$id]=okscope ;;
	esac
	case "${F_STATUS[$id]}" in
		ok) log "  [$(t "정상" "OK")] ${F_NAME[$id]}" ;;
		okscope) log "  [$(t "정상" "OK")] ${F_NAME[$id]} - $(t "범위만 확인 (스프레드시트 공유 여부는 확인하지 않음)" "scope only (spreadsheet sharing not checked)")" ;;
	esac
done

# 고객 ID가 틀렸으면 도메인 조회 단계 실패는 모두 같은 조치로 묶음
if [ -n "$REAL_CUSTOMER" ]; then
	CUST_FIX="$(t "접속 프로파일의 고객 ID($CUSTOMER)를 $REAL_CUSTOMER 로 바꾸거나 비워 두세요 (비우면 my_customer 사용)." "Change the customer ID ($CUSTOMER) in the connect profile to $REAL_CUSTOMER or leave it empty (my_customer is used).")"
else
	CUST_FIX="$(t "접속 프로파일의 고객 ID($CUSTOMER)를 이 테넌트의 고객 ID로 바꾸거나 비워 두세요 (비우면 my_customer 사용)" "Change the customer ID ($CUSTOMER) in the connect profile to this tenant's customer ID or leave it empty (my_customer is used)"): $P_CUST"
fi
CUST_USED=no
for id in "${FEATURE_IDS[@]}"; do
	[ "${F_STATUS[$id]}" = "fail" ] || continue
	if [ "${F_KIND[$id]}" = "customer" ] || { [ "$CUSTOMER_OK" = "no" ] && [ "${F_DOMSTEP[$id]}" = "yes" ]; }; then
		F_KIND[$id]=customer
		F_REASON[$id]=$(t "고객 ID가 이 테넌트의 것이 아님" "customer ID belongs to another tenant")
		F_FIX[$id]="$CUST_FIX"
		CUST_USED=yes
	fi
done
if [ "$CUSTOMER_OK" = "no" ] && [ "$CUST_USED" = "no" ]; then
	add_problem "$(t "고객 ID 불일치" "Customer ID mismatch"): $CUSTOMER / $REAL_CUSTOMER" "$CUST_FIX"
fi

# 관리자 권한 부족: [3]의 역할 상태에 맞춰 조치를 정함
for id in "${FEATURE_IDS[@]}"; do
	[ "${F_STATUS[$id]}" = "fail" ] && [ "${F_KIND[$id]}" = "privilege" ] || continue
	priv="${F_PRIV[$id]}"
	acct=$(t "위임된 계정" "delegated user")
	[ "$AUTH" = "oauth" ] && acct=$(t "동의한 계정" "consenting account")
	cust_step=""
	if [ "$CUSTOMER_OK" != "yes" ] && [ "$MYCUST_DENIED" != "yes" ] && echo "${F_SCOPES[$id]}" | grep -q "domain"; then
		cust_step="$(t "접속 프로파일의 고객 ID($CUSTOMER)가 이 테넌트의 것인지 확인하세요" "Check that customer ID ($CUSTOMER) in the connect profile belongs to this tenant"): $P_CUST"
	fi
	case "$ROLE_STATE" in
		custom) fix="$(t "맞춤 역할에 '$priv' 권한을 추가하세요" "Add '$priv' to the custom role"): $P_PRIV" ;;
		none) fix="$(t "$acct 에 관리자 역할을 할당하세요. 역할에 만료일이 있었다면 만료로 해제되었을 수 있습니다" "Assign an admin role to the $acct. If the role had an expiration date, it may have expired"): $P_ROLE" ;;
		super) fix="$(t "최고 관리자인데도 거부되었습니다. 권한 변경 직후라면 잠시 후 다시 실행하고, 같은 결과면 결과 파일을 로그프레소에 전달하세요." "Denied although the account is a super admin. If privileges were just changed, retry later; otherwise send the result file to Logpresso.")" ;;
		*) fix="$(t "1) $acct 에 관리자 역할이 할당되어 있는지" "1) Check that an admin role is assigned to the $acct"): $P_ROLE
$(t "2) 맞춤 역할이면 '$priv' 권한이 있는지" "2) If it is a custom role, check that it has '$priv'"): $P_PRIV" ;;
	esac
	[ -n "$cust_step" ] && fix="$fix
$cust_step"
	F_FIX[$id]="$fix"
done

finish