Leçon 5 — Construire

Comptes de service au moindre privilège

7 min de lecture

L'audit de sécurité annuel relève une ligne : « service Cloud Run de traitement des documents : identité = compte de service Compute par défaut, rôle Éditeur du projet ». Concrètement, une faille dans la bibliothèque PDF du worker donnerait à un attaquant le droit de supprimer tous les buckets, de lire toutes les tables BigQuery et de créer des machines virtuelles. Le worker n'a besoin que de lire un bucket, d'écrire une table et d'appeler Gemini. Ce projet lui donne exactement cela, et rien qui ne puisse être limité à une ressource n'est accordé sur le projet.

Le mécanisme

Quatre identités interviennent, deux créées par le projet, deux gérées par Google (les agents de service) :

flowchart LR
  GA["Agent Cloud Storage<br/>service-…@gs-project-accounts"] -- "pubsub.publisher<br/>(sujet docs)" --> T["Sujet docs"]
  PA["Agent Pub/Sub<br/>service-…@gcp-sa-pubsub"] -- "pubsub.publisher (sujet dlq)<br/>pubsub.subscriber (abonnement push)" --> DLQ["Sujet dlq"]
  PUSH["lab-pipeline-evt-push"] -- "run.invoker<br/>(ce service seulement)" --> W["Worker Cloud Run"]
  W -. "s'exécute en tant que" .-> RUN["lab-pipeline-evt-run"]
  RUN -- "storage.objectViewer (ce bucket)" --> B[("Bucket")]
  RUN -- "bigquery.dataEditor (cette table)" --> BQ[("Table analyses")]
  RUN -- "bigquery.jobUser, aiplatform.user<br/>(projet)" --> PRJ["Projet"]
IdentitéRôlePortéePourquoi pas plus étroit
lab-pipeline-evt-run (worker)storage.objectViewerle bucket
bigquery.dataEditorla table
bigquery.jobUserprojetun job de requête se crée dans un projet, pas dans une table
aiplatform.userprojetVertex AI n'a pas d'IAM par modèle
lab-pipeline-evt-push (Pub/Sub → worker)run.invokerle service
Agent Pub/Subpubsub.publisher / pubsub.subscribersujet dlq / abonnement push
Agent Cloud Storagepubsub.publishersujet docsaccordé par gcloud à la création de la notification

Deux comptes plutôt qu'un : le compte qui appelle le worker (push) et celui sous lequel il s'exécute (run) n'ont aucun droit en commun. Le compte push ne peut rien faire d'autre qu'appeler ce service ; le compte run ne peut pas s'appeler lui-même ni publier sur un sujet.

Le service est déployé avec --no-allow-unauthenticated : sans jeton OIDC signé pour un compte qui a run.invoker, Cloud Run répond 403 avant que le code ne s'exécute. L'abonnement push génère ce jeton à chaque livraison (--push-auth-service-account).

La mise en œuvre

files/infra/cloud-up.sh (extrait)
step "Droits du worker : lecture du bucket, écriture de LA table, appel de Vertex AI"
$G storage buckets add-iam-policy-binding "gs://$BUCKET" --member="serviceAccount:$RUN_SA" \
  --role=roles/storage.objectViewer >/dev/null
bq --project_id="$PROJECT" add-iam-policy-binding --member="serviceAccount:$RUN_SA" \
  --role=roles/bigquery.dataEditor "$TABLE" >/dev/null
for role in roles/bigquery.jobUser roles/aiplatform.user; do
  $G projects add-iam-policy-binding "$PROJECT" --member="serviceAccount:$RUN_SA" --role="$role" \
    --condition=None >/dev/null
done
...
$G run deploy "$P-worker" --region="$REGION" --image="$IMAGE" --service-account="$RUN_SA" \
  --no-allow-unauthenticated --ingress=all --concurrency=4 --max-instances=3 --min-instances=0 ...
$G run services add-iam-policy-binding "$P-worker" --region="$REGION" \
  --member="serviceAccount:$PUSH_SA" --role=roles/run.invoker >/dev/null

Le script est relançable : chaque ressource est recherchée (describe) avant d'être créée, les liaisons IAM sont idempotentes par nature, l'abonnement est mis à jour s'il existe. La suppression suit l'ordre inverse, avec un détail qui compte :

files/infra/cloud-down.sh (extrait)
# Retirer les liaisons AVANT de supprimer le compte, sinon elles restent en « deleted:serviceAccount:... ».
for role in roles/bigquery.jobUser roles/aiplatform.user; do
  $G projects remove-iam-policy-binding "$PROJECT" --member="serviceAccount:$P-run@$PROJECT.iam.gserviceaccount.com" \
    --role="$role" --condition=None >/dev/null 2>&1
done
for sa in run push; do $G iam service-accounts delete "$P-$sa@$PROJECT.iam.gserviceaccount.com" 2>/dev/null; done

Résultat obtenu (2026-10-16)

make cloud-up relancé sur l'infrastructure existante : rien n'est recréé, l'image est reconstruite et une nouvelle révision déployée, en 1 min 38 s.

==> Comptes de service
==> Droits du worker : lecture du bucket, écriture de LA table, appel de Vertex AI
Updated IAM policy for project [<projet>].
...
Service [lab-pipeline-evt-worker] revision [lab-pipeline-evt-worker-00006-7qh] has been deployed and is serving 100 percent of traffic.
==> Pub/Sub : sujet, sujet de lettres mortes, abonnements
Updated subscription [projects/<projet>/subscriptions/lab-pipeline-evt-push].
==> Notification Cloud Storage -> Pub/Sub (objets créés sous entrant/ uniquement)
make cloud-up PROJECT=<projet>  13,23s user 1,78s system 15% cpu 1:37,71 total

Les liaisons réellement en place, relevées ensuite :

## projet
roles/aiplatform.user     serviceAccount:lab-pipeline-evt-run@<projet>.iam.gserviceaccount.com
roles/bigquery.jobUser    serviceAccount:lab-pipeline-evt-run@<projet>.iam.gserviceaccount.com
## bucket
roles/storage.objectViewer serviceAccount:lab-pipeline-evt-run@<projet>.iam.gserviceaccount.com
## table analyses
roles/bigquery.dataEditor serviceAccount:lab-pipeline-evt-run@<projet>.iam.gserviceaccount.com
## service lab-pipeline-evt-worker
roles/run.invoker         serviceAccount:lab-pipeline-evt-push@<projet>.iam.gserviceaccount.com
## sujet lab-pipeline-evt-dlq
roles/pubsub.publisher    serviceAccount:service-<numéro>@gcp-sa-pubsub.iam.gserviceaccount.com
## sujet lab-pipeline-evt-docs
roles/pubsub.publisher    serviceAccount:service-<numéro>@gs-project-accounts.iam.gserviceaccount.com

Ce qui casse en production

  • Propagation IAM. Une liaison accordée il y a dix secondes peut encore être refusée : le premier déploiement échoue parfois sur « Permission denied » pour le compte tout juste créé. Parade : le sleep 5 après la création des comptes, et des scripts relançables ; la propagation peut prendre jusqu'à quelques minutes, relancer make cloud-up suffit.
  • Agent de service absent. L'agent Cloud Storage n'existe qu'après le premier usage de Pub/Sub par Cloud Storage dans le projet ; une liaison manuelle sur un agent inexistant échoue. Parade : laisser gcloud storage buckets notifications create l'accorder, ou forcer sa création avec gcloud storage service-agent.
  • Liaisons orphelines. Supprimer un compte avant ses liaisons laisse des membres deleted:serviceAccount:…?uid=… dans la politique du projet. Si un compte du même nom est recréé, il ne les hérite pas, ce qui donne des erreurs de droits incompréhensibles. Parade : l'ordre du script, et cloud-status.sh qui cherche aussi ces membres.
  • Ingress trop ouvert. --ingress=all est nécessaire pour un push Pub/Sub (il arrive par Internet), mais la protection repose alors entièrement sur l'IAM. Ne passez jamais --allow-unauthenticated « pour tester » : l'URL du service figure dans la configuration de l'abonnement, visible de tous les lecteurs du projet.

La règle de décision

Un compte de service par rôle fonctionnel (qui appelle, qui s'exécute), jamais le compte Compute par défaut. Chaque droit sur la ressource la plus étroite qui l'accepte ; un droit sur le projet seulement quand l'API n'offre pas mieux, et alors écrit et justifié dans le script. Les liaisons sont créées et retirées par le même code que les ressources.

Pratique

Le worker doit maintenant écrire un résumé PDF dans un second bucket, gs://…-archives. Quel droit ajoutez-vous ?

Après un make cloud-down puis un make cloud-up, le worker répond 403 à toutes les livraisons. Les journaux Cloud Run montrent « The request was not authenticated ». Quelle est la cause la plus probable ?

Exercice. Rédigez la commande qui vérifie qu'aucun compte du lab n'a de rôle de base (roles/owner, roles/editor, roles/viewer) sur le projet, et qui échoue sinon. Où l'ajoutez-vous ?

Voir la solution
bad=$(gcloud projects get-iam-policy "$PROJECT" --flatten='bindings[].members' \
  --filter="bindings.members~lab-pipeline-evt- AND bindings.role:(roles/owner roles/editor roles/viewer)" \
  --format='value(bindings.role)')
[ -z "$bad" ] || { echo "Rôle de base accordé à un compte du lab : $bad"; exit 1; }

À la fin de cloud-up.sh, pour qu'une modification manuelle faite entre deux déploiements soit détectée, et dans la CI si l'infrastructure y est déployée. En entreprise, une contrainte d'organisation (iam.automaticIamGrantsForDefaultServiceAccounts) empêche en plus le compte Compute par défaut de recevoir Éditeur à sa création.

  • Je sépare l'identité qui appelle un service de celle sous laquelle il s'exécute.
  • J'accorde chaque rôle sur la ressource la plus étroite qui l'accepte.
  • Je connais les droits dont les agents de service Pub/Sub et Cloud Storage ont besoin.
  • Je supprime les liaisons avant les comptes, et je vérifie qu'il n'en reste aucune.
Suivant