Skip to content

Commit 820c3c8

Browse files
committed
Add detailed section on pipeline decorators and security analysis for Azure DevOps REST API authentication
1 parent 36b17a9 commit 820c3c8

File tree

1 file changed

+330
-2
lines changed

1 file changed

+330
-2
lines changed

docs/ado-rest-api-auth-without-pat.md

Lines changed: 330 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ For the specific **Advanced Security API gating** scenario, the recommended path
2626
10. [Recommendation Matrix](#10-recommendation-matrix)
2727
11. [Implementation Examples](#11-implementation-examples)
2828
12. [Scaling to 200 Projects with a Custom Pipeline Task](#12-scaling-to-200-projects-with-a-custom-pipeline-task)
29-
13. [References](#13-references)
29+
13. [Pipeline Decorators — The Hardest Scenario](#13-pipeline-decorators--the-hardest-scenario)
30+
14. [Why Microsoft Removed Build Identity Access — Security Analysis](#14-why-microsoft-removed-build-identity-access--security-analysis)
31+
15. [References](#15-references)
3032

3133
---
3234

@@ -705,7 +707,327 @@ foreach ($project in $projects) {
705707

706708
---
707709

708-
## 13. References
710+
## 13. Pipeline Decorators — The Hardest Scenario
711+
712+
### Context
713+
714+
The customer uses a **pipeline decorator** (not a custom task) that injects steps into every pipeline in the organization. The decorator currently uses `System.AccessToken` to call Advanced Security APIs and gate pipelines. After April 15, 2026, this will stop working.
715+
716+
Key constraint: the customer already has **200 service connections with SPs** to cloud environments — but those are **Azure Resource Manager** SPs for deploying to Azure, not ADO-scoped SPs. Adding all 200 as Basic users in ADO would cost ~$1,200/month and is unnecessary.
717+
718+
---
719+
720+
### Why This Is Harder Than a Custom Task
721+
722+
Pipeline decorators have **unique constraints** that don't apply to regular pipeline tasks:
723+
724+
| Constraint | Impact |
725+
|-----------|--------|
726+
| **Service connection names must be hardcoded** — no variables, no parameters, no runtime expressions | The SC name in the decorator YAML must be a literal string, resolved at compile time |
727+
| **Decorator runs on EVERY pipeline in the org** | The referenced SC must be authorized in every project where pipelines run |
728+
| **Decorators cannot accept user inputs** | Unlike custom tasks, there's no `task.json` with input fields — the decorator YAML is fixed |
729+
| **SC authorization is project-scoped** | Even with "Grant access to all pipelines," that only applies within the project where the SC lives |
730+
731+
This means: **the decorator must reference a single, hardcoded service connection name, and that SC must exist and be authorized in every project.**
732+
733+
---
734+
735+
### Evaluating Your Proposed Solution
736+
737+
#### Your Solution: One Shared WIF Service Connection Across All Projects
738+
739+
**Proposal**: Create **1 Service Principal** with only `Advanced Security: Read alerts` at the org level, create **1 Azure DevOps Service Connection** with Workload Identity Federation, and share it across all 200 projects with a consistent name (e.g., `advsec-gate-sc`).
740+
741+
**Verdict: This is a sound approach.** Here's the analysis:
742+
743+
| Aspect | Assessment |
744+
|--------|-----------|
745+
| **Security** | ✅ The SP has the narrowest possible scope — only `Advanced Security: Read alerts`, org-wide. It cannot modify code, manage releases, access ARM resources, or change alert states. |
746+
| **Cost** | ✅ Only 1 Basic license (~$6/month). No Advanced Security committer license since the SP doesn't commit code. |
747+
| **Shared SC concern** | ⚠️ Microsoft recommends against sharing SCs broadly, but that guidance targets **ARM SCs with cloud access**. This SC can only read security alerts in ADO — the blast radius of compromise is limited to reading vulnerability data (not modifying anything). |
748+
| **Decorator compatibility** | ✅ The SC name can be hardcoded in the decorator YAML since it's the same name everywhere. |
749+
| **Rollout** | 🟡 Must create/share the SC in all 200 projects (scriptable via REST API). |
750+
751+
**Key defense of this approach**: The reason Microsoft discourages shared SCs is the principle of least privilege — a shared ARM SC could allow pipelines in Project A to deploy to Project B's Azure resources. But this SC has **zero ARM access**. It can only read ADO security alerts. The risk profile is fundamentally different.
752+
753+
---
754+
755+
### Challenging Your Solution — Risks to Consider
756+
757+
| Risk | Severity | Mitigation |
758+
|------|----------|------------|
759+
| **Alert data exposure** — any pipeline in the org can read security alerts from any repo | 🟡 Medium | Accept if org policy allows central security visibility; if not, scope the SP's `Read alerts` permission per-project instead of org-wide |
760+
| **SC authorization sprawl** — must grant "Use" to all pipelines in 200 projects | 🟡 Medium | Script it via REST API; use "Grant access to all pipelines" per project |
761+
| **Single point of failure** — if the SC breaks, all 200 projects lose their gate | 🟡 Medium | Monitor the SC, set up alerts; WIF has no secrets to expire |
762+
| **Decorator update required** — decorator YAML must be changed from `System.AccessToken` to `AzureCLI@3` with the SC | 🟢 Low | One-time change, centrally managed |
763+
| **SC doesn't exist yet in new projects** — new projects need manual/automated provisioning | 🟡 Medium | Automate with a project creation hook or periodic script |
764+
765+
---
766+
767+
### Alternative Solutions Compared
768+
769+
#### Alternative 1: Decorator Uses System.AccessToken + Explicitly Grant "Advanced Security: Read alerts" to Build Service
770+
771+
**Status**: ❌ **Will NOT work after April 15, 2026.** Microsoft is blocking build service identities at the API level regardless of explicit permissions. This is not a permission issue — it's an identity-type block.
772+
773+
#### Alternative 2: One SP Per Project (200 SPs)
774+
775+
| Aspect | Assessment |
776+
|--------|-----------|
777+
| Cost | ❌ 200 × ~$6 = ~$1,200/month |
778+
| Security | ✅ Better isolation per project |
779+
| Rollout | ❌ 200 Entra app registrations + ADO provisioning |
780+
| Decorator | ❌ Decorator can't dynamically select which SC to use — it must be hardcoded |
781+
782+
**Verdict**: Not viable. The decorator's hardcoded SC name means you NEED one shared SC anyway. Multiple SPs defeats the purpose and costs 200× more.
783+
784+
#### Alternative 3: Status Checks (Sprint 271+) — Eliminate the Decorator
785+
786+
| Aspect | Assessment |
787+
|--------|-----------|
788+
| Cost | ✅ $0 |
789+
| Security | ✅ Native platform feature |
790+
| Rollout | 🟡 Script branch policies across all repos via REST API |
791+
| Flexibility | ❌ PR gating only — no deployment gate, no custom logic |
792+
| Decorator | N/A — replaces the decorator entirely |
793+
794+
**Verdict**: Best for PR gating. But if the decorator does more than just pass/fail (custom severity thresholds, alert categorization, deployment gates, reporting), Status Checks won't replace it.
795+
796+
#### Alternative 4: Decorator Acquires Token from Key Vault (No Service Connection)
797+
798+
The decorator injects a script step that reads SP credentials from an Azure Key Vault-linked variable group, then acquires an Entra token directly. This avoids the SC-in-decorator limitation.
799+
800+
```yaml
801+
# decorator.yml
802+
steps:
803+
- ${{ if ne(variables['skipAdvSecGate'], 'true') }}:
804+
- task: AzureKeyVault@2
805+
inputs:
806+
azureSubscription: 'keyvault-reader-sc' # ARM SC - already exists
807+
KeyVaultName: 'advsec-sp-keyvault'
808+
SecretsFilter: 'advsec-sp-client-id,advsec-sp-client-secret,advsec-sp-tenant-id'
809+
RunAsPreJob: false
810+
- task: PowerShell@2
811+
displayName: 'Advanced Security Gate'
812+
inputs:
813+
targetType: 'inline'
814+
script: |
815+
# Acquire Entra token for the dedicated AdvSec SP
816+
$body = @{
817+
client_id = "$(advsec-sp-client-id)"
818+
scope = "499b84ac-1321-427f-aa17-267ca6975798/.default"
819+
client_secret = "$(advsec-sp-client-secret)"
820+
grant_type = "client_credentials"
821+
}
822+
$token = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$(advsec-sp-tenant-id)/oauth2/v2.0/token" -Method POST -Body $body).access_token
823+
# ... call Advanced Security API with $token ...
824+
```
825+
826+
| Aspect | Assessment |
827+
|--------|-----------|
828+
| Cost | ✅ 1 Basic license (~$6/month) + Key Vault costs (negligible) |
829+
| Security | ⚠️ Client secret exists — use certificate to mitigate; secret is in Key Vault, never in pipeline YAML |
830+
| SC dependency | ⚠️ Requires an ARM SC for Key Vault — but customers likely already have one for deployments |
831+
| Decorator compat | ✅ Works if the ARM SC has the same name in all projects (common pattern) |
832+
833+
**Verdict**: Viable fallback if WIF is not available or the "Azure DevOps" SC type hasn't rolled out yet. But adds secret management complexity.
834+
835+
---
836+
837+
### Recommended Solution
838+
839+
**For your specific scenario (pipeline decorator, 200 projects, April 15 deadline):**
840+
841+
```
842+
┌─────────────────────────────────────────────────────────────┐
843+
│ RECOMMENDED: Your proposed solution with refinements │
844+
│ │
845+
│ 1 Service Principal (Entra) │
846+
│ └── Advanced Security: Read alerts (org-wide) │
847+
│ └── Basic license ($6/month) │
848+
│ └── No code commit = No AdvSec committer license │
849+
│ │
850+
│ 1 Azure DevOps Service Connection (WIF) │
851+
│ └── Name: "advsec-gate-sc" (hardcoded in decorator) │
852+
│ └── Zero secrets (Workload Identity Federation) │
853+
│ └── Shared to all 200 projects via REST API │
854+
│ └── "Grant access to all pipelines" per project │
855+
│ │
856+
│ Updated Decorator (one-time change) │
857+
│ └── Replace System.AccessToken usage with AzureCLI@3 │
858+
│ └── Reference: azureDevOpsServiceConnection: advsec-gate │
859+
│ └── Republish extension │
860+
│ │
861+
│ Total cost: ~$6/month │
862+
│ Total SPs as Basic users: 1 (not 200) │
863+
└─────────────────────────────────────────────────────────────┘
864+
```
865+
866+
### Updated Decorator YAML
867+
868+
```yaml
869+
# decorator.yml — updated to use ADO Service Connection
870+
steps:
871+
- ${{ if ne(variables['skipAdvSecGate'], 'true') }}:
872+
- task: AzureCLI@3
873+
displayName: 'Advanced Security Gate (Decorator)'
874+
inputs:
875+
connectionType: 'azureDevOps'
876+
azureDevOpsServiceConnection: 'advsec-gate-sc'
877+
scriptType: 'pscore'
878+
scriptLocation: 'inlineScript'
879+
inlineScript: |
880+
$token = az account get-access-token `
881+
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
882+
--query "accessToken" --output tsv
883+
884+
$headers = @{
885+
Authorization = "Bearer $token"
886+
"Content-Type" = "application/json"
887+
}
888+
889+
# Dynamically resolve org and project from pipeline context
890+
$collectionUri = $env:SYSTEM_COLLECTIONURI
891+
$project = $env:SYSTEM_TEAMPROJECT
892+
$repoName = $env:BUILD_REPOSITORY_NAME
893+
$org = ($collectionUri -replace 'https://dev.azure.com/', '' -replace '/$', '')
894+
895+
# Get repository ID
896+
$repos = Invoke-RestMethod `
897+
-Uri "${collectionUri}${project}/_apis/git/repositories?api-version=7.2" `
898+
-Headers $headers
899+
$repoId = ($repos.value | Where-Object { $_.name -eq $repoName }).id
900+
901+
if (-not $repoId) {
902+
Write-Host "##vso[task.logissue type=warning]Repository not found in ADO, skipping Advanced Security gate"
903+
exit 0
904+
}
905+
906+
# Query Advanced Security alerts
907+
$alerts = Invoke-RestMethod `
908+
-Uri "https://advsec.dev.azure.com/$org/$project/_apis/alert/repositories/$repoId/alerts?criteria.states=active&criteria.severities=critical,high&api-version=7.2-preview.1" `
909+
-Headers $headers
910+
911+
$activeHighCritical = @($alerts.value | Where-Object {
912+
$_.severity -in @('critical', 'high') -and $_.state -eq 'active'
913+
})
914+
915+
if ($activeHighCritical.Count -gt 0) {
916+
Write-Host "##vso[task.logissue type=error]Found $($activeHighCritical.Count) active high/critical security alerts"
917+
foreach ($a in $activeHighCritical) {
918+
Write-Host " - [$($a.severity)] $($a.title)"
919+
}
920+
Write-Host "##vso[task.complete result=Failed;]Advanced Security Gate FAILED"
921+
exit 1
922+
}
923+
924+
Write-Host "✅ No active high/critical security alerts — gate passed"
925+
```
926+
927+
### Provisioning Script — Share SC Across 200 Projects
928+
929+
```powershell
930+
# Run once: provision the shared service connection across all projects
931+
param(
932+
[string]$OrgUrl = "https://dev.azure.com/{org}",
933+
[string]$ServiceConnectionName = "advsec-gate-sc"
934+
)
935+
936+
# Authenticate
937+
az login --allow-no-subscriptions
938+
$token = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv
939+
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
940+
941+
# Get all projects (handle pagination for 200+)
942+
$allProjects = @()
943+
$continuationToken = $null
944+
do {
945+
$uri = "$OrgUrl/_apis/projects?`$top=100&api-version=7.2"
946+
if ($continuationToken) { $uri += "&continuationToken=$continuationToken" }
947+
$response = Invoke-WebRequest -Uri $uri -Headers $headers
948+
$projects = ($response.Content | ConvertFrom-Json).value
949+
$allProjects += $projects
950+
$continuationToken = $response.Headers['x-ms-continuationtoken']
951+
} while ($continuationToken)
952+
953+
Write-Host "Found $($allProjects.Count) projects"
954+
955+
# Check if SC already exists in each project; if not, create or share it
956+
foreach ($project in $allProjects) {
957+
$checkUri = "$OrgUrl/$($project.name)/_apis/serviceendpoint/endpoints?endpointNames=$ServiceConnectionName&api-version=7.2"
958+
$existing = (Invoke-RestMethod -Uri $checkUri -Headers $headers).value
959+
960+
if ($existing.Count -gt 0) {
961+
Write-Host " [$($project.name)] SC already exists — ensuring authorization..."
962+
# Authorize for all pipelines in this project
963+
$authBody = @{
964+
allPipelines = @{ authorized = $true }
965+
resource = @{ type = "endpoint"; id = $existing[0].id }
966+
} | ConvertTo-Json -Depth 5
967+
968+
Invoke-RestMethod -Uri "$OrgUrl/$($project.name)/_apis/pipelines/pipelinepermissions/endpoint/$($existing[0].id)?api-version=7.2-preview.1" `
969+
-Method PATCH -Headers $headers -Body $authBody -ContentType "application/json" | Out-Null
970+
Write-Host " [$($project.name)] ✅ Authorized"
971+
} else {
972+
Write-Host " [$($project.name)] Creating SC..."
973+
# Share the existing SC to this project by adding a project reference
974+
# (Requires the SC to exist in at least one project first)
975+
# Exact payload depends on ADO Service Connection type
976+
Write-Host " [$($project.name)] ⚠️ Manual share or REST creation needed"
977+
}
978+
}
979+
```
980+
981+
---
982+
983+
## 14. Why Microsoft Removed Build Identity Access — Security Analysis
984+
985+
### The Security Risk (Confirmed)
986+
987+
**Your intuition is correct.** Prior to Sprint 269, any pipeline running with `System.AccessToken` could read Advanced Security alerts because the Build Service identity had **implicit API access by default**. This created a real security risk:
988+
989+
```
990+
Risk Chain:
991+
Any pipeline task/extension (including marketplace)
992+
→ Accesses System.AccessToken (tasks get it automatically)
993+
→ Authenticates as Build Service identity
994+
→ Calls Advanced Security API (allowed by default pre-Sprint 269)
995+
→ Reads all security alerts for the project/repo
996+
→ Can exfiltrate vulnerability data
997+
```
998+
999+
### Key Facts from Microsoft Documentation
1000+
1001+
1. **Build Service had default access**: The `Project Collection Build Service` and project-scoped Build Service identities were allowed to call Advanced Security APIs by default — no explicit permission grant was needed.
1002+
1003+
2. **Tasks vs Scripts — important distinction**:
1004+
- **Scripts** (PowerShell, Bash inline): require explicit `env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)` mapping
1005+
- **Pipeline Tasks/Extensions**: can access the job access token as part of their normal operation via the Task SDK, without the user explicitly mapping it
1006+
1007+
3. **Shared identity problem**: The Build Service identity is **shared across ALL pipelines in a project** (or the entire collection if not restricted). There's no per-pipeline isolation. Any task in any pipeline gets the same identity.
1008+
1009+
4. **Microsoft's stated reasoning** (Sprint 269 release notes):
1010+
> *"This change prevents pipeline-based automation from accessing or modifying security alert data using build service accounts, reducing the risk of unintended alert state changes during CI/CD runs."*
1011+
1012+
5. **The fix**: Microsoft is requiring a **named service principal** with explicit `Advanced Security: Read alerts` permission, which provides:
1013+
- Explicit, auditable identity (not a shared build account)
1014+
- Can be scoped narrowly
1015+
- Uses Entra tokens (short-lived, conditional access)
1016+
- Per-pipeline access control via service connections
1017+
1018+
### Is a Simple Extension a Risk?
1019+
1020+
**Yes.** A marketplace extension (or any custom task) installed in the organization:
1021+
- Runs with the job's access token automatically (tasks access it via the Task SDK)
1022+
- Does NOT need "Allow scripts to access the OAuth token" — that setting only applies to script steps, not task steps
1023+
- Could call any API the Build Service identity has access to
1024+
- Before Sprint 269, that included Advanced Security APIs
1025+
1026+
**This is precisely why Microsoft made the Sprint 269 change** — to ensure that only explicitly authorized service principals (with audit trails and conditional access) can read security alert data, not any pipeline task running under the broadly-shared Build Service identity.
1027+
1028+
---
1029+
1030+
## 15. References
7091031

7101032
| Resource | URL |
7111033
|----------|-----|
@@ -719,6 +1041,12 @@ foreach ($project in $projects) {
7191041
| Entra Tokens via Azure CLI | https://learn.microsoft.com/en-us/azure/devops/cli/entra-tokens |
7201042
| No New Azure DevOps OAuth Apps (April 2025) | https://devblogs.microsoft.com/devops/no-new-azure-devops-oauth-apps/ |
7211043
| Configure Advanced Security Status Checks | https://learn.microsoft.com/en-us/azure/devops/repos/security/configure-github-advanced-security-features |
1044+
| Author a Pipeline Decorator | https://learn.microsoft.com/en-us/azure/devops/extend/develop/add-pipeline-decorator |
1045+
| Pipeline Decorator Context | https://learn.microsoft.com/en-us/azure/devops/extend/develop/pipeline-decorator-context |
1046+
| Advanced Security Permissions | https://learn.microsoft.com/en-us/azure/devops/repos/security/github-advanced-security-permissions |
1047+
| Sprint 269 Release Notes (Build Identity Restriction) | https://learn.microsoft.com/en-us/azure/devops/release-notes/2026/ghazdo/sprint-269-update |
1048+
| Sprint 271 Release Notes (Status Checks) | https://learn.microsoft.com/en-us/azure/devops/release-notes/2026/ghazdo/sprint-271-update |
1049+
| Pipeline Security Best Practices | https://learn.microsoft.com/en-us/azure/devops/pipelines/security/misc |
7221050

7231051
---
7241052

0 commit comments

Comments
 (0)