Mert Özen Her Satırda Daha İleri
DevOps GitHub Actions — #10

GitHub Actions ile Deploy 1: OIDC ile Azure App Service ve Şifresiz Cloud Erişimi

Mert Özen 8 Kas 2026 12 dk 5 görüntülenme
GitHub Actions ile Deploy 1: OIDC ile Azure App Service ve Şifresiz Cloud Erişimi

OIDC ile job her çalışmada GitHub'dan imzalı, kısa ömürlü bir token alır ve Azure bunu federated credential ile doğrulayıp client secret olmadan erişim verir. id-token: write, sub claim formatları, Temmuz 2026'daki ID'li subject değişikliği, custom property claim'leri, staging slot'a deploy, smoke test ve swap; AWS ve Google Cloud karşılıkları.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10, azure/login@v3 (v3.1.0) ve azure/webapps-deploy@v3 (v3.0.8) ile denenmiştir. Azure tarafı ve token alışverişi GitHub'da ve Azure'da koşulmamıştır; claim formatları ve tarihler Kasım 2026 itibarıyla GitHub ve Microsoft dokümantasyonundan doğrulanmıştır.

Önce Tahmin Et

Azure'da Bookstore için bir app registration açtın ve ona tek bir federated credential ekledin; subject alanı repo:octo-org/bookstore:ref:refs/heads/main. Deploy workflow'un main'den tetikleniyor ve job'da environment: production var. azure/login adımı başarılı olur mu?

İkinci soru: aynı repository'yi Ağustos 2026'da sıfırdan oluşturdun ve subject'i yine isimle, repo:octo-org/bookstore:environment:production diye yazdın. Bu kez ne olur?

Secret'lı Girişin Sorunu

Bir önceki yazıda deploy job'ı DEPLOY_TOKEN adlı bir environment secret'ına dayanıyordu. Azure'a klasik yoldan bağlanmak da böyle: bir service principal oluşturursun, ona client secret üretirsin ve bu secret'ı GitHub'a koyarsın. Çalışır, ama üç sorunu var. Secret'ın bir son kullanma tarihi vardır ve o tarih genellikle bir cuma akşamı deploy'u kırarak hatırlanır. Secret bir kere sızarsa (log'a basılması, eski bir fork, kötü niyetli bir action) sahibi onu GitHub dışından, istediği makineden, son kullanma tarihine kadar kullanabilir. Üçüncüsü, secret "hangi workflow, hangi branch, hangi environment" bilgisini taşımaz; elinde olan herkes aynı yetkiye sahiptir.

OIDC (OpenID Connect) bu modeli tersine çeviriyor. Repository'de saklanan bir credential yok. Her job çalışırken GitHub'dan imzalı, kısa ömürlü bir kimlik token'ı ister; cloud tarafı bu token'ın içindeki bilgilere bakar ve önceden tanımladığın güven ilişkisine uyuyorsa kendi kısa ömürlü access token'ını verir. Sızacak kalıcı bir şey kalmıyor; sızan token da dakikalar içinde ölüyor ve yalnızca tanımladığın repository ve environment adına geçerli. Tool'dan bağımsız tarafı, yani kısa ömürlü credential'ın neden daha güvenli olduğu, CI (Continuous Integration) ve CD (Continuous Delivery) kavramlarını anlatan CI/CD Temelleri serisinin pipeline güvenliği yazısında; burada mekaniğe ve Azure'a odaklanıyoruz.

OIDC Akışı: Dört Adımda Şifresiz Giriş

10-oidc-flow-tr.png

Job permissions: id-token: write ile başladığında runner'a iki environment variable verilir: ACTIONS_ID_TOKEN_REQUEST_URL ve ACTIONS_ID_TOKEN_REQUEST_TOKEN. Bir step (veya azure/login gibi bir action) bu adrese istek atıp istediği audience için bir JWT (JSON Web Token) alır. Token'ı imzalayan GitHub'ın OIDC provider'ı; issuer değeri https://token.actions.githubusercontent.com. Cloud tarafı (Azure'da Microsoft Entra ID) token'ın imzasını provider'ın yayınladığı public key'lerle doğrular, ardından iss, aud ve sub claim'lerini senin tanımladığın federated credential ile karşılaştırır. Eşleşirse job'a, yalnızca o job süresince yaşayan bir access token döner.

Önemli bir ayrıntı: id-token: write verdiğinde job'a bir yetki eklemiş olmuyorsun; yalnızca kimlik belgesi isteme hakkı veriyorsun. Belgeyle ne yapılabileceğine cloud tarafı karar veriyor. Bu yüzden permissions: bloğunu job seviyesinde, yalnızca deploy job'larına yaz. Unutma: bir permission yazdığın anda yazmadıkların none olur, actions/checkout kullanacaksan contents: read'i de ekle.

Token'ın İçinde Ne Var: Claim'ler ve Subject

Token'ı görmek için küçük bir probe workflow'u yazdım. Token'ın kendisini değil, yalnızca payload'daki seçili claim'leri basıyor:

name: oidc-probe

on: workflow_dispatch

permissions:
  id-token: write

jobs:
  probe:
    runs-on: ubuntu-latest
    steps:
      - name: Request an ID token and print selected claims
        run: |
          if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
            echo "No OIDC endpoint: missing id-token permission or not running on GitHub"
            exit 1
          fi
          token=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r .value)
          payload=$(echo "$token" | cut -d. -f2 | tr '_-' '/+')
          while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done
          echo "$payload" | base64 -d | jq '{sub, aud, environment, ref, repository_id, exp}'

act ile koşunca sonuç beklendiği gibi:

[oidc-probe/probe] ⭐ Run Main Request an ID token and print selected claims
[oidc-probe/probe]   | No OIDC endpoint: missing id-token permission or not running on GitHub
[oidc-probe/probe]   ❌  Failure - Main Request an ID token and print selected claims [135.677ms]
[oidc-probe/probe] 🏁  Job failed

act'in bir OIDC provider'ı yok, dolayısıyla bu iki variable hiç tanımlanmıyor. Aynı mesajı GitHub'da id-token: write vermeyi unuttuğunda da görürsün. Script'in base64url çözme kısmını, elle ürettiğim imzasız bir token ile runner image'ında ayrıca test ettim; JWT payload'ı base64url kodlu ve padding'siz olduğu için tr ve = tamamlaması şart.

Gerçek bir GitHub token'ında ne olduğunu dokümantasyondaki örnekten biliyoruz: sub, aud, iss, ref, sha, repository, repository_id, repository_owner_id, environment, job_workflow_ref, runner_environment ve daha fazlası. Güven kararını neredeyse her zaman sub belirler ve formatı job'ın bağlamına göre değişir:

  • Job bir environment kullanıyorsa: repo:octo-org/bookstore:environment:production
  • Environment yoksa ve branch push'uysa: repo:octo-org/bookstore:ref:refs/heads/main
  • Environment yoksa ve tag push'uysa: repo:octo-org/bookstore:ref:refs/tags/v1.4.0
  • Pull request event'iyse: repo:octo-org/bookstore:pull_request

Tahminin ilk sorusunun cevabı ilk satırda. Job environment: production kullandığı anda sub artık ref:refs/heads/main değil, environment:production olur. Branch'e göre yazılmış federated credential eşleşmez ve azure/login, README'sinde de anlatılan AADSTS700213: No matching federated identity record found for presented assertion subject hatasıyla düşer. Environment kullanan job'lar için credential'ı environment'a göre yaz; bu aynı zamanda daha güvenli, çünkü environment'ın protection rule'ları artık cloud erişiminin de ön koşulu.

İkinci sorunun cevabı 2026'nın en önemli OIDC değişikliğinde. 15 Temmuz 2026'dan sonra oluşturulan, yeniden adlandırılan veya transfer edilen repository'lerde varsayılan sub, isimlerin yanına değişmeyen sayısal ID'leri ekliyor: repo:octo-org@5544123/bookstore@821093847:environment:production. Sebebi "subject recycling": bir organization adı bırakılıp başkası tarafından alınırsa, isimle yazılmış bir trust policy yeni sahibin repository'sine de güvenebilir. Ağustos'ta oluşturduğun repository yeni formatı kullandığı için isimle yazdığın subject eşleşmez ve yine AADSTS700213 alırsın. Eski repository'ler opt-in yapana kadar eski formatta kalıyor; geçişi kesintisiz yapmak için önce yeni subject'le ikinci bir credential ekle, OIDC ayarlarından opt-in yap, workflow'u doğrula, sonra eskisini sil.

Nisan 2026'da GA (General Availability, genel kullanıma açılma) olan bir diğer yenilik de repository custom property'lerinin token'a repo_property_ önekiyle claim olarak eklenebilmesi. Organization admin'i örneğin tier property'sini OIDC ayarlarından açarsa, tier=production olan her repository'nin token'ında repo_property_tier claim'i görünür. Trust policy'yi repository adları listesi yerine bu claim'e göre yazmak, yeni bir servis eklendiğinde cloud tarafına dokunmadan erişim verebilmek demek. Azure'da bunu klasik federated credential ile değil, aşağıda değineceğimiz flexible federated credential'ın expression'ıyla kullanabilirsin.

Azure Tarafı: App Registration ve Federated Credential

Azure'da hesabım olmadığı için bu bölümdeki komutlar Microsoft Learn'deki şemaya göre yazıldı, çalıştırılmadı. Bookstore için iki environment, iki kimlik:

# Staging kimliği: yalnızca staging slot'una deploy edebilir
az ad app create --display-name bookstore-deploy-staging
az ad sp create --id "$STAGING_APP_ID"
az ad app federated-credential create --id "$STAGING_APP_OBJECT_ID" --parameters staging.json
az role assignment create --assignee "$STAGING_APP_ID" --role "Website Contributor" \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-bookstore/providers/Microsoft.Web/sites/bookstore-api/slots/staging"
{
  "name": "bookstore-environment-staging",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:octo-org/bookstore:environment:staging",
  "audiences": ["api://AzureADTokenExchange"]
}

Production için aynısını environment:production subject'iyle ve site seviyesinde rol ataması ile ayrı bir app registration'da yaparsın. İki kimlik ayrı olduğu için staging job'ı ele geçirilse bile production slot'una dokunamaz. GitHub tarafında iki environment'a da aynı isimle AZURE_CLIENT_ID secret'ı koyarsın; değerler farklı. 09'daki "aynı isim, environment'a göre farklı değer" fikri burada güvenlik sınırına dönüşüyor. Client ID, tenant ID ve subscription ID gizli bilgiler değil, ama azure/login README'si bunları yalnızca secrets üzerinden vermeni, PR (pull request) başlığı veya workflow_dispatch input'u gibi dışarıdan gelen değerlerle doldurmamanı öneriyor; v3 client ID'yi log'da varsayılan olarak maskeliyor.

Klasik federated credential'da subject birebir ve büyük/küçük harf duyarlı eşleşir; wildcard yok. Organization adında büyük harf varsa (Octo-Org) subject'i token'daki gibi yazmalısın. Bir uygulamaya en fazla 20 federated credential eklenebiliyor; çok sayıda branch veya repository için Entra'nın preview'daki flexible federated identity credential'ı, claimsMatchingExpression ile pattern eşleştirmeye izin veriyor. GitHub için bu expression'ın sub yanında repository_id ve/veya repository_owner_id claim'lerini de kontrol etmesi zorunlu.

Workflow: Staging Slot'a Deploy, Smoke Test ve Swap

App Service'in deployment slot'ları (Standard ve üstü tier'larda) blue-green deploy'un hazır hâli: staging slot'u production ile aynı App Service planında çalışan ikinci bir uygulama. Yeni sürümü oraya deploy edip test edersin, sonra swap ile iki slot'un yerini değiştirirsin. Blue-green'in mantığı ve geri dönüş stratejisi ilerideki Deployment Stratejileri serisinin blue-green yazısında; burada workflow tarafı:

name: deploy-azure

on:
  push:
    tags: ['v*']
  workflow_dispatch:

permissions:
  contents: read

env:
  AZURE_WEBAPP_NAME: bookstore-api
  AZURE_RESOURCE_GROUP: rg-bookstore

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet publish api/src/Bookstore.Api -c Release -o out/api
      - uses: actions/upload-artifact@v7
        with:
          name: api-publish
          path: out/api

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    environment:
      name: staging
      url: ${{ steps.deploy.outputs.webapp-url }}
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: api-publish
          path: out/api
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - id: deploy
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          slot-name: staging
          package: out/api
      - name: Smoke test staging slot
        env:
          SLOT_URL: ${{ steps.deploy.outputs.webapp-url }}
        run: |
          for attempt in 1 2 3 4 5; do
            if curl --fail --silent --show-error "$SLOT_URL/health"; then
              exit 0
            fi
            echo "Attempt $attempt failed, retrying in 10s"
            sleep 10
          done
          exit 1

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment:
      name: production
      url: https://bookstore-api.azurewebsites.net
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Swap staging into production
        run: |
          az webapp deployment slot swap \
            --resource-group "$AZURE_RESOURCE_GROUP" \
            --name "$AZURE_WEBAPP_NAME" \
            --slot staging \
            --target-slot production

Birkaç karar var. Staging'in environment URL'i (Uniform Resource Locator) sabit yazılmadı, webapps-deploy'un webapp-url output'undan geliyor; slot'un gerçek adresi deployment kaydına düşüyor. Smoke test bu URL'i run: içine expression olarak gömmek yerine env: üzerinden alıyor; değer güvenli bir kaynaktan gelse de bu alışkanlık 13. yazıdaki script injection konusunun temeli. Smoke test beş kez, onar saniye arayla deniyor, çünkü yeni deploy edilen bir slot ilk istekte soğuk başlar. Production job'ı yeniden build etmiyor ve dosya kopyalamıyor; yalnızca swap yapıyor. Production'a giden, staging'de test edilmiş byte'ların aynısı.

actionlint üç workflow'u da (deploy-azure, oidc-probe ve 09'dan deploy) temiz geçti. act ile koşunca build ve artifact adımları geçti, azure/login ise OIDC'ye gelmeden düştü:

[deploy-azure/deploy-staging] ⭐ Run Main azure/login@v3
[deploy-azure/deploy-staging]   | Running Azure CLI Login.
[deploy-azure/deploy-staging]   ❗  ::error::Login failed with Error: Unable to locate executable file: az. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.. Double check if the 'auth-type' is correct. Refer to https://github.com/Azure/login#readme for more information.
[deploy-azure/deploy-staging]   ❌  Failure - Main azure/login@v3 [507.08325ms]
[deploy-azure/deploy-staging] 🏁  Job failed

azure/login kendi başına bir şey yapmıyor, Azure CLI'yi (Command Line Interface) çağırıyor. GitHub'ın ubuntu-latest image'ında az kurulu geliyor; act'in kullandığı catthehacker/ubuntu:act-24.04 image'ında yok. Kursaydık bile bir sonraki engel OIDC olacaktı: act'te token endpoint'i yok. Kısacası bu workflow'un Azure'la konuşan kısmı yerelde doğrulanamıyor; yerelde test edebileceğin şey build, artifact akışı ve YAML (YAML Ain't Markup Language) dosyasının kendisi. Bu yüzden ilk gerçek koşuyu bir test subscription'ında, workflow_dispatch ile yap.

AWS ve Google Cloud Karşılıkları

Akış AWS (Amazon Web Services) ve Google Cloud'da da aynı; değişen, trust policy'nin yazıldığı yer ve action'ın adı:

KonuAzureAWSGoogle Cloud
Actionazure/login@v3aws-actions/configure-aws-credentials@v6google-github-actions/auth@v3
Workflow girdisiclient-id, tenant-id, subscription-idrole-to-assume, aws-regionworkload_identity_provider
Güven tanımıApp registration veya managed identity üzerinde federated credentialIAM (Identity and Access Management) OIDC identity provider ve role trust policyWorkload Identity Pool ve provider
Varsayılan audienceapi://AzureADTokenExchangests.amazonaws.comProvider'ın tam kaynak adı
Subject kısıtıBirebir subject veya flexible expressiontoken.actions.githubusercontent.com:sub koşulu (StringEquals / StringLike)attribute-condition (CEL, Common Expression Language ifadesi)

AWS'nin README'sindeki trust policy örneği şimdiden yeni, ID'li subject formatıyla yazılmış. AWS'de StringLike ile repo:octo-org/* gibi geniş bir pattern yazmak mümkün ve tehlikeli; Google Cloud tarafında da README, provider'ı en azından assertion.repository_owner ile kısıtlamanı istiyor. Kural her yerde aynı: koşulu repository, environment ve mümkünse ID'lerle daralt.

Üretimde Dikkat

Credential'ı environment'a bağla, branch'e değil. environment:production subject'i, environment'ın required reviewer ve tag policy'sini Azure erişiminin de ön koşulu yapar. Branch subject'i ise main'e push edebilen her workflow'a production kapısını açar.

Rolü en dar scope'a ver. Subscription seviyesinde Contributor, OIDC'nin getirdiği her şeyi geri alır. Staging kimliği slot'a, production kimliği tek siteye.

Swap'tan önce slot ayarlarını kontrol et. "Deployment slot setting" olarak işaretlenmemiş app setting'ler ve connection string'ler swap'la birlikte production'a geçer; staging veritabanına bakan bir connection string'i production'a taşımak klasik bir kazadır.

Rollback'i planla. Swap'ın güzel tarafı, sorun çıkarsa aynı komutla geri swap yapılabilmesi: eski sürüm artık staging slot'unda bekliyor. Bunu manuel değil, ayrı bir workflow_dispatch ile tek tık hâline getir.

Repository'nin subject formatını bil. 15 Temmuz 2026'dan sonra oluşturulan veya adı değişen her repository ID'li formatta. Repository'yi yeniden adlandırmak da formatı değiştirir; Azure tarafını önceden güncellemeden rename yapma.

Sık Düşülen Tuzaklar

id-token: write'ı workflow seviyesine yazmak. Böylece test job'ları da token isteyebilir. Yalnızca cloud'a giden job'a ver.

Environment ekleyip credential'ı güncellemeyi unutmak. Job'a environment: eklemek sub'ı değiştirir; dün çalışan login bugün AADSTS700213 ile düşer.

"Her branch" kabul eden geniş subject. AWS'de repo:octo-org/bookstore:*, Azure'da ref:refs/heads/* eşleyen bir expression, feature branch'e push edebilen herkesi production'a deploy edebilir hâle getirir.

Portal'da owner/repository ID alanlarını eski bir repository için doldurmak. azure/login README'si bunu ayrıca uyarıyor: portal ID'li subject üretir, opt-in yapmamış repository'nin token'ı ise isimli; eşleşme olmaz. Ters yönü de var: yeni repository'ye isimli subject.

Büyük/küçük harf farkı. Entra subject'i büyük/küçük harf duyarlı karşılaştırır; Octo-Org ile octo-org farklı. Hata mesajı (AADSTS7002138) bunu açıkça söylüyor, ama ilk seferde kimse okumuyor.

Token'ı debug için log'a basmak. Probe'daki gibi yalnızca seçili claim'leri bas; JWT'nin tamamı geçerlilik süresi boyunca bir bearer credential.

Küçük Bir Deneme

★☆☆ oidc-probe'u kendi public test repository'nde iki kez çalıştır: bir kez environment'sız, bir kez environment: staging ile. sub değerlerini karşılaştır ve repository'nin hangi subject formatını kullandığını not al.

★★☆ deploy-production job'ına, swap'tan sonra production URL'inde /health'i kontrol eden ve başarısız olursa az webapp deployment slot swap'ı tersine çalıştıran bir step ekle. if: failure() ile hangi step'lerin koştuğunu act'te gör (Azure adımları düşecek, ama koşul akışı görünür).

★★★ AWS için bir IAM role trust policy'si yaz: yalnızca octo-org/bookstore repository'sinin production environment'ına izin versin, ID'li subject formatını kullansın ve aud'u sts.amazonaws.com ile sınırlasın. Aynı policy'yi StringLike ve * ile yazınca neyin açıldığını tek cümleyle açıkla.

PaaS'a (Platform as a Service) deploy, credential'ı cloud'a devretmenin en temiz yolu. Ama her şey App Service'te çalışmıyor; çoğu ekip hâlâ kendi Linux sunucusuna container deploy ediyor. Bir sonraki yazıda Bookstore'un image'ını GHCR'a (GitHub Container Registry) push'layıp SSH (Secure Shell) ile kendi sunucumuza, known_hosts pin'leyerek deploy edeceğiz.