Permission Check Script
If loggers or commands fail with permission errors and the cause is hard to find, use the check scripts to call the Google APIs directly with the same settings as the Sonar connect profile and see which features are available. For each unavailable feature, the script shows the reason and the action to take.
- The scripts call the APIs the same way as the Google Workspace app 1.9.2610.0 or later. Both the service account method and the user account OAuth method are supported.
- The scripts only read data and do not change Google Workspace or Sonar settings. Each check reads only one item.
- Output is in Korean by default. Use the
-l enoption for English output.
Scripts
| Script | Where to run | What it does |
|---|---|---|
sonar_profile_export.sh | A server that can reach the Sonar web console (API) | Saves the connect profile settings (customer ID, delegated user, HTTP proxy) to a file |
gws_permission_check.sh | A server that can reach the internet (Google) | Calls the Google APIs with the saved file and the service key, and checks each feature |
If the Sonar server can reach the internet directly, you can run both scripts on the same server.
Prerequisites
bash(4 or later),curlandopensslon a Linux server (no extra installation besides basic commands such asawkandsed)- Sonar API key: issue one in the Sonar web console at Users > Users > (user) > API Key. The user must be able to see the connect profile to check.
- Service key JSON file: the same content as the Service key of the connect profile (a service account JSON key or an
authorized_userJSON). Sonar does not return the service key through its API for security reasons, so you need the file separately.
Step 1: Create the script files
Save the two scripts under Full scripts at the bottom of this page as files with the same names. To avoid line break changes when pasting, we recommend saving them with an editor directly on the Linux server.
Step 2: Export the connect profile
Run on a server that can reach Sonar. The type of Google Workspace connect profiles is google-workspace. To export all Google Workspace connect profiles at once, use -t google-workspace.
To export specific connect profiles only, give identifiers with -n instead of -t, separated by commas.
The script asks for the Sonar API key. The key is not shown on screen, and you can also set it in the SONAR_API_KEY environment variable. Because Sonar usually uses a private certificate, the script does not verify the certificate when connecting to Sonar.
A file named profile_(identifier)_(time).txt is created for each connect profile. The file does not contain protected values such as the service key, and only the account that ran the script can read it.
Step 3: Run the permission check
Copy the file from step 2 and the service key JSON file to a server that can reach the internet, and run the check. If there are several connect profiles, run it for each file.
| Option | Required | Description |
|---|---|---|
-k | Required | Service key JSON file |
-f | Required | File exported in step 2. The customer ID and delegated user are read from this file |
-u | Optional | For the user account OAuth method, give the consenting account to also check its admin status. For the service account method, this value is used instead of the delegated user in the file |
-p | Optional | IP:PORT of an HTTP proxy if this server needs one to reach Google. The proxy in the connect profile is for the Sonar server and is not used automatically |
-l | Optional | Output language. en for English |
Interpreting the results
The script checks in this order.
| Step | Name | What it checks |
|---|---|---|
| [0] | Profile file | Identifier and name of the connect profile, customer ID, delegated user, HTTP proxy |
| [1] | Service key | Authentication method, service account or OAuth client ID, Google Cloud project |
| [2] | Token | For the service account method, requests only the scopes each feature needs, like the app, to find scopes missing from domain-wide delegation. For the OAuth method, gets a token with the refresh token |
| [2-1] | Token info | Client, user and scopes of the token |
| [3] | Admin status of the account | Super admin or custom role admin, and the actual customer ID of the account |
| [4] | API call per feature | Audit logs, drive logs, Alert Center, domains, users, groups, drive apps |
At the end, Result by feature and Summary are shown. Features are the same units as in the table of 1. Required Permissions.
| Status | Meaning |
|---|---|
| OK | The API call for the feature succeeded |
| UNAVAILABLE | Missing scope, admin privilege or API setting. The summary shows the action |
| UNSUPPORTED | Not supported by the authentication method (Alert Center with OAuth) |
If you prepared only the permissions you need, features you do not use are shown as UNAVAILABLE. You can ignore the items for those features. For the spreadsheet commands, only the scopes are checked; spreadsheet sharing is not checked.
Summary example (the custom role lacks the domain management privilege):
Summary: items that need action 1
(Items for features you do not use can be ignored)
1. Unavailable: domains; users; groups, users (group option)
-> Action: Add 'Domain management' to the custom role: Admin console > Account > Admin roles > (role) > Privileges
Sending the result
The result is shown on screen and saved as gws_check_result_(time).txt in the current folder. If a call failed, the raw Google response is saved in the appendix at the end of the file.
If the problem remains after taking action, send this result file to Logpresso technical support. The result file does not contain the private key, client secret, refresh token or access token, but it does contain the delegated user email and the customer ID.
Full scripts
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