Uwierzytelnianie Sophos Email API i routing do właściwego tenanta
Każde wywołanie Sophos Email Management API zaczyna się od trzech oddzielnych kroków: service principal pobiera krótkotrwały token OAuth2, whoami ustala typ i ID wywołującego, a dopiero potem tenant docelowy jest łączony z jego hostem regionalnym. Sam poprawny token nie wybiera tenanta ani regionu.
Bezpieczny rezultat to nierozłączny zestaw: SOPHOS_ACCESS_TOKEN, SOPHOS_TENANT_ID i SOPHOS_API_HOST. Host globalny służy tylko do wykrywania tożsamości i tenantów. Operacje Email trafiają do ${SOPHOS_API_HOST}/email/v1 z Authorization i X-Tenant-ID.
Tworzenie service principal dla dostępu API Preview
Dla bezpośredniego tenanta zaloguj się do Sophos Fusion Admin (dawniej Sophos Central Admin) jako Super Admin i otwórz Global Settings > API Credentials. W Sophos Fusion Partner użyj Settings & Policies > API Credentials. Dopóki API mają status Preview, dostęp odbywa się na poziomie SuperAdmin. Ogranicz credential do kontrolowanej automatyzacji i nie zakładaj, że można wybrać węższą rolę service principal.
- Utwórz osobne dane uwierzytelniające dla każdej aplikacji i środowiska.
- W nazwie i opisie wskaż właściciela, cel i docelowego tenanta.
- Udokumentuj status Preview i dostęp SuperAdmin jako ryzyko oraz ogranicz użycie do zatwierdzonego tenanta.
- Natychmiast zapisz Client ID i Client Secret w firmowym magazynie sekretów.
- Poza Sophos Fusion monitoruj wygaśnięcie, rotację i kontakt awaryjny.
Pobieranie access token bez pozostawiania sekretu
Przykład Bash wymaga curl i jq. Odczytuje sekret bez wyświetlania, koduje pola formularza i wysyła je przez standardowe wejście. Dzięki temu sekret nie trafia do historii powłoki ani do rozwiniętych argumentów procesu:
set -e -o pipefail
cleanup() {
if [[ -n ${TENANTS_FILE:-} ]]; then
rm -f "$TENANTS_FILE" "${TENANTS_FILE}.new"
fi
unset SOPHOS_CLIENT_SECRET TOKEN_RESPONSE PAGE_RESPONSE WHOAMI
}
trap cleanup EXIT
read -r -p "Client ID: " SOPHOS_CLIENT_ID
read -r -s -p "Client Secret: " SOPHOS_CLIENT_SECRET
printf '\n'
TOKEN_RESPONSE=$(
printf 'grant_type=client_credentials&client_id=%s&client_secret=%s&scope=token' \
"$(jq -rn --arg v "$SOPHOS_CLIENT_ID" '$v|@uri')" \
"$(jq -rn --arg v "$SOPHOS_CLIENT_SECRET" '$v|@uri')" |
curl --fail-with-body --silent --show-error \
--request POST \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-binary @- \
https://id.sophos.com/api/v2/oauth2/token
) || { cleanup; exit 1; }
unset SOPHOS_CLIENT_SECRET
SOPHOS_ACCESS_TOKEN=$(jq -er '
select(.token_type == "bearer") |
select((.expires_in | type) == "number" and .expires_in > 0) |
.access_token | select(type == "string" and length > 0)
' <<<"$TOKEN_RESPONSE") || { cleanup; exit 1; }
unset TOKEN_RESPONSE
Stałe są grant_type=client_credentials, scope=token, ścieżka tokena i Content-Type: application/x-www-form-urlencoded. client_id i client_secret pochodzą z credential. Poprawna odpowiedź zawiera access_token, token_type: "bearer" i expires_in. Po wygaśnięciu pobierz nowy token tym samym przepływem i nie loguj całej odpowiedzi.
Ustalanie wywołującego przez Who-am-I
whoami jest globalnym wywołaniem discovery; bearer header również jest przekazywany przez standardowe wejście curl. Token jest wstawiany do tego strumienia konfiguracji, którego przykład nie wyświetla:
WHOAMI=$(
printf 'header = "Authorization: Bearer %s"\n' "$SOPHOS_ACCESS_TOKEN" |
curl --fail-with-body --silent --show-error \
--config - https://api.central.sophos.com/whoami/v1
)
SOPHOS_ID=$(jq -er '.id | select(type == "string" and length > 0)' <<<"$WHOAMI")
SOPHOS_ID_TYPE=$(jq -er '
.idType | select(. == "tenant" or . == "partner" or . == "organization")
' <<<"$WHOAMI")
id to UUID wywołującego; idType rozróżnia tenant, partner i organization; apiHosts.global to host globalny; apiHosts.dataRegion to host regionalny bezpośredniego tenanta.
Dla idType: "tenant" pole id jest też wymaganym ID tenanta. Zweryfikuj obie wartości routingu:
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'
[[ "$SOPHOS_ID_TYPE" == "tenant" ]] || {
printf 'Direct-tenant flow requires idType tenant\n' >&2
exit 1
}
SOPHOS_TENANT_ID=$(jq -er --arg re "$UUID_RE" '
.id | select(type == "string" and test($re))
' <<<"$WHOAMI")
SOPHOS_API_HOST=$(jq -er '
.apiHosts.dataRegion |
select(type == "string" and test("^https://api-[a-z0-9-]+\\.central\\.sophos\\.com$"))
' <<<"$WHOAMI")
Brak dataRegion, nieoczekiwany idType lub host poza zweryfikowanym formatem oznacza przerwanie. Nie wyprowadzaj hosta z przykładu, kodu regionu ani statycznej tabeli.
Ustalanie tenanta docelowego jako partner
Przy idType: "partner" wartość whoami.id jest ID partnera i nie może być X-Tenant-ID. Wylicz zarządzane tenanty przez globalny endpoint partnera z X-Partner-ID; każdy obiekt zwraca własne id, dataRegion i apiHost.
Pierwsza strona używa pageTotal=true, a kolejne są odczytywane do pages.total, z lokalnym limitem:
[[ "$SOPHOS_ID_TYPE" == "partner" ]] || {
printf 'Partner flow requires idType partner\n' >&2
exit 1
}
TENANTS_FILE=$(mktemp) || exit 1
trap cleanup EXIT
printf '[]\n' >"$TENANTS_FILE"
page=1
max_pages=1000
expected_pages=
while (( page <= max_pages )); do
if (( page == 1 )); then
PAGE_QUERY=(--data-urlencode 'page=1' --data-urlencode 'pageSize=100' --data-urlencode 'pageTotal=true')
else
PAGE_QUERY=(--data-urlencode "page=$page" --data-urlencode 'pageSize=100')
fi
PAGE_RESPONSE=$(
printf 'header = "Authorization: Bearer %s"\n' "$SOPHOS_ACCESS_TOKEN" |
curl --fail-with-body --silent --show-error --get --config - \
--header "X-Partner-ID: $SOPHOS_ID" \
"${PAGE_QUERY[@]}" \
https://api.central.sophos.com/partner/v1/tenants
)
if (( page == 1 )); then
expected_pages=$(jq -er '
select((.items | type) == "array") |
.pages.total | select(type == "number" and floor == . and . >= 1 and . <= 1000)
' <<<"$PAGE_RESPONSE")
else
jq -e '(.items | type) == "array"' <<<"$PAGE_RESPONSE" >/dev/null || exit 1
fi
jq -e --argjson page "$PAGE_RESPONSE" '. + $page.items' \
"$TENANTS_FILE" >"${TENANTS_FILE}.new" &&
mv "${TENANTS_FILE}.new" "$TENANTS_FILE" || exit 1
(( page >= expected_pages )) && break
((page++))
done
(( page == expected_pages )) || exit 1
unset PAGE_RESPONSE PAGE_QUERY page expected_pages max_pages
Wybierz cel według zatwierdzonego UUID, nigdy wyłącznie name. dataRegion jest oznaczeniem regionu; żądanie korzysta z pełnego apiHost:
read -r -p 'Approved target tenant UUID: ' TARGET_TENANT_ID
TARGET_TENANT=$(jq -cer --arg id "$TARGET_TENANT_ID" --arg re "$UUID_RE" '
[.[] |
select((.id | type) == "string" and (.id | test($re))) |
select((.id | ascii_downcase) == ($id | ascii_downcase)) |
select((.apiHost | type) == "string" and
(.apiHost | test("^https://api-[a-z0-9-]+\\.central\\.sophos\\.com$")))] |
select(length == 1) | .[0]
' "$TENANTS_FILE")
SOPHOS_TENANT_ID=$(jq -r '.id' <<<"$TARGET_TENANT")
SOPHOS_API_HOST=$(jq -r '.apiHost' <<<"$TARGET_TENANT")
rm -f "$TENANTS_FILE"
unset TARGET_TENANT TARGET_TENANT_ID TENANTS_FILE
idType: "organization" także jest osobnym kontekstem, nie ID tenanta. Ten przewodnik realizuje tylko przepływy tenant i partner; nie traktuj organizacji jak partnera.
Walidacja routingu i nagłówków odczytem
Token, ID tenanta i host regionalny traktuj jako jeden zestaw. Ta neutralna operacyjnie kontrola sprawdza tylko format i powiązanie, bez wywoływania endpointu z późniejszego runbooka:
[[ -n "$SOPHOS_ACCESS_TOKEN" ]] || exit 1
[[ "$SOPHOS_TENANT_ID" =~ $UUID_RE ]] || exit 1
[[ "$SOPHOS_API_HOST" =~ ^https://api-[a-z0-9-]+\.central\.sophos\.com$ ]] || exit 1
EMAIL_API_HOST="${SOPHOS_API_HOST%/}/email/v1"
Discovery przechodzi tylko wtedy, gdy whoami zwraca oczekiwany typ i ID, UUID pochodzi z zatwierdzonej mapy, a host należy do tego tenanta. Wykonywalny bezpieczny test odczytu i schemat opisuje runbook automatyzacji skrzynek; omówienie API klasyfikuje wszystkie rodziny.
Każde żądanie JSON Email wymaga hosta regionalnego z apiHosts.dataRegion lub apiHost, plus /email/v1; Authorization: Bearer <access-token>; X-Tenant-ID: <tenant-uuid>; Accept: application/json; oraz dla JSON Content-Type: application/json.
Rozróżnianie 401, 403, 404 i 429
401 Unauthorized: credential nie istnieje, jest nieprawidłowy lub zablokowany albo JWT wygasł. Sprawdź credential i wersję sekretu, po czym uwierzytelnij się jeszcze raz; nie zmieniaj tenanta ani regionu.403 Forbidden: uwierzytelnienie powiodło się. Ponieważ dostęp Preview jest już na poziomie SuperAdmin, sprawdź przypisanie tenanta, uprawnienie operacji i bieżącą dostępność API; zmiana credential nie naprawia routingu.404 Not Found: sprawdź host regionalny,/email/v1i ID obiektu; nie testuj innych regionów.429 Too Many Requests: respektuj nagłówki retry lub rate limit i ogranicz liczbę ponowień.
Udokumentowane limity ogólne to: zalecane maksimum 10 wywołań na sekundę, wymuszane 100 na minutę z seriami do 300, zalecane 1 000 na godzinę oraz wymuszane 200 000 na dobę. Zależnie od limitu zliczanie obejmuje credential API, konto i źródłowy adres IP; nie zmieniaj ich, aby omijać limity. Dodatkowo mogą obowiązywać limity operacji.
Dla 429 i przejściowych 5xx użyj Full Jitter: random_between(0, min(cap, base * (2 ** attempt))). Sophos podaje przykładowo base = 1000 ms i cap = 30000 ms. Ogranicz też liczbę prób i łączny czas, potem przerwij i zaalarmuj. Nie ponawiaj automatycznie 401, 403 ani 404.
Nie ponawiaj w ciemno zapisu, usuwania, zwalniania ani clawback: serwer mógł przyjąć operację przed timeout. Najpierw sprawdź jej stan i idempotencję.
Usuwanie sekretów i danych sesji
Treści odpowiedzi mogą zawierać dane tenanta i dane osobowe. Loguj tylko czas, metodę, zredagowaną ścieżkę, status HTTP i request ID lub trackingId, nigdy Client Secret, tokenów dostępu lub refresh ani pełnych odpowiedzi.
cleanup
unset SOPHOS_ACCESS_TOKEN SOPHOS_CLIENT_ID SOPHOS_ID SOPHOS_ID_TYPE
unset SOPHOS_TENANT_ID SOPHOS_API_HOST EMAIL_API_HOST WHOAMI
trap - EXIT
Dla utraconego lub podejrzanego sekretu utwórz nowy credential, zweryfikuj routing, a następnie wykonaj kontrolowany test z właściwego runbooka, przełącz aplikację i usuń stary. Usunięcie blokuje przyszłe wywołania, lecz nie cofa wykonanej operacji Email.