From dc9804064d85e7dca97741e9b062f0c8650b1bde Mon Sep 17 00:00:00 2001 From: jamesadevine Date: Fri, 12 Jun 2026 11:40:56 +0100 Subject: [PATCH] docs: add safe-output permissions reference and TF401027 troubleshooting guide Adds a dedicated reference for the most common Stage 3 runtime failure: 401/403 on safe outputs because $(System.AccessToken) resolves to a build-service identity (PCBS or per-project Build Service) that lacks PullRequestContribute / GenericContribute / CreateBranch on the target repo, sometimes via an explicit Deny. - New repo reference: docs/safe-output-permissions.md (linked from AGENTS.md docs index) - New site page: site/src/content/docs/troubleshooting/safe-output-permissions.mdx - common-issues.mdx: cross-link the new dedicated page - service-connections.mdx: add same-project TF401027 row to the troubleshooting table - debug-ado-agentic-workflow.md prompt: Stage 3 "Write Token Issues" rewritten with the TF401027 decoder table, Build\ -> identity mapping, the explicit-Deny-wins note, and a link to the reference Covers: TF401027 error format, default build identity mapping, "Limit job authorization scope to current project" toggle and its cross-project caveat, Git Repositories permission bitmask decoder, which safe-output tool needs which bit, az + REST recipe to dump the ACL for any identity on any repo, and the three fix paths in recommended order (write service connection / flip the auth-scope toggle / lift the Deny). Site builds clean with starlight-links-validator (35 pages, including the new /troubleshooting/safe-output-permissions/). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 + docs/safe-output-permissions.md | 324 ++++++++++++++++++ prompts/debug-ado-agentic-workflow.md | 19 +- .../docs/setup/service-connections.mdx | 1 + .../docs/troubleshooting/common-issues.mdx | 6 + .../safe-output-permissions.mdx | 259 ++++++++++++++ 6 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 docs/safe-output-permissions.md create mode 100644 site/src/content/docs/troubleshooting/safe-output-permissions.mdx diff --git a/AGENTS.md b/AGENTS.md index 2a726acc..37cb5e20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,6 +250,12 @@ index to jump to the right page. - [`docs/safe-outputs.md`](docs/safe-outputs.md) — full reference for every safe-output tool agents can use to propose actions (PRs, work items, wiki pages, comments, etc.) plus their per-agent configuration. +- [`docs/safe-output-permissions.md`](docs/safe-output-permissions.md) — + diagnosis and fix reference for Stage 3 401/403 failures: the + default build identity (PCBS vs project-scoped Build Service), + `$(System.AccessToken)` semantics, the "Limit job authorization + scope to current project" toggle, permission-bitmask decoder, + REST recipe for inspecting ACEs, and the three fix paths. - [`docs/ado-aw-debug.md`](docs/ado-aw-debug.md) — debug-only `ado-aw-debug:` front-matter section (`skip-integrity`, `create-issue` for filing GitHub issues from dogfood pipelines). NOT a regular safe-output. diff --git a/docs/safe-output-permissions.md b/docs/safe-output-permissions.md new file mode 100644 index 00000000..f6ef4db9 --- /dev/null +++ b/docs/safe-output-permissions.md @@ -0,0 +1,324 @@ +# Safe-output permissions & the default build identity + +_Part of the [ado-aw documentation](../AGENTS.md)._ + +This page is the reference for diagnosing 401/403 failures from the +Stage 3 SafeOutputs executor — the most common runtime failure class +for ado-aw pipelines once compilation succeeds. + +It covers: + +- Which Azure DevOps identity Stage 3 actually runs as +- How the **"Limit job authorization scope to current project"** + toggle changes that identity +- How to read the exact ADO error (`TF401027`) and decode the + permission bitmask +- A REST recipe for inspecting the relevant ACEs from the command line +- The three fix paths, in order of how on-convention they are + +For the broader Stage 3 catalogue (PR / work item / wiki errors), see +[`docs/safe-outputs.md`](safe-outputs.md). For the service-connection +model, see the `permissions:` section of +[`docs/network.md`](network.md). + +--- + +## TL;DR + +When a Stage 3 safe output fails with HTTP 403 and the body contains: + +```text +TF401027: You need the Git 'PullRequestContribute' permission to +perform this action. Details: identity 'Build\', scope 'repository'. +``` + +…the `$(System.AccessToken)` your pipeline is using does not have +that permission on the target repository. The build identity is **not +the user who triggered the run** — it is one of two service accounts +ADO mints on your behalf, and `` tells you which one. Concrete +fix paths are below in [Fix options](#fix-options). + +--- + +## What identity Stage 3 runs as + +By default the Stage 3 executor uses `$(System.AccessToken)` — the +short-lived OAuth token Azure DevOps mints for every pipeline run. +Which identity that token represents depends on a single setting: +**"Limit job authorization scope to current project for non-release +pipelines."** + +| Toggle | Identity behind `$(System.AccessToken)` | Display name | Descriptor shape | +|---|---|---|---| +| **OFF** (default) | Collection-scoped build service | `Project Collection Build Service ()` | `Microsoft.TeamFoundation.ServiceIdentity;:Build:` | +| **ON** | Project-scoped build service | ` Build Service ()` | `Microsoft.TeamFoundation.ServiceIdentity;:Build:` | + +The `` printed inside `Build\` in the error message is +exactly what lets you tell them apart: if it matches your project's +ID, it's the project-scoped identity; otherwise it's the +collection-scoped one. + +The toggle lives in three places (most-specific wins): + +- **Per-pipeline** — Pipeline → Edit → "…" → Triggers → "Limit job + authorization scope to current project". +- **Project-level** — Project Settings → Pipelines → Settings. +- **Organization-level** — Organization Settings → Pipelines → + Settings. + +> The collection-scoped identity (toggle OFF) can reach resources in +> other projects in the same organization but is more privileged and +> therefore more often subject to explicit Deny ACEs. The +> project-scoped identity (toggle ON) is restricted to its own +> project but is usually already a member of `[Project]\Contributors`, +> which carries `PullRequestContribute` by default. + +If `permissions.write:` is set in the agent's front matter, Stage 3 +uses the **ARM service connection's identity** instead, and none of +the above applies — see [Option 1](#option-1-wire-a-write-service-connection-recommended). + +--- + +## Decoding the failure + +### The error format + +```text +TF401027: You need the Git '' permission to perform +this action. Details: identity 'Build\', scope ''. +``` + +| Field | Meaning | +|---|---| +| `` | The exact permission bit ADO denied. Map to a bit value using the table below. | +| `Build\` | The build-service identity Stage 3 ran as. Match against your project ID to identify which one. | +| `` | `repository` (per-repo ACE), `project` (all repos), or `branch` (`refs/heads/`). | + +### Git Repositories permission bits + +These are the bits that appear under the "Git Repositories" security +namespace in ADO (namespace ID +`2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87`). Bitwise OR the values to +decode an `allow` / `deny` mask: + +| Bit | Name | Display name | +|---:|---|---| +| 1 | `Administer` | Administer | +| 2 | `GenericRead` | Read | +| 4 | `GenericContribute` | Contribute | +| 8 | `ForcePush` | Force push (rewrite history, delete branches and tags) | +| 16 | `CreateBranch` | Create branch | +| 32 | `CreateTag` | Create tag | +| 64 | `ManageNote` | Manage notes | +| 128 | `PolicyExempt` | Bypass policies when pushing | +| 256 | `CreateRepository` | Create repository | +| 512 | `DeleteRepository` | Delete or disable repository | +| 1024 | `RenameRepository` | Rename repository | +| 2048 | `EditPolicies` | Edit policies | +| 4096 | `RemoveOthersLocks` | Remove others' locks | +| 8192 | `ManagePermissions` | Manage permissions | +| **16384** | **`PullRequestContribute`** | **Contribute to pull requests** | +| 32768 | `PullRequestBypassPolicy` | Bypass policies when completing pull requests | +| 65536 | `ViewAdvSecAlerts` | Advanced Security: view alerts | +| 131072 | `DismissAdvSecAlerts` | Advanced Security: manage and dismiss alerts | +| 262144 | `ManageAdvSecScanning` | Advanced Security: manage settings | +| 524288 | `ManageEnterpriseLiveMigrations` | Enterprise Live Migration: manage migrations | + +In ADO, **Deny always wins**: any bit present in `effectiveDeny` +overrides the same bit in `effectiveAllow`, even if the allow comes +from group membership. + +### Which Stage 3 tool needs which permission + +| Safe-output tool | Permission required (bit) | +|---|---| +| `add-pr-comment`, `submit-pr-review`, `reply-to-pr-comment`, `resolve-pr-thread`, `update-pr` | `PullRequestContribute` (16384) | +| `create-pull-request` | `PullRequestContribute` (16384) + `CreateBranch` (16) + `GenericContribute` (4) on the target repo | +| `create-branch` | `CreateBranch` (16) + `GenericContribute` (4) | +| `create-git-tag` | `CreateTag` (32) + `GenericContribute` (4) | +| `create-work-item`, `update-work-item`, `comment-on-work-item`, `link-work-items`, `upload-workitem-attachment` | Work Items namespace (`5a27515b-ccd7-42c9-84f1-54c998f03866`) — not Git Repositories | +| `create-wiki-page`, `update-wiki-page` | Project-level Wiki permissions — not Git Repositories | +| `queue-build` | Build namespace (`33344d9c-fc72-4d6f-aba5-fa317101a7e9`) — `QueueBuilds` (32) on the target definition | +| `add-build-tag`, `upload-build-attachment`, `upload-pipeline-artifact` | Current build only — never fail on perms | + +--- + +## REST recipe: inspect the ACEs + +You usually do not need to wait for another failed run to confirm +which identity has what. The following requires only an +`az`-authenticated session and a Bearer token for ADO (resource +`499b84ac-1321-427f-aa17-267ca6975798`). + +### 1. Resolve the build identity from the error message + +```text +identity 'Build\2670d706-90db-4242-acd8-5c1db9662bcb' +``` + +```bash +TOKEN=$(az account get-access-token \ + --resource 499b84ac-1321-427f-aa17-267ca6975798 \ + --query accessToken -o tsv) + +# Replace with the descriptor scope (org services host id; you +# can copy it from a known descriptor you've already retrieved for this org). +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://vssps.dev.azure.com//_apis/identities?descriptors=Microsoft.TeamFoundation.ServiceIdentity;:Build:2670d706-90db-4242-acd8-5c1db9662bcb&api-version=7.1" \ + | jq '.value[] | {customDisplayName, id, descriptor}' +``` + +`customDisplayName` will be either `Project Collection Build Service +()` or ` Build Service ()`. + +### 2. Pull the per-repo ACE for that identity + +```bash +NS=2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 # Git Repositories +PROJ= +REPO= +DESC='Microsoft.TeamFoundation.ServiceIdentity;:Build:' + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://dev.azure.com//_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \ + | jq '.value[].acesDictionary' +``` + +You will get back something like: + +```json +{ + "Microsoft.TeamFoundation.ServiceIdentity;…:Build:…": { + "allow": 0, + "deny": 16404, + "extendedInfo": { + "inheritedAllow": 196608, + "effectiveAllow": 196608, + "effectiveDeny": 16404 + } + } +} +``` + +Decode `effectiveDeny` against the bit table above: +`16404 = 16384 + 16 + 4 = PullRequestContribute | CreateBranch | +GenericContribute`. That is an **explicit Deny on this repo** — no +group-level Allow can win against it. + +### 3. (Optional) Check the project-scoped identity + +If the failing identity is the collection-scoped one, also pull the +ACE for the project-scoped identity. If `effectiveDeny == 0` and +`effectiveAllow` includes `PullRequestContribute` (16384) there, the +fastest fix is [Option 2](#option-2-flip-the-pipeline-to-the-project-scoped-build-service) +— flip the auth-scope toggle and the next run will just work. + +```bash +PROJ_DESC="Microsoft.TeamFoundation.ServiceIdentity;:Build:${PROJ}" +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://dev.azure.com//_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${PROJ_DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \ + | jq '.value[].acesDictionary' +``` + +--- + +## Fix options + +In order of how on-convention they are for the ado-aw three-stage +trust model. Pick exactly one — they are alternatives, not +complementary. + +### Option 1: Wire a write service connection (recommended) + +Add an ARM service connection whose backing identity has the +permission you need on the target repository, and reference it from +the agent front matter: + +```yaml +permissions: + read: ado-aw-read # optional, used by Stage 1 + write: ado-aw-write # used by Stage 3 +``` + +Stage 3 will mint its token via that connection instead of using +`$(System.AccessToken)`, so the build-service ACEs become irrelevant. + +This is the most explicit option: the identity used for writes is +named in the front matter, audit logs attribute every action to that +named principal, and the least-privilege grant lives entirely on the +service connection's identity. It also works unchanged for +cross-organization writes. + +See [`docs/network.md`](network.md) (Permissions section) and the +"Service Connections" page on the documentation site for the full +setup steps. + +### Option 2: Flip the pipeline to the project-scoped build service + +If you do not want a dedicated write service connection and the +**project-scoped** Build Service already has `PullRequestContribute` +on the target repo (verify with [Step 3](#3-optional-check-the-project-scoped-identity) +above), the lowest-effort fix is to switch +`$(System.AccessToken)` from the collection-scoped to the +project-scoped identity: + +- **Per-pipeline (preferred)** — Pipeline → Edit → "…" → Triggers → + enable "Limit job authorization scope to current project". +- **Project-level** — Project Settings → Pipelines → Settings → + enable for all new pipelines in the project. +- **Organization-level** — Organization Settings → Pipelines → + Settings → enable for all new pipelines org-wide. + +> **Cross-project caveat.** With this toggle ON, the token cannot +> reach resources outside the project — `resources.repositories` +> pointing at sibling-project repos, `DownloadPipelineArtifact@2` +> with a `project:` parameter naming another project, secure files +> homed in another project, and template `extends:` from cross-project +> repos all stop working. Anything outside the organization +> entirely (other ADO orgs, GitHub, external registries) is not +> affected — those use their own credentials. + +The per-pipeline toggle is the lowest-blast-radius choice: it does +not affect any other pipeline in the project. + +### Option 3: Lift the explicit Deny on the collection-scoped identity + +Only if you need this pipeline to keep using +`$(System.AccessToken)` *and* you cannot enable +[Option 2](#option-2-flip-the-pipeline-to-the-project-scoped-build-service): + +1. Project Settings → Repositories → the affected repo → Security. +2. Select `Project Collection Build Service ()`. +3. Reset the denied permissions (e.g. `Contribute to pull requests`, + `Contribute`, `Create branch`) from `Deny` to `Not set` or + `Allow`. + +This is rarely the right answer in repos that have a deliberate +Deny in place — the Deny is usually there to keep every pipeline in +the collection from being able to write to one sensitive repo. By +lifting it you re-enable that capability for *every* pipeline in +the entire organization that targets this repo. Use Option 1 or +Option 2 unless you have a specific reason to broaden the grant. + +--- + +## Common 401/403 signatures + +| HTTP status | Body fragment | Most likely cause | +|---|---|---| +| 401 Unauthorized | `TF400813: The user '...' is not authorized to access this resource` | Token is malformed or missing — usually a misconfigured service-connection step; check that the AzureCLI@2 mint succeeded. | +| 403 Forbidden | `TF401027: You need the Git 'PullRequestContribute' permission` | This page — Stage 3 identity lacks PR-contribute on the target repo. | +| 403 Forbidden | `TF401027: You need the Git 'GenericContribute' permission` | Same diagnosis; need `Contribute` on the repo (typically because of `create-pull-request` or `create-branch`). | +| 403 Forbidden | `VS800075: The project ... does not exist, or you do not have permission to access it.` | Cross-project request blocked because "Limit job authorization scope to current project" is ON. Use Option 1 with a write service connection that has cross-project rights, or move the resource into the calling project. | +| 403 Forbidden | `TF401019: The Git repository ... is disabled` | Repo disabled by an admin — not a permissions issue; re-enable in Project Settings → Repositories. | +| 404 Not Found | (no body) on a PR or work-item URL | The identity lacks `Read` on the resource — ADO returns 404 instead of 403 for non-readable resources to avoid leaking existence. Grant `Read` on the repo / area path. | + +--- + +## See also + +- [`docs/safe-outputs.md`](safe-outputs.md) — full Stage 3 tool reference +- [`docs/network.md`](network.md) — `permissions:` and the service-connection model +- [`docs/audit.md`](audit.md) — `ado-aw audit` extracts every Stage 3 execution outcome under `safe_output_execution` +- Microsoft Learn: [Job authorization scope](https://learn.microsoft.com/azure/devops/pipelines/process/access-tokens) +- Microsoft Learn: [Default permissions and access for Azure DevOps](https://learn.microsoft.com/azure/devops/organizations/security/permissions) diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index b538a6ea..5c426bca 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -361,17 +361,34 @@ This job executes the approved safe outputs using the write token. Failures here **Symptoms**: API calls return 401/403. The executor can't authenticate to Azure DevOps. +**Decode the error first.** The body of an ADO 403 usually contains a structured `TF401027` message: + +```text +TF401027: You need the Git '' permission to perform +this action. Details: identity 'Build\', scope ''. +``` + +| Field | What it tells you | +|---|---| +| `` | The exact permission ADO denied. `PullRequestContribute` covers `add-pr-comment` / `submit-pr-review` / `reply-to-pr-comment` / `resolve-pr-thread` / `update-pr`. `GenericContribute` + `CreateBranch` + `PullRequestContribute` are what `create-pull-request` needs. `CreateBranch` alone is what `create-branch` needs. `CreateTag` is what `create-git-tag` needs. | +| `Build\` | The Stage 3 identity. If the guid matches the **project ID**, the pipeline is running as the project-scoped ` Build Service ()`. If it does not, it is the org-wide `Project Collection Build Service ()` and "Limit job authorization scope to current project" is OFF. | +| `` | `repository` = per-repo ACE, `project` = project-wide, `branch` = `refs/heads/`. | + **Common causes**: -- **`permissions.write` not set**: The front matter is missing the write ARM service connection: +- **No `permissions.write:` set, and the default build identity lacks the permission on the target repo.** The Stage 3 executor uses `$(System.AccessToken)` by default; the identity behind that token (PCBS or per-project Build Service) needs the right permission bit on the repo. An **explicit Deny** at the repo ACE on the failing identity will beat any group-level Allow. This is the most common Stage 3 failure mode; see [`docs/safe-output-permissions.md`](../docs/safe-output-permissions.md) for the full diagnosis flow, including a REST recipe for dumping the ACL and a decoder for the permission bitmask. +- **`permissions.write` not set when the ADO admin has hardened the default build identity:** ```yaml permissions: write: my-write-arm-connection ``` - **ARM service connection not authorized**: The pipeline needs explicit authorization for the service connection. Go to the pipeline's settings in ADO and authorize the service connection. - **Token scope insufficient**: The ARM service connection may not have the required permissions on the ADO project. Verify the connection's role assignments. +- **Cross-project failure (`VS800075`)**: The pipeline is trying to act on a resource in a different project than where it runs and "Limit job authorization scope to current project" is ON. Either turn the toggle off (broader scope) or use a write service connection whose identity has explicit rights in the target project. - **Compile-time validation**: The compiler should catch missing `permissions.write` when write-requiring safe outputs are configured. If you're seeing this at runtime, the front matter may have been edited without recompiling. +**Diagnosis hint when reporting**: include the full `TF401027` line (with `` and the `Build\` value), the failing safe-output `name`, the target repo / PR / work item id, and — if you have it — whether the build identity has an explicit Deny vs missing Allow on the target. The [`safe-output-permissions.md`](../docs/safe-output-permissions.md) reference page has the REST recipe to pull this in one curl. + ### PR Creation Failures **Symptoms**: `create-pull-request` safe output fails during execution. diff --git a/site/src/content/docs/setup/service-connections.mdx b/site/src/content/docs/setup/service-connections.mdx index 136215db..375105d5 100644 --- a/site/src/content/docs/setup/service-connections.mdx +++ b/site/src/content/docs/setup/service-connections.mdx @@ -179,4 +179,5 @@ If this pipeline succeeds, your connection is correctly configured for `ado-aw`. | `AzureCLI@2` fails with "service connection not found" | The pipeline isn't authorized to use the connection — check pipeline permissions in the connection's Security tab | | Token mints but safe outputs return 401/403 | The service principal doesn't have sufficient ADO permissions — verify its group membership in ADO Organization Settings → Users | | "AADSTS700024: Client assertion is not within its valid time range" | Federated credential issuer/subject mismatch — regenerate in the App Registration | +| Same-project safe-output writes return 403 with `TF401027: ... 'PullRequestContribute'` | You're on the default `$(System.AccessToken)` and the underlying build identity lacks PR-contribute (or has an explicit Deny) on the target repo. See the dedicated [Safe-output 401/403 errors](/ado-aw/troubleshooting/safe-output-permissions/) page for the diagnosis flow and three fix paths. | | Cross-project safe-output writes fail with 403 even though default executor token usually works | The pipeline setting "Limit job authorization scope to current project" is restricting `$(System.AccessToken)`. Either disable it (broader scope) or set `permissions.write` to an ARM SC with explicit cross-project rights | diff --git a/site/src/content/docs/troubleshooting/common-issues.mdx b/site/src/content/docs/troubleshooting/common-issues.mdx index ac0b51f7..8d9409f5 100644 --- a/site/src/content/docs/troubleshooting/common-issues.mdx +++ b/site/src/content/docs/troubleshooting/common-issues.mdx @@ -140,6 +140,12 @@ Stage 2 (Detection) may reject safe outputs that appear to contain: **Solution:** Review the agent's output proposals. Ensure generated content doesn't accidentally include patterns that resemble secrets (long base64 strings, key-value patterns matching `password=...`). +### Stage 3 SafeOutputs fails with HTTP 403 / `TF401027` + +The Stage 3 executor uses `$(System.AccessToken)` by default, which represents one of two ADO-minted build-service identities (collection-scoped or project-scoped) depending on the **"Limit job authorization scope to current project"** setting. If that identity lacks `PullRequestContribute` / `GenericContribute` / `CreateBranch` (or, worse, has an *explicit Deny*) on the target repo, every safe output that touches PRs, branches, or pushes fails with `TF401027`. + +See the dedicated [Safe-output 401/403 errors](/ado-aw/troubleshooting/safe-output-permissions/) page for the full diagnosis flow, a permission bitmask decoder, a REST recipe for inspecting the ACEs, and the three fix paths (write service connection / flip the auth-scope toggle / lift the Deny). + ## Build & Development ### `cargo build` fails with missing dependencies diff --git a/site/src/content/docs/troubleshooting/safe-output-permissions.mdx b/site/src/content/docs/troubleshooting/safe-output-permissions.mdx new file mode 100644 index 00000000..a242b031 --- /dev/null +++ b/site/src/content/docs/troubleshooting/safe-output-permissions.mdx @@ -0,0 +1,259 @@ +--- +title: Safe-output 401/403 errors +description: Diagnose and fix Stage 3 SafeOutputs permission failures (TF401027, PullRequestContribute, build identity, job authorization scope) +--- + +The single most common runtime failure for an otherwise-working +`ado-aw` pipeline is a 401/403 from the **Stage 3 SafeOutputs** +executor when it tries to post a PR comment, open a PR, file a work +item, etc. This page covers diagnosis and the three fix paths in +order of how on-convention they are. + +## Failure signature + +Stage `SafeOutputs` is the failing job. The executor log (or +`ado-aw audit ` → `safe_output_execution`) contains an +HTTP 403 with a body that looks like this: + +```text +TF401027: You need the Git 'PullRequestContribute' permission to +perform this action. Details: identity 'Build\', scope 'repository'. +``` + +Two things to read out of that line: + +1. **`PullRequestContribute`** — the exact permission bit ADO + denied. The full set of permission bits and which Stage 3 tool + needs which is below. +2. **`Build\`** — the *identity* the Stage 3 executor was + running as. It is not the user who triggered the build; it is one + of two ADO-minted build-service accounts. + +## Which identity Stage 3 runs as + +The Stage 3 executor uses `$(System.AccessToken)` by default — the +short-lived OAuth token that Azure DevOps mints for every run. Which +identity that token represents depends on a single setting: **"Limit +job authorization scope to current project for non-release +pipelines."** + +| Toggle | Identity | Display name | Guid in `Build\` | +|---|---|---|---| +| **OFF** (default) | Collection-scoped build service | `Project Collection Build Service ()` | An ADO-internal random GUID | +| **ON** | Project-scoped build service | ` Build Service ()` | Your **project ID** | + +If the GUID in the error message matches your project's ID, Stage 3 +is already running as the project-scoped identity. If it does not, +the toggle is OFF and Stage 3 is running as the collection-scoped +one. + +The toggle exists in three places (most specific wins): + +- **Per-pipeline** — Pipeline → Edit → "…" → Triggers → "Limit job + authorization scope to current project". +- **Project-level** — Project Settings → Pipelines → Settings. +- **Organization-level** — Organization Settings → Pipelines → + Settings. + +If `permissions.write:` is set in the agent's front matter, Stage 3 +uses the **ARM service connection's identity** instead, and none of +the above applies — see [Option 1](#option-1-wire-a-write-service-connection-recommended). + +## Decoding the permission bitmask + +When you inspect the repo's ACL directly (recipe below), ADO returns +bitmask integers for `allow` / `deny` / `effectiveAllow` / +`effectiveDeny`. Decode by bitwise-ORing the values from this table +(Git Repositories namespace +`2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87`): + +| Bit | Name | Display name | +|---:|---|---| +| 1 | `Administer` | Administer | +| 2 | `GenericRead` | Read | +| 4 | `GenericContribute` | Contribute | +| 8 | `ForcePush` | Force push | +| 16 | `CreateBranch` | Create branch | +| 32 | `CreateTag` | Create tag | +| 64 | `ManageNote` | Manage notes | +| 128 | `PolicyExempt` | Bypass policies when pushing | +| 256 | `CreateRepository` | Create repository | +| 512 | `DeleteRepository` | Delete or disable repository | +| 1024 | `RenameRepository` | Rename repository | +| 2048 | `EditPolicies` | Edit policies | +| 4096 | `RemoveOthersLocks` | Remove others' locks | +| 8192 | `ManagePermissions` | Manage permissions | +| **16384** | **`PullRequestContribute`** | **Contribute to pull requests** | +| 32768 | `PullRequestBypassPolicy` | Bypass completion policies | +| 65536 | `ViewAdvSecAlerts` | AdvSec: view alerts | +| 131072 | `DismissAdvSecAlerts` | AdvSec: manage/dismiss alerts | +| 262144 | `ManageAdvSecScanning` | AdvSec: manage settings | +| 524288 | `ManageEnterpriseLiveMigrations` | Enterprise Live Migration | + +:::caution[Deny always wins] +Any bit present in `effectiveDeny` overrides the same bit in +`effectiveAllow`, regardless of group membership. If a repo has an +explicit Deny on the build identity for a permission, granting that +identity Allow via a group will not unblock it. You must either +remove the Deny, switch to a different identity, or use a service +connection. +::: + +### Which Stage 3 tool needs which permission + +| Safe-output tool | Permission required (bit) | +|---|---| +| `add-pr-comment`, `submit-pr-review`, `reply-to-pr-comment`, `resolve-pr-thread`, `update-pr` | `PullRequestContribute` (16384) | +| `create-pull-request` | `PullRequestContribute` (16384) + `CreateBranch` (16) + `GenericContribute` (4) | +| `create-branch` | `CreateBranch` (16) + `GenericContribute` (4) | +| `create-git-tag` | `CreateTag` (32) + `GenericContribute` (4) | +| `create-work-item`, `update-work-item`, `comment-on-work-item`, `link-work-items`, `upload-workitem-attachment` | Work Items namespace — not Git Repositories | +| `create-wiki-page`, `update-wiki-page` | Project-level Wiki permissions | +| `queue-build` | Build namespace, `QueueBuilds` (32) on the target definition | +| `add-build-tag`, `upload-build-attachment`, `upload-pipeline-artifact` | Current build only — never fail on perms | + +## Inspect the ACEs from the command line + +You do not need to wait for another failed run to confirm which +identity has what. Authenticate with `az` and call the ADO +[Security namespace REST API](https://learn.microsoft.com/rest/api/azure/devops/security/access-control-lists/query) +directly. + +```bash +TOKEN=$(az account get-access-token \ + --resource 499b84ac-1321-427f-aa17-267ca6975798 \ + --query accessToken -o tsv) + +NS=2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 # Git Repositories +ORG= +PROJ= +REPO= +DESC='Microsoft.TeamFoundation.ServiceIdentity;:Build:' + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://dev.azure.com/${ORG}/_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \ + | jq '.value[].acesDictionary' +``` + +A response like: + +```json +{ + "Microsoft.TeamFoundation.ServiceIdentity;…:Build:…": { + "allow": 0, + "deny": 16404, + "extendedInfo": { + "inheritedAllow": 196608, + "effectiveAllow": 196608, + "effectiveDeny": 16404 + } + } +} +``` + +…decodes to `effectiveDeny = 16404 = 16384 + 16 + 4 = +PullRequestContribute | CreateBranch | GenericContribute` — an +explicit Deny on this repo. The `inheritedAllow` of 196608 only +covers `ViewAdvSecAlerts | DismissAdvSecAlerts`, which is irrelevant +to safe outputs. + +### Before flipping the auth-scope toggle, check the project-scoped identity + +If the failing identity is the collection-scoped one (toggle OFF), +also pull the ACE for the project-scoped one — its build GUID is +your project ID: + +```bash +PROJ_DESC="Microsoft.TeamFoundation.ServiceIdentity;:Build:${PROJ}" +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://dev.azure.com/${ORG}/_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${PROJ_DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \ + | jq '.value[].acesDictionary' +``` + +If `effectiveDeny == 0` and `effectiveAllow` includes +`PullRequestContribute` (16384) for that identity, the fastest fix +is [Option 2](#option-2-flip-the-pipeline-to-the-project-scoped-build-service). + +## Fix options + +Pick exactly one — they are alternatives. + +### Option 1: Wire a write service connection (recommended) + +Add an ARM service connection whose backing identity has the +required permission on the target repository, and reference it in +the agent's front matter: + +```yaml +permissions: + read: ado-aw-read # optional, used by Stage 1 + write: ado-aw-write # used by Stage 3 +``` + +Stage 3 mints its token via the connection instead of using +`$(System.AccessToken)`, so the build-service ACEs become +irrelevant. Audit logs attribute every write to the named service +principal; the least-privilege grant lives entirely on that +principal. This is the only option that works for **cross-org** +writes. + +See [Service Connections](/ado-aw/setup/service-connections/) for +the full setup steps. + +### Option 2: Flip the pipeline to the project-scoped build service + +If the project-scoped Build Service already has the right +permissions on the repo (verify with the recipe above), enable +"Limit job authorization scope to current project" — start +per-pipeline for the lowest blast radius: + +- **Per-pipeline** — Pipeline → Edit → "…" → Triggers → "Limit job + authorization scope to current project". +- **Project-level** — Project Settings → Pipelines → Settings. +- **Organization-level** — Organization Settings → Pipelines → + Settings. + +:::caution[Cross-project caveat] +With this toggle ON, `$(System.AccessToken)` cannot reach resources +in **other projects in the same organization**: +`resources.repositories` pointing at sibling-project repos, +`DownloadPipelineArtifact@2` with a `project:` parameter naming +another project, secure files homed in another project, and +template `extends:` from cross-project repos all stop working. +Resources outside the org entirely (other ADO orgs, GitHub, +external registries) are not affected — those use their own +credentials. +::: + +### Option 3: Lift the explicit Deny on the collection-scoped identity + +Only use this when you cannot use Options 1 or 2: + +1. Project Settings → Repositories → the affected repo → Security. +2. Select `Project Collection Build Service ()`. +3. Reset the denied permission(s) (`Contribute to pull requests`, + `Contribute`, `Create branch`, …) from `Deny` to `Not set` or + `Allow`. + +This re-enables write capability for **every pipeline in the +organization** that targets this repo. The Deny is usually +deliberate; broadening it should be a conscious decision. + +## Common 401/403 signatures + +| HTTP | Body fragment | Most likely cause | +|---|---|---| +| 401 | `TF400813: ... is not authorized` | Token mint failed (check the AzureCLI@2 step), or token is malformed | +| 403 | `TF401027: ... 'PullRequestContribute'` | This page | +| 403 | `TF401027: ... 'GenericContribute'` | Same; need `Contribute` (e.g. `create-pull-request` / `create-branch`) | +| 403 | `VS800075: The project ... does not exist` | Cross-project request blocked because the auth-scope toggle is ON. Use Option 1 with a cross-project-capable service connection | +| 403 | `TF401019: ... repository is disabled` | Not a permissions issue — re-enable the repo | +| 404 | (empty body on a PR / work-item URL) | Identity lacks `Read` — ADO returns 404 not 403 for non-readable resources | + +## See also + +- [Service Connections setup](/ado-aw/setup/service-connections/) — full steps for creating the write service connection used in Option 1 +- [Safe outputs reference](/ado-aw/reference/safe-outputs/) — the catalogue of Stage 3 tools and their per-tool configuration +- [Audit](/ado-aw/reference/audit/) — `ado-aw audit --json` exposes per-item Stage 3 execution outcomes under `safe_output_execution` +- Microsoft Learn: [Job authorization scope](https://learn.microsoft.com/azure/devops/pipelines/process/access-tokens) +- Microsoft Learn: [Default permissions and access](https://learn.microsoft.com/azure/devops/organizations/security/permissions)