Aller au contenu
Avanet

Authentifier l’API Sophos Email et la router vers le bon tenant

Tout appel à l’API Sophos Email Management commence par trois étapes distinctes : un service principal obtient un jeton OAuth2 de courte durée, whoami détermine le type et l’ID de l’appelant, puis le tenant cible est associé à son hôte régional. Un jeton valide ne choisit à lui seul ni le tenant ni la région.

L’état sûr est un ensemble indissociable : SOPHOS_ACCESS_TOKEN, SOPHOS_TENANT_ID et SOPHOS_API_HOST. L’hôte global sert uniquement à découvrir l’identité et les tenants. Les opérations Email vont vers ${SOPHOS_API_HOST}/email/v1 avec Authorization et X-Tenant-ID.

Créer un service principal pour l’accès API Preview

Pour un tenant direct, connectez-vous à Sophos Fusion Admin (anciennement Sophos Central Admin) comme Super Admin et ouvrez Global Settings > API Credentials. Dans Sophos Fusion Partner, utilisez Settings & Policies > API Credentials. Tant que les API sont en Preview, leur accès est de niveau SuperAdmin. Réservez ce credential à l’automatisation contrôlée et ne supposez pas qu’un rôle de service principal plus restreint peut être sélectionné.

  1. Créez un credential distinct par application et environnement.
  2. Indiquez propriétaire, finalité et tenant cible dans le nom et la description.
  3. Consignez comme risque le statut Preview et l’accès SuperAdmin, puis limitez l’usage au tenant approuvé.
  4. Placez immédiatement le Client ID et le Client Secret dans un coffre-fort d’entreprise.
  5. Surveillez hors de Sophos Fusion l’expiration, la rotation et le contact d’urgence.

Demander le jeton sans laisser de secret

Cet exemple Bash requiert curl et jq. Il masque le secret, encode les valeurs du formulaire et les transmet sur l’entrée standard. Le secret ne figure ainsi ni dans l’historique shell ni dans les arguments de processus développés :

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

grant_type=client_credentials, scope=token, le chemin du jeton et Content-Type: application/x-www-form-urlencoded sont fixes. client_id et client_secret proviennent du credential. Une réponse valide contient access_token, token_type: "bearer" et expires_in. À expiration, redemandez un jeton par le même flux sans journaliser la réponse complète.

Identifier l’appelant avec Who-am-I

whoami est un appel global de découverte ; le bearer header passe encore par l’entrée standard de curl. Le jeton est inséré dans ce flux de configuration, que l’exemple n’affiche pas :

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 est l’UUID de l’appelant ; idType distingue tenant, partner et organization ; apiHosts.global est l’hôte global ; apiHosts.dataRegion est l’hôte régional d’un tenant direct.

Avec idType: "tenant", id est aussi l’ID de tenant requis. Validez les deux valeurs avant utilisation :

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

Une dataRegion absente, un idType inattendu ou un hôte hors format impose l’arrêt. Ne déduisez jamais l’hôte d’un exemple, d’un code de région ou d’une table statique.

Résoudre le tenant cible comme partenaire

Avec idType: "partner", l’id de whoami est un ID partenaire et ne doit jamais devenir X-Tenant-ID. Énumérez les tenants via l’endpoint partenaire global avec X-Partner-ID. Chaque objet fournit ses propres id, dataRegion et apiHost.

La première page demande pageTotal=true, puis les pages numérotées sont lues jusqu’à pages.total, avec une limite locale :

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

Sélectionnez la cible par un UUID approuvé, jamais par le seul name. dataRegion est un identifiant ; la requête utilise l’apiHost complet correspondant :

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" constitue aussi un contexte distinct, pas un ID de tenant. Ce guide implémente volontairement les flux tenant et partenaire seulement ; ne traitez pas une organisation comme un partenaire.

Valider le routage avant la première opération Email

Traitez jeton, ID du tenant et hôte régional comme un seul ensemble. Ce contrôle indépendant de l’opération valide uniquement format et association, sans appeler un endpoint d’un runbook ultérieur :

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

La découverte réussit uniquement si whoami renvoie le type et l’ID attendus, si l’UUID vient du référentiel approuvé et si l’hôte appartient à ce tenant. Pour un test de lecture sûr exécutable et son schéma, utilisez le runbook d’automatisation des boîtes aux lettres ; la présentation de l’API classe toutes les familles.

Chaque appel JSON Email requiert l’hôte régional de apiHosts.dataRegion ou apiHost, suivi de /email/v1 ; Authorization: Bearer <access-token> ; X-Tenant-ID: <tenant-uuid> ; Accept: application/json ; et Content-Type: application/json pour JSON.

Distinguer 401, 403, 404 et 429

  • 401 Unauthorized : credential absent, invalide ou bloqué, ou JWT expiré. Vérifiez le credential et sa version de secret, puis réauthentifiez une seule fois ; ne changez pas tenant ou région.
  • 403 Forbidden : l’authentification a réussi. L’accès Preview étant déjà de niveau SuperAdmin, vérifiez l’affectation du tenant, l’autorisation de l’opération et la disponibilité actuelle de l’API ; changer de credential ne répare pas le routage.
  • 404 Not Found : contrôlez l’hôte régional, /email/v1 et l’ID d’objet ; ne sondez pas d’autres régions.
  • 429 Too Many Requests : respectez les headers de retry ou de rate limit et limitez les tentatives.

Les limites générales documentées sont : 10 appels par seconde maximum recommandés, 100 par minute imposés avec des rafales jusqu’à 300, 1 000 par heure recommandés et 200 000 par jour imposés. Selon la limite, le comptage couvre le credential API, le compte et l’IP source ; ne changez jamais de credential ou d’IP pour le contourner. Des limites propres à l’opération peuvent s’ajouter.

Pour 429 et les 5xx temporaires, utilisez Full Jitter : random_between(0, min(cap, base * (2 ** attempt))). Sophos donne base = 1000 ms et cap = 30000 ms comme exemples. Limitez aussi le nombre d’essais et la durée totale, puis arrêtez et alertez. Ne relancez pas automatiquement 401, 403 ou 404.

Ne répétez jamais aveuglément une écriture, suppression, libération ou clawback : le serveur peut avoir accepté l’opération avant le timeout. Vérifiez d’abord son état et son idempotence.

Supprimer les secrets et données de session

Les corps de réponse peuvent contenir des données du tenant et des données personnelles. Ne journalisez que l’heure, la méthode, le chemin expurgé, le statut HTTP et un éventuel request ID ou trackingId, jamais le Client Secret, les jetons d’accès ou de refresh ni les réponses complètes. Nettoyez ensuite les variables :

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

Pour un secret perdu ou suspect, créez un nouveau credential, validez le routage puis exécutez le test contrôlé du runbook concerné, basculez l’application et supprimez l’ancien. La suppression révoque les appels futurs, mais n’annule pas une opération Email déjà effectuée.