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.
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 :
// 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.
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.
Le workflow
# .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.
audsousStringEquals.subsousStringLikeseulement 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 plusiam:PassRolepour 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-nameavec 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.