# 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.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Fluxul unui deploy OIDC. Un job GitHub Actions de pe branch-ul main cere un token de identitate de la GitHub. Apelează AWS STS AssumeRoleWithWebIdentity cu acel token. IAM verifică trust policy-ul: emitentul e GitHub, audiența e sts.amazonaws.com, subiectul se potrivește cu repo org/platform ref refs/heads/main. STS întoarce credențiale de o oră pentru rolul github-deploy-prod, pe care job-ul le folosește pentru cdk deploy. Nimic nu e stocat.">
<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 · id token (semnat de 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 · credențiale de 1 oră</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 · verifică 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 se potrivește → 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">Rol 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">nu există access key · nimic de rotit · CloudTrail arată repo-ul și ref-ul în numele sesiunii</text>
<text x="450" y="218" text-anchor="middle" fill="#ff6b8a">un fork, un feature branch sau alt repo primesc AccessDenied la pasul 3</text>
</g>
</svg>
</div>

## 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:

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

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.

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

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](/ro/blog/monorepo-three-apps-build-only-what-changed).

## Workflow-ul

```yaml
# .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](/contact).
