Skip to content
GitHub OIDC în loc de access keys: un rol de deploy per cont AWS, zero secrete în CI
← ← Înapoi la Idei Cloud

GitHub OIDC în loc de access keys: un rol de deploy per cont AWS, zero secrete în CI

Există o cheie de acces AWS cu viață lungă în secretele repository-ului tău de GitHub. A fost creată de cine a configurat primul pipeline, are AdministratorAccess pentru că ăsta era cel mai rapid mod de a face deploy-ul să treacă, și nimeni n-a rotit-o de atunci. Dacă propoziția asta nu descrie configurația ta, ești în minoritate și poți sări la secțiunea despre trust policy pentru detalii. Dacă o descrie, articolul ăsta e despre cum am înlocuit-o cu ceva care nu are nicio cheie de scurs, într-o după-amiază, pe trei conturi.

Ce schimbă OIDC de fapt

GitHub Actions poate emite un token de identitate cu viață scurtă pentru fiecare rulare de workflow. Token-ul e semnat de GitHub și poartă claim-uri despre de unde vine: repository-ul, branch-ul sau tag-ul, environment-ul, fișierul de workflow, actorul. IAM-ului din AWS i se poate spune să aibă încredere în emitentul de token-uri al GitHub, iar un rol IAM poate fi configurat să accepte doar token-uri ale căror claim-uri se potrivesc cu o condiție. Workflow-ul își schimbă token-ul pe credențiale AWS temporare care trăiesc o oră și nu sunt stocate nicăieri.

Trei consecințe. Nu există niciun secret în GitHub, deci nu e nimic de scurs sau de rotit. Permisiunea de deploy e legată de de unde vine codul, nu de cine are cheia, deci un fork sau un branch oarecare nu poate face deploy în producție chiar dacă rulează același fișier de workflow. Și fiecare sesiune asumată apare în CloudTrail cu repository-ul și branch-ul în numele sesiunii, deci „cine a făcut deploy-ul ăsta?” are un răspuns.

Job GitHub Actionsrepo org/platform · main 1 · id token (semnat de GitHub) 4 · credențiale de 1 oră AWS STSAssumeRoleWithWebIdentity 2 · verifică trust policy 3 · sub se potrivește → allow Rol IAMgithub-deploy-prod trust: iss = token.actions.githubusercontent.com · aud = sts.amazonaws.com · sub = repo:org/platform:ref:refs/heads/main nu există access key · nimic de rotit · CloudTrail arată repo-ul și ref-ul în numele sesiunii un fork, un feature branch sau alt repo primesc AccessDenied la pasul 3

Un provider, trei roluri, trei conturi

Provider-ul OIDC e o resursă per cont, cu URL-ul emitentului GitHub și thumbprint-ul. Creăm câte unul în dev, staging și prod, din același cod CDK care creează tot restul, și un rol de deploy lângă el. Trust policy-ul rolului e locul unde trăiește separarea mediilor:

// 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] },
  }),
});

Citește map-ul allowedSubjects ca politica ce este. Orice branch și orice pull request pot face deploy în dev, exact ce au nevoie mediile de preview. Doar branch-ul main poate face deploy în staging. Doar un job care rulează în environment-ul GitHub numit production poate face deploy în prod, iar environment-ul ăla are required reviewers configurați în GitHub, deci un deploy de producție e un build de main pe care un om a dat approve. Claim-ul subject pentru un job de environment e repo:org/platform:environment:production indiferent de branch, așa că îl combinăm cu o regulă de branch protection care permite doar lui main să facă deploy în acel environment.

Două detalii care ne-au costat timp. Condiția aud trebuie să fie StringEquals, nu StringLike, altfel un linter se va plânge pe bună dreptate că e acceptată orice audiență. Și formatul claim-ului sub se schimbă cu trigger-ul: ref:refs/heads/main la push, pull_request la PR, environment:name la un job de environment. Dacă un workflow primește AccessDenied la assume, afișează claim-urile token-ului cu actions/github-script înainte să atingi IAM; de nouă ori din zece e formatul subiectului.

Ce poate face rolul de fapt

Trust policy-ul spune cine poate asuma rolul. Politica de permisiuni spune ce poate face odată asumat, și aici „least privilege pentru un rol de deploy” încetează să fie un slogan. Deploy-ul nostru rulează cdk deploy, iar modelul CDK face răspunsul curat: rolul de deploy nu are nevoie de permisiunea de a crea servicii App Runner sau tabele DynamoDB. Are nevoie de permisiunea de a da un template lui CloudFormation și de a lăsa rolul de execuție al CloudFormation să facă treaba.

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-*`],
}));

Asta e toată politica. Patru declarații sts:AssumeRole către rolurile pe care le-a creat CDK la bootstrap, plus permisiunea de a porni proiectele noastre CodeBuild. Rolul de execuție CloudFormation creat de CDK la bootstrap e cel cu permisiuni largi, și poate fi folosit doar de CloudFormation, care poate fi condus doar printr-un template ce a trecut prin code review. Rolul de GitHub în sine nu poate apela apprunner:DeleteService. Nu poate nici măcar să listeze bucket-uri. Când am rulat raportul de unused access din IAM Access Analyzer după o lună, rolurile de deploy aveau zero permisiuni nefolosite, o propoziție pe care n-am putut-o spune niciodată despre o credențială de CI.

Rolul de image publishing merită o observație: dacă imaginile tale sunt construite în afara CodeBuild, chiar pe runner-ul GitHub, rolul de deploy are nevoie de ecr:GetAuthorizationToken și de permisiuni de push pe repository-uri. Am mutat build-urile de imagini în CodeBuild parțial ca să ținem asta departe de rolul GitHub, și parțial pentru că un runner cu 2 vCPU care construiește trei imagini Next.js e lent.

Workflow-ul

# .github/workflows/deploy-prod.yml
on:
  workflow_dispatch:
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production          # required reviewers stau aici
    permissions:
      id-token: write                # asta activează 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

Fără AWS_ACCESS_KEY_ID, fără AWS_SECRET_ACCESS_KEY, fără niciun bloc de secrete. ID-ul de cont din ARN-ul rolului nu e sensibil; ID-urile de cont apar în fiecare ARN din fiecare linie de log. role-session-name pune rularea și actorul în CloudTrail, lucru pe care l-am folosit exact o dată, ca să confirmăm că un deploy de care nu-și amintea nimeni fusese un workflow programat, nu o persoană.

Ce s-a întâmplat cu cheia veche

Am șters-o. Nu „dezactivată o vreme în caz că se strică ceva”; am rulat noul pipeline o săptămână în dev, un deploy în staging, unul în prod, apoi am șters utilizatorul IAM. Ceva s-a stricat: un modul Terraform dintr-un alt repository, întreținut de altcineva, care copiase aceeași cheie. A eșuat zgomotos, ceea ce e și ideea. Și-a primit propriul rol două zile mai târziu.

Secretul din GitHub a fost șters în aceeași schimbare. Secretele din GitHub sunt write-only prin UI, dar pot fi citite de orice workflow din repository, inclusiv de unul adăugat într-un pull request de un colaborator, deci un secret de care nu ai nevoie e un secret care ajunge, în cele din urmă, într-un log.

Checklist-ul

  • Un provider OIDC per cont, creat din cod de infrastructură.
  • Un rol per cont, numit după mediul lui, cu un trust policy care numește repository-ul și ref-ul sau environment-ul care îl poate asuma. Wildcard-uri pe branch doar în dev.
  • aud sub StringEquals. sub sub StringLike doar când chiar ai nevoie de wildcard.
  • Politica de permisiuni: asumă rolurile de bootstrap CDK, pornește proiectele de build, nimic altceva. Dacă nu ești pe CDK, echivalentul e cloudformation:* pe stack-urile tale plus iam:PassRole pentru rolul de execuție.
  • Sesiuni de o oră. Un deploy care are nevoie de mai mult are o altă problemă.
  • role-session-name cu ID-ul rulării și actorul.
  • Șterge cheia de acces. Șterge utilizatorul IAM. Șterge secretul din GitHub. Uită-te ce se strică; ăla e inventarul lucrurilor care împărțeau cheia.

Toată schimbarea a avut sub 150 de linii de CDK și 20 de linii de YAML per workflow. A scos cel mai valoros secret din companie din cel mai expus loc în care putea trăi. Dacă pipeline-ul tău încă are o cheie de acces în el, te putem ajuta s-o scoți.