|
| 1 | +<# |
| 2 | +.SYNOPSIS |
| 3 | + Configures GitHub Actions OIDC federated identity to Azure for the |
| 4 | + Evidence Portal workflow, idempotently. |
| 5 | +
|
| 6 | +.DESCRIPTION |
| 7 | + Bootstraps everything the .github/workflows/deploy.yml workflow needs to |
| 8 | + authenticate to Azure without storing a client secret in the repository: |
| 9 | +
|
| 10 | + 1. Creates (or reuses) an Entra ID app registration + service |
| 11 | + principal that the workflow will sign in as. |
| 12 | + 2. Adds Federated Identity Credentials so GitHub's OIDC tokens for |
| 13 | + pushes to a specified branch (default: main) and for the |
| 14 | + "workshop" deployment environment can exchange for an Azure AD |
| 15 | + access token. No secret leaves Azure. |
| 16 | + 3. Grants the SP "Website Contributor" on the resource group so it |
| 17 | + can deploy to App Services. Optionally also grants "Storage |
| 18 | + Blob Data Contributor" on the storage account if the CI ever |
| 19 | + needs to touch storage. |
| 20 | + 4. Sets the three repo secrets (AZURE_CLIENT_ID, AZURE_TENANT_ID, |
| 21 | + AZURE_SUBSCRIPTION_ID) via the GitHub CLI. |
| 22 | +
|
| 23 | + All steps are idempotent and safe to re-run. |
| 24 | +
|
| 25 | +.PARAMETER Repo |
| 26 | + GitHub repository in "owner/name" form. Defaults to |
| 27 | + devopsabcs-engineering/msal-java. |
| 28 | +
|
| 29 | +.PARAMETER Branch |
| 30 | + Git branch the workflow runs against. Defaults to main. |
| 31 | +
|
| 32 | +.PARAMETER Environment |
| 33 | + GitHub deployment environment name used by the workflow. Defaults to |
| 34 | + workshop. A second federated credential is added for this so |
| 35 | + workflow_dispatch from the environment also works. |
| 36 | +
|
| 37 | +.PARAMETER AppDisplayName |
| 38 | + Display name for the SP. Defaults to msal-java-github-actions. |
| 39 | +
|
| 40 | +.PARAMETER ResourceGroup |
| 41 | + Azure resource group where the App Services live. Defaults to |
| 42 | + rg-evidence-workshop. |
| 43 | +
|
| 44 | +.PARAMETER GrantStorageContributor |
| 45 | + If specified, also grants Storage Blob Data Contributor on the |
| 46 | + workshop storage account discovered in the resource group. |
| 47 | +
|
| 48 | +.PARAMETER SkipSecretWrite |
| 49 | + If specified, prints the values that would be written to GitHub |
| 50 | + secrets but does not call `gh`. Useful when running in a directory |
| 51 | + that is not a git working tree, or when secrets are managed by hand. |
| 52 | +
|
| 53 | +.EXAMPLE |
| 54 | + ./scripts/setup-github-oidc.ps1 |
| 55 | +
|
| 56 | +.EXAMPLE |
| 57 | + ./scripts/setup-github-oidc.ps1 -Repo myorg/myfork -Branch dev |
| 58 | +#> |
| 59 | +[CmdletBinding()] |
| 60 | +param( |
| 61 | + [string]$Repo = 'devopsabcs-engineering/msal-java', |
| 62 | + [string]$Branch = 'main', |
| 63 | + [string]$Environment = 'workshop', |
| 64 | + [string]$AppDisplayName = 'msal-java-github-actions', |
| 65 | + [string]$ResourceGroup = 'rg-evidence-workshop', |
| 66 | + [switch]$GrantStorageContributor, |
| 67 | + [switch]$SkipSecretWrite |
| 68 | +) |
| 69 | + |
| 70 | +$ErrorActionPreference = 'Stop' |
| 71 | + |
| 72 | +function Write-Section($message) { |
| 73 | + Write-Host '' |
| 74 | + Write-Host '------------------------------------------------------------' |
| 75 | + Write-Host " $message" |
| 76 | + Write-Host '------------------------------------------------------------' |
| 77 | +} |
| 78 | + |
| 79 | +function Test-Command($name) { |
| 80 | + return [bool](Get-Command $name -ErrorAction SilentlyContinue) |
| 81 | +} |
| 82 | + |
| 83 | +# --------------------------------------------------------------------------- |
| 84 | +# Step 0 — Prerequisites |
| 85 | +# --------------------------------------------------------------------------- |
| 86 | +Write-Section 'Step 0: Verifying prerequisites' |
| 87 | + |
| 88 | +if (-not (Test-Command az)) { |
| 89 | + throw 'Azure CLI (az) is not installed. https://aka.ms/install-azure-cli' |
| 90 | +} |
| 91 | +if (-not $SkipSecretWrite -and -not (Test-Command gh)) { |
| 92 | + throw 'GitHub CLI (gh) is not installed. Install from https://cli.github.com/ or re-run with -SkipSecretWrite to print secrets manually.' |
| 93 | +} |
| 94 | + |
| 95 | +$account = az account show --output json 2>$null | ConvertFrom-Json |
| 96 | +if (-not $account) { throw "Not logged in to Azure CLI. Run 'az login' first." } |
| 97 | +$tenantId = $account.tenantId |
| 98 | +$subscriptionId = $account.id |
| 99 | +Write-Host " Subscription: $($account.name) ($subscriptionId)" |
| 100 | +Write-Host " Tenant: $tenantId" |
| 101 | + |
| 102 | +if (-not $SkipSecretWrite) { |
| 103 | + & gh auth status 2>$null | Out-Null |
| 104 | + if ($LASTEXITCODE -ne 0) { |
| 105 | + throw "Not logged in to GitHub CLI. Run 'gh auth login' first." |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +# --------------------------------------------------------------------------- |
| 110 | +# Step 1 — Ensure app registration + service principal |
| 111 | +# --------------------------------------------------------------------------- |
| 112 | +Write-Section "Step 1: Ensuring app registration '$AppDisplayName'" |
| 113 | + |
| 114 | +$appId = az ad app list ` |
| 115 | + --display-name $AppDisplayName ` |
| 116 | + --query '[0].appId' -o tsv 2>$null |
| 117 | +if (-not $appId) { |
| 118 | + Write-Host ' Creating new app registration...' |
| 119 | + $appId = az ad app create ` |
| 120 | + --display-name $AppDisplayName ` |
| 121 | + --sign-in-audience AzureADMyOrg ` |
| 122 | + --query appId -o tsv |
| 123 | +} else { |
| 124 | + Write-Host " Reusing existing app registration: $appId" |
| 125 | +} |
| 126 | + |
| 127 | +$objectId = az ad app show --id $appId --query id -o tsv |
| 128 | + |
| 129 | +$spId = az ad sp list --filter "appId eq '$appId'" --query '[0].id' -o tsv 2>$null |
| 130 | +if (-not $spId) { |
| 131 | + Write-Host ' Creating service principal...' |
| 132 | + $spId = az ad sp create --id $appId --query id -o tsv |
| 133 | +} else { |
| 134 | + Write-Host " Reusing existing service principal: $spId" |
| 135 | +} |
| 136 | + |
| 137 | +Write-Host " appId : $appId" |
| 138 | +Write-Host " objectId : $objectId" |
| 139 | +Write-Host " spId : $spId" |
| 140 | + |
| 141 | +# --------------------------------------------------------------------------- |
| 142 | +# Step 2 — Federated Identity Credentials (branch + environment) |
| 143 | +# --------------------------------------------------------------------------- |
| 144 | +Write-Section 'Step 2: Ensuring Federated Identity Credentials' |
| 145 | + |
| 146 | +# Each credential maps a GitHub OIDC subject claim to this app reg. |
| 147 | +# - Branch credential covers `on: push` and `workflow_dispatch` triggers |
| 148 | +# that don't go through an Environment. |
| 149 | +# - Environment credential covers steps inside `environment: workshop` |
| 150 | +# in the workflow (used for the deploy job). |
| 151 | +$credentials = @( |
| 152 | + @{ |
| 153 | + Name = "github-$($Repo -replace '/', '-')-branch-$Branch" |
| 154 | + Subject = "repo:${Repo}:ref:refs/heads/$Branch" |
| 155 | + }, |
| 156 | + @{ |
| 157 | + Name = "github-$($Repo -replace '/', '-')-env-$Environment" |
| 158 | + Subject = "repo:${Repo}:environment:$Environment" |
| 159 | + } |
| 160 | +) |
| 161 | + |
| 162 | +$existing = az ad app federated-credential list --id $appId --output json 2>$null | ConvertFrom-Json |
| 163 | +if (-not $existing) { $existing = @() } |
| 164 | + |
| 165 | +foreach ($cred in $credentials) { |
| 166 | + $match = $existing | Where-Object { $_.subject -eq $cred.Subject } |
| 167 | + if ($match) { |
| 168 | + Write-Host " Reusing federated credential '$($match.name)' for subject $($cred.Subject)" |
| 169 | + continue |
| 170 | + } |
| 171 | + Write-Host " Adding federated credential for subject $($cred.Subject)" |
| 172 | + $body = @{ |
| 173 | + name = $cred.Name |
| 174 | + issuer = 'https://token.actions.githubusercontent.com' |
| 175 | + subject = $cred.Subject |
| 176 | + audiences = @('api://AzureADTokenExchange') |
| 177 | + description = "GitHub Actions OIDC for $($cred.Subject)" |
| 178 | + } | ConvertTo-Json -Compress |
| 179 | + |
| 180 | + # az reads the JSON from a file or stdin (here-string -> temp file is |
| 181 | + # the cross-platform option). |
| 182 | + $tmp = New-TemporaryFile |
| 183 | + try { |
| 184 | + Set-Content -LiteralPath $tmp.FullName -Value $body -Encoding ASCII |
| 185 | + az ad app federated-credential create --id $appId --parameters "@$($tmp.FullName)" --output none |
| 186 | + } finally { |
| 187 | + Remove-Item -LiteralPath $tmp.FullName -Force -ErrorAction SilentlyContinue |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +# --------------------------------------------------------------------------- |
| 192 | +# Step 3 — Role assignments |
| 193 | +# --------------------------------------------------------------------------- |
| 194 | +Write-Section "Step 3: Assigning Website Contributor on $ResourceGroup" |
| 195 | + |
| 196 | +$rgScope = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroup" |
| 197 | + |
| 198 | +az group show --name $ResourceGroup --output none 2>$null |
| 199 | +if ($LASTEXITCODE -ne 0) { |
| 200 | + throw "Resource group '$ResourceGroup' does not exist in subscription $subscriptionId. Deploy infra first via scripts/deploy.ps1." |
| 201 | +} |
| 202 | + |
| 203 | +$existingRole = az role assignment list ` |
| 204 | + --assignee $spId ` |
| 205 | + --scope $rgScope ` |
| 206 | + --role 'Website Contributor' ` |
| 207 | + --query '[0].id' -o tsv 2>$null |
| 208 | +if ($existingRole) { |
| 209 | + Write-Host ' Website Contributor already assigned.' |
| 210 | +} else { |
| 211 | + Write-Host ' Granting Website Contributor (may take a few seconds to propagate)...' |
| 212 | + az role assignment create ` |
| 213 | + --assignee-object-id $spId ` |
| 214 | + --assignee-principal-type ServicePrincipal ` |
| 215 | + --role 'Website Contributor' ` |
| 216 | + --scope $rgScope ` |
| 217 | + --output none |
| 218 | +} |
| 219 | + |
| 220 | +if ($GrantStorageContributor) { |
| 221 | + Write-Host '' |
| 222 | + Write-Host ' -GrantStorageContributor specified; locating storage account in RG...' |
| 223 | + $storageAccount = az storage account list ` |
| 224 | + --resource-group $ResourceGroup ` |
| 225 | + --query '[0].name' -o tsv 2>$null |
| 226 | + if (-not $storageAccount) { |
| 227 | + Write-Warning ' No storage account found in RG; skipping Storage Blob Data Contributor grant.' |
| 228 | + } else { |
| 229 | + $storageScope = "$rgScope/providers/Microsoft.Storage/storageAccounts/$storageAccount" |
| 230 | + $existingStorageRole = az role assignment list ` |
| 231 | + --assignee $spId ` |
| 232 | + --scope $storageScope ` |
| 233 | + --role 'Storage Blob Data Contributor' ` |
| 234 | + --query '[0].id' -o tsv 2>$null |
| 235 | + if ($existingStorageRole) { |
| 236 | + Write-Host " Storage Blob Data Contributor already assigned on $storageAccount." |
| 237 | + } else { |
| 238 | + Write-Host " Granting Storage Blob Data Contributor on $storageAccount..." |
| 239 | + az role assignment create ` |
| 240 | + --assignee-object-id $spId ` |
| 241 | + --assignee-principal-type ServicePrincipal ` |
| 242 | + --role 'Storage Blob Data Contributor' ` |
| 243 | + --scope $storageScope ` |
| 244 | + --output none |
| 245 | + } |
| 246 | + } |
| 247 | +} |
| 248 | + |
| 249 | +# --------------------------------------------------------------------------- |
| 250 | +# Step 4 — GitHub repository secrets |
| 251 | +# --------------------------------------------------------------------------- |
| 252 | +Write-Section "Step 4: Setting GitHub repository secrets on $Repo" |
| 253 | + |
| 254 | +$secrets = [ordered]@{ |
| 255 | + AZURE_CLIENT_ID = $appId |
| 256 | + AZURE_TENANT_ID = $tenantId |
| 257 | + AZURE_SUBSCRIPTION_ID = $subscriptionId |
| 258 | +} |
| 259 | + |
| 260 | +if ($SkipSecretWrite) { |
| 261 | + Write-Host ' -SkipSecretWrite specified. Set these manually under' |
| 262 | + Write-Host " https://github.com/$Repo/settings/secrets/actions :" |
| 263 | + foreach ($k in $secrets.Keys) { |
| 264 | + Write-Host (" {0,-22} = {1}" -f $k, $secrets[$k]) |
| 265 | + } |
| 266 | +} else { |
| 267 | + foreach ($k in $secrets.Keys) { |
| 268 | + Write-Host " Setting $k" |
| 269 | + $value = $secrets[$k] |
| 270 | + # `gh secret set` reads the value from stdin when --body is omitted. |
| 271 | + $value | & gh secret set $k --repo $Repo --body $value | Out-Null |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +# --------------------------------------------------------------------------- |
| 276 | +# Done |
| 277 | +# --------------------------------------------------------------------------- |
| 278 | +Write-Host '' |
| 279 | +Write-Host '============================================================' |
| 280 | +Write-Host ' GitHub OIDC setup complete' |
| 281 | +Write-Host '============================================================' |
| 282 | +Write-Host " Repository : $Repo" |
| 283 | +Write-Host " Branch credential : repo:${Repo}:ref:refs/heads/$Branch" |
| 284 | +Write-Host " Environment credential: repo:${Repo}:environment:$Environment" |
| 285 | +Write-Host " App registration : $AppDisplayName ($appId)" |
| 286 | +Write-Host " Service principal : $spId" |
| 287 | +Write-Host " RG scope : $rgScope" |
| 288 | +Write-Host '' |
| 289 | +Write-Host ' Next: push to '"$Branch"' (or run the workflow via' |
| 290 | +Write-Host ' gh workflow run "Deploy SPA and API"' |
| 291 | +Write-Host ' under https://github.com/'"$Repo"'/actions).' |
0 commit comments