Skip to content

Commit 94beaae

Browse files
committed
ci: add setup-github-oidc.ps1 to bootstrap workflow auth idempotently
Adds scripts/setup-github-oidc.ps1 which configures everything that .github/workflows/deploy.yml needs to authenticate to Azure without a client secret: 1. Creates (or reuses) an Entra ID app registration + service principal for the workflow. 2. Adds Federated Identity Credentials for both repo:OWNER/REPO:ref:refs/heads/<branch> repo:OWNER/REPO:environment:<env> so push triggers and environment-scoped deploy jobs both work. 3. Grants the SP "Website Contributor" on rg-evidence-workshop, with an opt-in flag (-GrantStorageContributor) for storage RBAC if a future workflow ever needs to touch ADLS. 4. Sets the three required repo secrets via the GitHub CLI: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (or, with -SkipSecretWrite, prints the values for manual entry). All steps detect existing resources and no-op on re-run, matching the idempotency contract of the rest of scripts/.
1 parent a630b7a commit 94beaae

1 file changed

Lines changed: 291 additions & 0 deletions

File tree

scripts/setup-github-oidc.ps1

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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

Comments
 (0)