Zum Inhalt springen
Avanet

Sophos Email API authentifizieren und zum richtigen Tenant routen

Vor jedem Aufruf der Sophos Email Management API stehen drei getrennte Schritte: Ein Service Principal bezieht einen kurzlebigen OAuth2-Token, whoami bestimmt den Typ und die ID des Aufrufers, und erst danach wird der Ziel-Tenant mit seinem regionalen API-Host gekoppelt. Ein gültiger Token allein wählt weder Tenant noch Datenregion.

Der sichere Zielzustand besteht aus genau drei zusammengehörigen Werten: SOPHOS_ACCESS_TOKEN, SOPHOS_TENANT_ID und SOPHOS_API_HOST. Der globale Host wird nur für Identitäts- und Tenant-Ermittlung verwendet. Email-Operationen gehen an ${SOPHOS_API_HOST}/email/v1 und enthalten Authorization sowie X-Tenant-ID.

Service Principal für den API-Preview-Zugriff erstellen

Für einen direkten Tenant meldet man sich als Super Admin in Sophos Fusion Admin (ehemals Sophos Central Admin) an und öffnet Global Settings > API Credentials. In Sophos Fusion Partner liegt die Funktion unter Settings & Policies > API Credentials. Solange die APIs als Preview gekennzeichnet sind, entspricht ihr Zugriff der SuperAdmin-Ebene. Deshalb darf dieses Credential nur für diesen kontrollierten Automatisierungszweck verwendet werden; eine auswählbare, engere Service-Principal-Rolle wird hier nicht vorausgesetzt.

  1. Ein eigenes Credential pro Anwendung und Umgebung anlegen.
  2. Name und Beschreibung so wählen, dass Owner, Zweck und Ziel-Tenant nachvollziehbar sind.
  3. Preview-Status und SuperAdmin-Zugriff als Risiko dokumentieren und den Einsatz auf den freigegebenen Tenant begrenzen.
  4. Client ID und Client Secret unmittelbar in einen Unternehmens-Secret-Store übernehmen.
  5. Ablauf, Rotation und Notfallkontakt ausserhalb von Sophos Fusion überwachen.

Access-Token ohne Secret-Spuren anfordern

Das folgende Bash-Beispiel benötigt curl und jq. Es liest das Secret verdeckt ein, URL-kodiert alle Formwerte und sendet sie über die Standardeingabe. Dadurch erscheint das Secret weder als expandiertes Befehlsargument noch in der Shell-History:

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

Fest vorgegeben sind grant_type=client_credentials, scope=token, der Token-Pfad und Content-Type: application/x-www-form-urlencoded. client_id und client_secret stammen aus dem eigenen Credential. Eine erfolgreiche Antwort enthält access_token, token_type: "bearer" und expires_in. Bei Ablauf wird über denselben Client-Credentials-Flow ein neuer Access-Token bezogen; die vollständige Token-Antwort wird nicht protokolliert.

Aufrufer mit Who-am-I bestimmen

whoami ist ein globaler Discovery-Aufruf. Der Bearer-Header wird auch hier über die Standardeingabe an curl übergeben. Der Token wird dabei in den Konfigurationsstrom eingesetzt; das Beispiel gibt diesen Strom nicht aus:

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")

Die Antwortfelder haben klare Aufgaben:

  • id ist die UUID des Aufrufers.
  • idType unterscheidet tenant, partner und organization.
  • apiHosts.global bezeichnet den globalen API-Host.
  • apiHosts.dataRegion ist bei einem direkten Tenant der regionale Host für dessen Produkt-APIs.

Bei idType: "tenant" ist id zugleich die benötigte Tenant-ID. Beide Routingwerte werden streng geprüft, bevor sie übernommen werden:

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")

Eine fehlende dataRegion, ein unerwarteter idType oder ein Host ausserhalb des geprüften Formats ist eine Stop-Bedingung. Keinen Host aus einem Beispiel, einem Regionskürzel oder einer statischen Tabelle ableiten.

Als Partner den Ziel-Tenant auflösen

Bei idType: "partner" ist die von whoami gelieferte id eine Partner-ID und darf nie als X-Tenant-ID verwendet werden. Zuerst werden alle verwalteten Tenants über den globalen Partner-Endpunkt gelesen. Der Request benötigt X-Partner-ID; jedes Tenant-Objekt liefert dann seine eigene id, dataRegion und den vollständigen apiHost.

Die Liste ist paginiert. Die erste Seite wird mit pageTotal=true abgerufen, danach werden die nummerierten Seiten bis pages.total gelesen. Die lokale Obergrenze verhindert eine unendliche Schleife:

[[ "$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

Den Ziel-Tenant anhand einer vorab freigegebenen UUID auswählen, nicht allein anhand von name. So werden ähnlich benannte Kunden nicht verwechselt. dataRegion ist eine Regionskennung; für den Request wird der zugehörige vollständige apiHost verwendet:

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" ist ebenfalls ein eigener Aufruferkontext und keine Tenant-ID. Dieser Artikel führt bewusst nur den Tenant- und Partnerablauf aus; eine Organisationsidentität darf nicht still wie ein Partner behandelt werden.

Routingwerte vor der ersten Email-Operation validieren

Token, Tenant-ID und Regionalhost müssen als unveränderliches Set behandelt werden. Diese operation-neutrale Prüfung bestätigt nur Format und Zusammengehörigkeit; sie ruft bewusst keinen Endpunkt aus einem späteren Runbook auf:

[[ -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"

Erfolgreich ist die Discovery erst, wenn whoami den erwarteten Aufrufertyp und die erwartete ID liefert, die Tenant-UUID aus der freigegebenen Zuordnung stammt und der Regionalhost zu genau diesem Tenant gehört. Für einen ausführbaren, ungefährlichen Lesetest und dessen Schema anschließend das Runbook zur Postfachautomatisierung verwenden; die API-Übersicht ordnet alle Operationsfamilien ein.

Für tenantbezogene Email-JSON-Aufrufe sind folgende Bestandteile verbindlich:

  • regionaler Basis-Host aus apiHosts.dataRegion beziehungsweise apiHost, ergänzt um /email/v1;
  • Authorization: Bearer <access-token>;
  • X-Tenant-ID: <tenant-uuid>;
  • Accept: application/json und bei JSON-Aufrufen Content-Type: application/json.

401, 403, 404 und 429 trennen

  • 401 Unauthorized: Credential fehlt, ist ungültig oder gesperrt, oder der JWT ist abgelaufen. Credential und Secret-Version prüfen, dann genau einmal neu authentifizieren. Tenant oder Region nicht ändern, um 401 zu umgehen.
  • 403 Forbidden: Authentifizierung war erfolgreich. Da Preview-Zugriffe bereits auf SuperAdmin-Ebene erfolgen, Tenant-Zuordnung, Operationsberechtigung und aktuelle API-Verfügbarkeit prüfen; Credential-Wechsel repariert kein Routing.
  • 404 Not Found: Regionalhost, /email/v1-Pfad und Objekt-ID prüfen. Ein 404 ist kein Signal, andere Regionen durchzuprobieren.
  • 429 Too Many Requests: Der Client hat ein Rate Limit überschritten. Vorhandene Retry- beziehungsweise Rate-Limit-Header beachten und nur begrenzt erneut versuchen.

Die dokumentierten allgemeinen Grenzen sind kompakt: empfohlen höchstens 10 Aufrufe pro Sekunde, erzwungen 100 pro Minute mit Bursts bis 300, empfohlen 1.000 pro Stunde und erzwungen 200.000 pro Tag. Die Zählung berücksichtigt je nach Grenze API-Credential, Konto und Quell-IP; Credential- oder IP-Wechsel darf Limits nicht umgehen. Operationsspezifische Grenzen können zusätzlich gelten.

Für 429 und vorübergehende 5xx eignet sich exponentieller Backoff mit Full Jitter: random_between(0, min(cap, base * (2 ** attempt))). Sophos nennt als Beispiel base = 1000 ms und cap = 30000 ms. Der eigene Client benötigt zusätzlich eine feste Obergrenze für Versuche und Gesamtdauer; nach Erreichen wird abgebrochen und alarmiert. 401, 403 und 404 werden nicht automatisch wiederholt.

Schreib-, Lösch-, Freigabe- und Clawback-Aufrufe niemals blind wiederholen. Ein Timeout kann nach serverseitiger Annahme eintreten. Vor einem erneuten Versuch zuerst den produktspezifischen Status prüfen und die Idempotenz der Operation klären.

Secrets und Sessiondaten sauber entfernen

Antwortinhalte können Tenant- und personenbezogene Daten enthalten. Logs beschränken sich auf Zeitpunkt, Methode, redigierten Pfad, HTTP-Status sowie eine vorhandene Request-ID oder trackingId. Client Secret, Access- und Refresh-Token sowie vollständige Antwortkörper gehören nicht hinein.

Nach dem Lauf werden temporäre Dateien und sensible Shell-Variablen entfernt:

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

Ein verlorenes oder verdächtiges Secret wird nicht weiterverwendet: neues Credential erstellen, die Routingwerte und danach den kontrollierten Test des zuständigen Runbooks prüfen, Anwendung umstellen und das alte Credential löschen. Das Löschen widerruft künftige API-Aufrufe, macht aber keine bereits ausgeführte Email-Operation rückgängig.