Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .apm/agents/auth-expert.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ When reviewing or writing auth code:
- Windows: `GIT_ASKPASS` must be `'echo'` not empty string
- Classic PATs (`ghp_`) work cross-org but are being deprecated — prefer fine-grained
- ADO uses Basic auth with base64-encoded `:PAT` — different from GitHub bearer token flow
- ADO also supports AAD bearer tokens via `az account get-access-token` (resource `499b84ac-1321-427f-aa17-267ca6975798`); precedence is `ADO_APM_PAT` -> az bearer -> fail. Stale PATs (401) silently fall back to the bearer with a `[!]` warning. See the auth skill for the four diagnostic cases.
31 changes: 31 additions & 0 deletions .apm/skills/auth/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,34 @@ All auth flows MUST go through `AuthResolver`. No direct `os.getenv()` for token
## Canonical reference

The full per-org -> global -> credential-fill -> fallback resolution flow is in [`docs/src/content/docs/getting-started/authentication.md`](../../../docs/src/content/docs/getting-started/authentication.md) (mermaid flowchart). Treat it as the single source of truth; if behavior diverges, fix the diagram in the same PR.

## Bearer-token authentication for ADO

ADO hosts (`dev.azure.com`, `*.visualstudio.com`) resolve auth in this order:

1. `ADO_APM_PAT` env var if set
2. AAD bearer via `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798` if `az` is installed and `az account show` succeeds
3. Otherwise: auth-failed error from `build_error_context`

Token source constants live in `src/apm_cli/core/token_manager.py`: `ADO_APM_PAT = "ADO_APM_PAT"`, `ADO_BEARER_SOURCE = "AAD_BEARER_AZ_CLI"`.

**Stale-PAT silent fallback:** if `ADO_APM_PAT` is rejected with HTTP 401, APM retries with the az bearer and emits:

```
[!] ADO_APM_PAT was rejected for {host} (HTTP 401); fell back to az cli bearer.
[!] Consider unsetting the stale variable.
```

**Verbose source line** (one per host, emitted under `--verbose`):

```
[i] dev.azure.com -- using bearer from az cli (source: AAD_BEARER_AZ_CLI)
[i] dev.azure.com -- token from ADO_APM_PAT
```

**Diagnostic cases** (`_emit_stale_pat_diagnostic` + `build_error_context` in `src/apm_cli/core/auth.py`):

1. No PAT, no `az`: `No ADO_APM_PAT was set and az CLI is not installed.` -> install `az`, run `az login --tenant <tenant>`, or set `ADO_APM_PAT`.
2. No PAT, `az` not signed in: `az CLI is installed but no active session was found.` -> run `az login --tenant <tenant>` against the tenant that owns the org, or set `ADO_APM_PAT`.
3. No PAT, wrong tenant: `az CLI returned a token but the org does not accept it (likely a tenant mismatch).` -> run `az login --tenant <correct-tenant>`, or set `ADO_APM_PAT`.
4. PAT 401, no `az` fallback: `ADO_APM_PAT was rejected (HTTP 401) and no az cli fallback was available.` -> rotate the PAT, or install `az` and run `az login --tenant <tenant>`.
74 changes: 74 additions & 0 deletions .github/workflows/auth-acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ on:
git_url_public_repo:
description: 'Public repo for git: URL object format (owner/repo, optional)'
required: false
ado_bearer:
description: 'Run ADO AAD bearer-token tests (requires AZURE_* WIF secrets)'
type: boolean
default: false
ado_bearer_repo:
description: 'ADO repo for bearer tests (dev.azure.com/org/project/_git/repo)'
required: false

env:
PYTHON_VERSION: '3.12'
Expand Down Expand Up @@ -88,3 +95,70 @@ jobs:
else
./scripts/test-auth-acceptance.sh
fi

# ADO AAD bearer-token tests (#852).
#
# This job exercises the bearer code path against a real ADO repo using a
# short-lived AAD token acquired via `az` from a Workload Identity
# Federation (WIF) service connection. To enable:
#
# 1. Provision a WIF federated credential in your Entra tenant that
# trusts this repo's GitHub Actions OIDC issuer.
# 2. Grant the resulting service principal "Reader" (or higher)
# access to the test ADO org/repo.
# 3. Configure these secrets in the `auth-acceptance` environment:
# AZURE_CLIENT_ID -- WIF app/service-principal client id
# AZURE_TENANT_ID -- Entra tenant id that owns the ADO org
# AZURE_SUBSCRIPTION_ID -- any subscription the SP can read
# 4. Trigger the workflow with `ado_bearer: true` and a value for
# `ado_bearer_repo`.
#
# Until WIF is provisioned this job is gated to manual runs only and
# will be skipped on the default trigger.
ado-bearer-tests:
name: ADO AAD Bearer Tests (Linux)
runs-on: ubuntu-latest
environment: auth-acceptance
if: ${{ inputs.ado_bearer == true }}
permissions:
id-token: write # required for azure/login@v2 OIDC federation
contents: read

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Install dependencies
run: uv sync --extra dev

- name: Install APM in dev mode
run: uv run pip install -e .

- name: Azure login (Workload Identity Federation)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Verify az session has ADO bearer access
run: |
az account show
az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query expiresOn -o tsv

- name: Run ADO bearer integration tests (PAT unset)
env:
APM_TEST_ADO_BEARER: '1'
APM_TEST_ADO_REPO: ${{ inputs.ado_bearer_repo }}
# Deliberately do NOT set ADO_APM_PAT for the bearer-only test.
Comment on lines +158 to +162
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job sets APM_TEST_ADO_REPO from inputs.ado_bearer_repo, but tests/integration/test_ado_bearer_e2e.py currently ignores that env var and uses a hard-coded repo instead. Either update the test to consume APM_TEST_ADO_REPO (preferred) or drop this env/input to avoid a misleading workflow interface.

Copilot uses AI. Check for mistakes.
run: |
uv run pytest tests/integration/test_ado_bearer_e2e.py -v
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- New `enterprise/governance-guide.md` documentation page: flagship governance reference for CISO / VPE / Platform Tech Lead audiences, covering enforcement points, bypass contract, failure semantics, air-gapped operation, rollout playbook, and known gaps. Trims duplicated content in `governance.md`, `apm-policy.md`, and `integrations/github-rulesets.md`. Adds `templates/apm-policy-starter.yml`. (#851)
- `apm install` now supports Azure DevOps AAD bearer-token auth via `az account get-access-token`, with PAT-first fallback for orgs that disable PAT creation. Closes #852 (#856)

## [0.9.1] - 2026-04-22

Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/enterprise/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,21 @@ APM authenticates to git hosts using personal access tokens (PATs) read from env
- **Never stored in files.** Tokens are read from the environment at runtime. They are never written to `apm.yml`, `apm.lock.yaml`, or any generated file.
- **Never logged.** Token values are not included in console output, error messages, or debug logs.
- **Scoped to their git host.** A GitHub token is only sent to GitHub. An Azure DevOps token is only sent to Azure DevOps. Tokens are never transmitted to any other endpoint.
- **Injected via transient git config.** APM passes credentials with `http.extraheader` for the duration of a single git invocation; tokens are never embedded in URLs and are not visible in `ps` or process listings.

For GitHub, a fine-grained PAT with read-only `Contents` permission on the repositories you depend on is sufficient.

### Azure DevOps AAD bearer tokens

When `ADO_APM_PAT` is unset, APM can authenticate to Azure DevOps with a Microsoft Entra ID bearer token issued on demand by the Azure CLI (`az account get-access-token`). The posture:

- **Short-lived.** Tokens expire in roughly 60 minutes, are acquired per resolution, and are never persisted by APM.
- **No new secrets in manifests.** Nothing is written to `apm.yml` or `apm.lock.yaml`. The token never crosses the `apm.yml`/lockfile boundary.
- **Compatible with managed-identity / service-account-only orgs.** Works in environments where PAT creation is disabled, including WIF-backed pipelines.
- **Same transport rules as PATs.** Bearer values are injected via `http.extraheader`, scoped to ADO hosts only, and never logged.

See [Authentication: AAD bearer tokens](../../getting-started/authentication/#authenticating-with-microsoft-entra-id-aad-bearer-tokens) for the resolution precedence and CI patterns.

## Attack surface comparison

| Vector | Traditional package manager | APM |
Expand Down
37 changes: 35 additions & 2 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ All token-bearing requests use HTTPS. Tokens are never sent over unencrypted con
| 4 | `GH_TOKEN` | Any host | Set by `gh auth login` |
| 5 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain |

For Azure DevOps, the only token source is `ADO_APM_PAT`.
For Azure DevOps, APM resolves credentials in this order: `ADO_APM_PAT` env var, then a Microsoft Entra ID (AAD) bearer token from the Azure CLI (`az`). See [Azure DevOps](#azure-devops) below.

For Artifactory registry proxies, use `PROXY_REGISTRY_TOKEN`. See [Registry proxy (Artifactory)](#registry-proxy-artifactory) below.

Expand Down Expand Up @@ -146,6 +146,39 @@ apm install dev.azure.com/myorg/My%20Project/_git/My%20Repo%20Name

Create the PAT at `https://dev.azure.com/{org}/_usersSettings/tokens` with **Code (Read)** permission.

### Authenticating with Microsoft Entra ID (AAD) bearer tokens

When your org has disabled PAT creation (managed-identity-only orgs, locked-down enterprise tenants), APM can use an AAD bearer token issued by the Azure CLI instead. No env var is required: APM picks up the token from your active `az` session on demand.

**Prerequisite:** install the [Azure CLI](https://aka.ms/installazurecli) and sign in against the tenant that owns the org:

```bash
az login --tenant <your-tenant-id>
apm install dev.azure.com/myorg/myproject/myrepo
```

**Resolution precedence for ADO hosts** (`dev.azure.com`, `*.visualstudio.com`):

1. `ADO_APM_PAT` env var if set
2. AAD bearer via `az account get-access-token` if `az` is installed and signed in
3. Otherwise: auth-failed error with guidance for both paths

**Stale-PAT fallback:** if `ADO_APM_PAT` is set but rejected (HTTP 401), APM silently retries with the `az` bearer and emits:

```
[!] ADO_APM_PAT was rejected for dev.azure.com (HTTP 401); fell back to az cli bearer.
[!] Consider unsetting the stale variable.
```

**Verbose output** (`--verbose`) shows which source was used per host:

```
[i] dev.azure.com -- using bearer from az cli (source: AAD_BEARER_AZ_CLI)
[i] dev.azure.com -- token from ADO_APM_PAT
```

Bearer tokens are short-lived (~60 minutes), acquired on demand, never persisted by APM. See [Security Model: Token handling](../../enterprise/security/#token-handling) for the full posture.

## Package source behavior

| Package source | Host | Auth behavior | Fallback |
Expand All @@ -154,7 +187,7 @@ Create the PAT at `https://dev.azure.com/{org}/_usersSettings/tokens` with **Cod
| `github.com/org/repo` | github.com | Global env vars → credential fill | Unauth for public repos |
| `contoso.ghe.com/org/repo` | *.ghe.com | Global env vars → credential fill | Auth-only (no public repos) |
| GHES via `GITHUB_HOST` | ghes.company.com | Global env vars → credential fill | Unauth for public repos |
| `dev.azure.com/org/proj/repo` | ADO | `ADO_APM_PAT` only | Auth-only |
| `dev.azure.com/org/proj/repo` | ADO | `ADO_APM_PAT` -> AAD bearer via `az` | Auth-only |
| Artifactory registry proxy | custom FQDN | `PROXY_REGISTRY_TOKEN` | Error if `PROXY_REGISTRY_ONLY=1` |

## Registry proxy (Artifactory)
Expand Down
42 changes: 42 additions & 0 deletions docs/src/content/docs/integrations/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ This catches cases where a developer updates `apm.yml` but forgets to re-run `ap
steps:
- script: |
curl -sSL https://aka.ms/apm-unix | sh
export PATH="$HOME/.apm/bin:$PATH"
apm install
# Optional: only if targeting Codex, Gemini, or similar tools
# apm compile
Expand All @@ -88,6 +89,47 @@ steps:
ADO_APM_PAT: $(ADO_PAT)
```

### ADO with AAD bearer (no PAT)

In orgs that disable PAT creation, use a Workload Identity Federation (WIF) service connection and let APM consume the `az` session inherited from `AzureCLI@2`. Do NOT set `ADO_APM_PAT` -- APM falls back to the bearer cleanly only when no PAT env var is present.

```yaml
steps:
- task: AzureCLI@2
displayName: 'APM Install (AAD bearer)'
inputs:
azureSubscription: 'my-wif-service-connection'
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
curl -sSL https://aka.ms/apm-unix | sh
export PATH="$HOME/.apm/bin:$PATH"
apm install
```

For GitHub Actions targeting ADO repos, use [`azure/login@v2`](https://github.com/marketplace/actions/azure-login) with OIDC federated credentials so `az` is signed in before `apm install` runs:

```yaml
permissions:
id-token: write
contents: read
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: microsoft/apm-action@v1
# Do not set ADO_APM_PAT -- APM picks up the az session.
```

See [Authentication: AAD bearer tokens](../../getting-started/authentication/#authenticating-with-microsoft-entra-id-aad-bearer-tokens) for resolution precedence and verbose output.

## General CI

For any CI system with Python available:
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,10 @@ When you run `apm install`, APM automatically integrates primitives from install
After installation completes, APM prints a grouped diagnostic summary instead of inline warnings. Categories include collisions (skipped files), cross-package skill replacements, warnings, and errors.

- **Normal mode**: Shows counts and actionable tips (e.g., "9 files skipped -- use `apm install --force` to overwrite")
- **Verbose mode** (`--verbose`): Additionally lists individual file paths grouped by package, and full error details
- **Verbose mode** (`--verbose`): Additionally lists individual file paths grouped by package, full error details, and **the resolved auth source per remote host** (e.g., `[i] dev.azure.com -- using bearer from az cli (source: AAD_BEARER_AZ_CLI)` or `[i] github.com -- token from GITHUB_APM_PAT`). Useful for diagnosing PAT vs. Entra-ID-bearer behaviour against Azure DevOps.

```bash
# See exactly which files were skipped or had issues
# See exactly which files were skipped or had issues, and which auth source was used
apm install --verbose
```

Expand Down
23 changes: 22 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,36 @@ For SSO-protected orgs, authorize the token under Settings > Tokens > Configure

## Azure DevOps (ADO)

ADO uses a dedicated token variable -- the GitHub token chain does not apply:
ADO supports two auth modes; the GitHub token chain does not apply. Resolution order:

1. `ADO_APM_PAT` env var if set
2. AAD bearer from `az account get-access-token` if `az` is installed and signed in
3. Otherwise: auth-failed error

```bash
# PAT mode
export ADO_APM_PAT=your_ado_pat
apm install dev.azure.com/org/project/_git/repo

# Bearer mode (no env var needed)
az login --tenant <tenant-id>
apm install dev.azure.com/org/project/_git/repo
```

ADO paths use the 3-segment format: `org/project/repo`. Auth is always required.

If `ADO_APM_PAT` is set but ADO returns 401, APM silently retries with the `az` bearer and warns:
`[!] ADO_APM_PAT was rejected for {host} (HTTP 401); fell back to az cli bearer.`

### ADO auth troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `No ADO_APM_PAT was set and az CLI is not installed` | Neither path available | Install `az` from https://aka.ms/installazurecli and run `az login --tenant <tenant>`, or set `ADO_APM_PAT` |
| `az CLI is installed but no active session was found` | `az account show` fails | Run `az login --tenant <tenant>` against the tenant that owns the org |
| `az CLI returned a token but the org does not accept it (likely a tenant mismatch)` | Wrong tenant | Run `az login --tenant <correct-tenant>`, or set `ADO_APM_PAT` |
| `ADO_APM_PAT was rejected (HTTP 401) and no az cli fallback was available` | Stale PAT, no `az` | Rotate the PAT, or install `az` and run `az login --tenant <tenant>` |

## GitHub Enterprise Server (GHES)

```bash
Expand Down
19 changes: 19 additions & 0 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""APM install command and dependency installation engine."""

import builtins
import os
import sys
from pathlib import Path
from typing import List, Optional
Expand Down Expand Up @@ -1087,12 +1088,20 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
apm install --mcp api --url https://example.com/mcp # remote http/sse
apm install --mcp fetch -- npx -y @modelcontextprotocol/server-fetch # stdio (post-- argv)
"""
# C1 #856: defaults BEFORE try so the finally clause never sees an
# UnboundLocalError if InstallLogger(...) raises during construction.
_apm_verbose_prev = os.environ.get("APM_VERBOSE")
try:
# Create structured logger for install output early so exception
# handlers can always reference it (avoids UnboundLocalError if
# scope initialisation below throws).
is_partial = bool(packages)
logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial)
# HACK(#852): surface --verbose to deeper auth layers via env var until
# AuthResolver gains a first-class verbose channel. Restored in finally
# below to keep the mutation scoped to this command invocation.
if verbose:
os.environ["APM_VERBOSE"] = "1"

# W2-pkg-rollback (#827): snapshot bytes captured BEFORE
# _validate_and_add_packages_to_apm_yml mutates apm.yml.
Expand Down Expand Up @@ -1273,6 +1282,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
# Create shared auth resolver for all downloads in this CLI invocation
# to ensure credentials are cached and reused (prevents duplicate auth popups)
auth_resolver = AuthResolver()
# F2/F3 #856: thread the InstallLogger into AuthResolver so the verbose
# auth-source line and the deferred stale-PAT [!] warning route through
# CommandLogger / DiagnosticCollector instead of stderr/inline writes.
auth_resolver.set_logger(logger)

# Check if apm.yml exists
apm_yml_exists = manifest_path.exists()
Expand Down Expand Up @@ -1594,6 +1607,12 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
if not verbose:
logger.progress("Run with --verbose for detailed diagnostics")
sys.exit(1)
finally:
# HACK(#852) cleanup: restore APM_VERBOSE so it stays scoped to this call.
if _apm_verbose_prev is None:
os.environ.pop("APM_VERBOSE", None)
else:
os.environ["APM_VERBOSE"] = _apm_verbose_prev


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading