# GitHub OIDC à la place des access keys : un rôle de déploiement par compte AWS, zéro secret dans la CI

Il y a une clé d'accès AWS à longue durée de vie dans les secrets de votre dépôt GitHub. Elle a été créée par la personne qui a monté le premier pipeline, elle a `AdministratorAccess` parce que c'était le moyen le plus rapide de faire passer le déploiement, et personne ne l'a fait tourner depuis. Si cette phrase ne décrit pas votre configuration, vous êtes dans la minorité et vous pouvez sauter à la section sur la trust policy pour les détails. Si elle la décrit, cet article raconte comment nous l'avons remplacée par quelque chose qui n'a aucune clé à faire fuiter, en un après-midi, sur trois comptes.

## Ce qu'OIDC change vraiment

GitHub Actions peut émettre un jeton d'identité à courte durée de vie pour chaque exécution de workflow. Le jeton est signé par GitHub et porte des claims sur sa provenance : le dépôt, la branche ou le tag, l'environnement, le fichier de workflow, l'acteur. On peut dire à AWS IAM de faire confiance à l'émetteur de jetons de GitHub, et un rôle IAM peut être configuré pour n'accepter que les jetons dont les claims correspondent à une condition. Le workflow échange son jeton contre des identifiants AWS temporaires qui vivent une heure et ne sont stockés nulle part.

Trois conséquences. Il n'y a aucun secret dans GitHub, donc rien à faire fuiter ni à faire tourner. La permission de déployer est liée à *d'où vient le code*, pas à qui possède la clé, donc un fork ou une branche quelconque ne peut pas déployer en production même s'il exécute le même fichier de workflow. Et chaque session assumée apparaît dans CloudTrail avec le dépôt et la branche dans le nom de session, donc « qui a déployé ça ? » a une réponse.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flux d'un déploiement OIDC. Un job GitHub Actions sur la branche main demande un jeton d'identité à GitHub. Il appelle AWS STS AssumeRoleWithWebIdentity avec ce jeton. IAM vérifie la trust policy : l'émetteur est GitHub, l'audience est sts.amazonaws.com, le sujet correspond à repo org/platform ref refs/heads/main. STS renvoie des identifiants d'une heure pour le rôle github-deploy-prod, que le job utilise pour cdk deploy. Rien n'est stocké.">
<defs><marker id="arrO" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="60" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">Job GitHub Actions</text><text x="120" y="108" text-anchor="middle" fill="#9aa3c7">repo org/platform · main</text>
<line x1="222" y1="80" x2="338" y2="80" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="280" y="70" text-anchor="middle" fill="#9aa3c7">1 · jeton d'identité (signé par GitHub)</text>
<line x1="338" y1="110" x2="222" y2="110" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="280" y="128" text-anchor="middle" fill="#9aa3c7">4 · identifiants d'une heure</text>
<rect x="340" y="60" width="220" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="450" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">AWS STS</text><text x="450" y="108" text-anchor="middle" fill="#9aa3c7">AssumeRoleWithWebIdentity</text>
<line x1="562" y1="80" x2="678" y2="80" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="620" y="70" text-anchor="middle" fill="#9aa3c7">2 · vérifie la trust policy</text>
<line x1="678" y1="110" x2="562" y2="110" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrO)"/><text x="620" y="128" text-anchor="middle" fill="#9aa3c7">3 · sub correspond → allow</text>
<rect x="680" y="60" width="200" height="70" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="780" y="88" text-anchor="middle" fill="#f1f3ff" font-weight="700">Rôle IAM</text><text x="780" y="108" text-anchor="middle" fill="#ffd166">github-deploy-prod</text>
<text x="450" y="170" text-anchor="middle" fill="#9aa3c7">trust : iss = token.actions.githubusercontent.com · aud = sts.amazonaws.com · sub = repo:org/platform:ref:refs/heads/main</text>
<text x="450" y="196" text-anchor="middle" fill="#4fffb0">aucune access key n'existe · rien à faire tourner · CloudTrail montre le dépôt et la ref dans le nom de session</text>
<text x="450" y="218" text-anchor="middle" fill="#ff6b8a">un fork, une branche de fonctionnalité ou un autre dépôt reçoit AccessDenied à l'étape 3</text>
</g>
</svg>
</div>

## Un provider, trois rôles, trois comptes

Le provider OIDC est une ressource par compte avec l'URL de l'émetteur GitHub et son empreinte. Nous en créons un dans chacun de dev, staging et prod, depuis la même base de code CDK qui crée tout le reste, et un rôle de déploiement à côté. La trust policy du rôle est l'endroit où vit la séparation des environnements :

```ts
// infra/lib/github-deploy-role.ts
const provider = new iam.OpenIdConnectProvider(this, 'GitHubOidc', {
  url: 'https://token.actions.githubusercontent.com',
  clientIds: ['sts.amazonaws.com'],
});

const allowedSubjects: Record<Env, string[]> = {
  dev:     ['repo:org/platform:pull_request', 'repo:org/platform:ref:refs/heads/*'],
  staging: ['repo:org/platform:ref:refs/heads/main'],
  prod:    ['repo:org/platform:environment:production'],
};

new iam.Role(this, 'GitHubDeployRole', {
  roleName: `github-deploy-${env}`,
  maxSessionDuration: Duration.hours(1),
  assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
    StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' },
    StringLike:   { 'token.actions.githubusercontent.com:sub': allowedSubjects[env] },
  }),
});
```

Lisez la map `allowedSubjects` comme la politique qu'elle est. N'importe quelle branche et n'importe quelle pull request peuvent déployer en **dev**, ce dont les environnements de preview ont besoin. Seule la branche `main` peut déployer en **staging**. Seul un job qui tourne dans l'environnement GitHub nommé `production` peut déployer en **prod**, et cet environnement a des relecteurs obligatoires configurés dans GitHub, donc un déploiement de production est un build de `main` qu'un humain a approuvé. Le claim subject d'un job d'environnement est `repo:org/platform:environment:production` quelle que soit la branche, donc nous le combinons avec une règle de protection de branche qui n'autorise que `main` à déployer dans cet environnement.

Deux détails qui nous ont coûté du temps. La condition `aud` doit être `StringEquals`, pas `StringLike`, sinon un linter se plaindra à juste titre que n'importe quelle audience est acceptée. Et le format du claim `sub` change avec le déclencheur : `ref:refs/heads/main` pour un push, `pull_request` pour une PR, `environment:name` pour un job d'environnement. Si un workflow reçoit `AccessDenied` à l'assume, affichez les claims du jeton avec `actions/github-script` avant de toucher à IAM ; neuf fois sur dix, c'est le format du sujet.

## Ce que le rôle peut réellement faire

La trust policy dit qui peut assumer le rôle. La politique de permissions dit ce qu'il peut faire une fois assumé, et c'est là que « moindre privilège pour un rôle de déploiement » cesse d'être un slogan. Notre déploiement exécute `cdk deploy`, et le modèle de CDK rend la réponse propre : le rôle de déploiement n'a pas besoin de la permission de créer des services App Runner ou des tables DynamoDB. Il a besoin de la permission de remettre un template à CloudFormation et de laisser le rôle d'exécution propre à CloudFormation faire le travail.

```ts
role.addToPolicy(new iam.PolicyStatement({
  sid: 'AssumeCdkRoles',
  actions: ['sts:AssumeRole'],
  resources: [
    `arn:aws:iam::${account}:role/cdk-hnb659fds-deploy-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-file-publishing-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-image-publishing-role-${account}-${region}`,
    `arn:aws:iam::${account}:role/cdk-hnb659fds-lookup-role-${account}-${region}`,
  ],
}));
role.addToPolicy(new iam.PolicyStatement({
  sid: 'BuildImages',
  actions: ['codebuild:StartBuild', 'codebuild:BatchGetBuilds'],
  resources: [`arn:aws:codebuild:${region}:${account}:project/platform-*`],
}));
```

C'est toute la politique. Quatre déclarations `sts:AssumeRole` vers les rôles que CDK a créés au bootstrap, plus la permission de lancer nos projets CodeBuild. Le rôle d'exécution CloudFormation créé par CDK au bootstrap est celui qui a les permissions larges, et il ne peut être utilisé que par CloudFormation, qui ne peut être piloté que par un template passé en revue de code. Le rôle GitHub lui-même ne peut pas appeler `apprunner:DeleteService`. Il ne peut même pas lister les buckets. Quand nous avons lancé le rapport d'accès inutilisés d'IAM Access Analyzer après un mois, les rôles de déploiement avaient zéro permission inutilisée, une phrase que nous n'avions jamais pu prononcer à propos d'un identifiant de CI.

Le rôle de publication d'images mérite une réserve : si vos images sont construites en dehors de CodeBuild, sur le runner GitHub lui-même, le rôle de déploiement a besoin de `ecr:GetAuthorizationToken` et de permissions de push sur les dépôts. Nous avons déplacé les builds d'images dans CodeBuild en partie pour tenir ça à l'écart du rôle GitHub, et en partie parce qu'[un runner à 2 vCPU qui construit trois images Next.js est lent](/fr/blog/monorepo-three-apps-build-only-what-changed).

## Le workflow

```yaml
# .github/workflows/deploy-prod.yml
on:
  workflow_dispatch:
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production          # les relecteurs obligatoires vivent ici
    permissions:
      id-token: write                # c'est ce qui active OIDC
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<prod-account-id>:role/github-deploy-prod
          role-session-name: gh-${{ github.run_id }}-${{ github.actor }}
          aws-region: us-east-1
      - run: ./scripts/build-images.sh --tag ${{ github.sha }}
      - run: npx cdk deploy platform-prod --require-approval never
```

Pas de `AWS_ACCESS_KEY_ID`, pas de `AWS_SECRET_ACCESS_KEY`, pas de bloc de secrets du tout. L'identifiant de compte dans l'ARN du rôle n'est pas sensible ; les identifiants de compte apparaissent dans chaque ARN de chaque ligne de log. Le `role-session-name` met l'exécution et l'acteur dans CloudTrail, ce que nous avons utilisé exactement une fois, pour confirmer qu'un déploiement dont personne ne se souvenait était un workflow planifié et non une personne.

## Ce qu'est devenue l'ancienne clé

Nous l'avons supprimée. Pas « désactivée un moment au cas où quelque chose casse » ; nous avons fait tourner le nouveau pipeline une semaine en dev, un déploiement en staging, un en prod, puis supprimé l'utilisateur IAM. Quelque chose a bien cassé : un module Terraform dans un autre dépôt, maintenu par quelqu'un d'autre, qui avait copié la même clé. Il a échoué bruyamment, ce qui est le but. Il a eu son propre rôle deux jours plus tard.

Le secret GitHub a été supprimé dans le même changement. Les secrets dans GitHub sont en écriture seule via l'interface, mais ils sont lisibles par n'importe quel workflow du dépôt, y compris un ajouté dans une pull request par un collaborateur, donc un secret dont vous n'avez pas besoin est un secret qui finit un jour dans un log.

## La checklist

- Un provider OIDC par compte, créé par du code d'infrastructure.
- Un rôle par compte, nommé d'après son environnement, avec une trust policy qui nomme le dépôt et la ref ou l'environnement qui peut l'assumer. Des jokers sur la branche seulement en dev.
- `aud` sous `StringEquals`. `sub` sous `StringLike` seulement quand vous avez vraiment besoin d'un joker.
- Politique de permissions : assumer les rôles de bootstrap CDK, lancer les projets de build, rien d'autre. Si vous n'êtes pas sur CDK, l'équivalent est `cloudformation:*` sur vos stacks plus `iam:PassRole` pour le rôle d'exécution.
- Des sessions d'une heure. Un déploiement qui a besoin de plus a un autre problème.
- `role-session-name` avec l'identifiant d'exécution et l'acteur.
- Supprimez la clé d'accès. Supprimez l'utilisateur IAM. Supprimez le secret GitHub. Regardez ce qui casse ; c'est votre inventaire des choses qui partageaient la clé.

Tout le changement faisait moins de 150 lignes de CDK et 20 lignes de YAML par workflow. Il a retiré le secret le plus précieux de l'entreprise de l'endroit le plus exposé où il pouvait vivre. Si votre pipeline a encore une clé d'accès dedans, [nous pouvons vous aider à la sortir](/contact).
