Skip to content

Commit d4bda39

Browse files
committed
infra: assign least-privilege Cognitive Services User when ASSIGN_RBAC=true
Replaces the two-role assignment (Foundry User + Foundry Project Manager) with a single Cognitive Services User assignment on the Foundry account. This is the smallest built-in role whose data actions cover Microsoft.CognitiveServices/accounts/MaaS/* (the Claude inference path). Preserves the issue #38 / #39 serialization: all three Claude deployments still dependsOn the role-assignment resource, so the model-deployment LRO absorbs RBAC propagation lag. Verified end-to-end with ASSIGN_RBAC=true: azd up succeeded, verifier 10/10 PASS including live claude -p round trip, python src/hello_claude.py succeeded on first call with no retries. Closes #40.
1 parent c262656 commit d4bda39

8 files changed

Lines changed: 48 additions & 64 deletions

File tree

.env.sample

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ CLAUDE_MODEL_NAME=claude-sonnet-4-6
2222
CLAUDE_MODEL_VERSION=1
2323
CLAUDE_MODEL_CAPACITY=25
2424

25-
# Grant Foundry User + Foundry Project Manager to AZURE_PRINCIPAL_ID during `azd up`.
26-
# Requires Microsoft.Authorization/roleAssignments/write on the deployer.
25+
# Grant `Cognitive Services User` (least-privilege inference role, GUID
26+
# a97b65f3-24c7-4388-baec-2e87135dc908) to AZURE_PRINCIPAL_ID on the Foundry
27+
# account during `azd up`. Requires Microsoft.Authorization/roleAssignments/write
28+
# on the deployer.
2729
# ASSIGN_RBAC=true
2830

2931
# Let the postprovision hook run the official Claude Code installer if `claude` isn't on PATH.

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ This is the **Claude on Foundry Starter Kit** ([`Azure-Samples/claude`](https://
2828
| `CLAUDE_OPUS_MODEL` | no | empty | e.g. `claude-opus-4-8`. Empty = skip family. |
2929
| `CLAUDE_HAIKU_CAPACITY` / `CLAUDE_SONNET_CAPACITY` / `CLAUDE_OPUS_CAPACITY` | no | `25` | TPM ÷ 1000 per family |
3030
| `CLAUDE_MODEL_NAME` | no | `claude-sonnet-4-6` | **Legacy** single-deployment fallback (only used when all three `CLAUDE_*_MODEL` are empty) |
31-
| `ASSIGN_RBAC` | no | `false` | `true` grants Foundry User + Foundry Project Manager to `AZURE_PRINCIPAL_ID` |
31+
| `ASSIGN_RBAC` | no | `false` | `true` grants `Cognitive Services User` (least-privilege inference role) on the Foundry account to `AZURE_PRINCIPAL_ID` |
3232
| `CLAUDE_CODE_AUTO_INSTALL` | no | `false` | `true` installs the Claude Code CLI in the postprovision hook |
3333
| `CLAUDE_WRITE_VSCODE_SETTINGS` | no | `false` | `true` opts in to having the postprovision hook write `.vscode/settings.json` for the [Anthropic Claude Code VS Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). Default skips it — the CLI / SDK don't need workspace settings. |
3434

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ Rows marked **Attest** below are the three `modelProviderData` fields sent to An
240240
| `CLAUDE_MODEL_VERSION` | no | `1` | Applies to all deployed families. |
241241
| `CLAUDE_MODEL_NAME` | no | `claude-sonnet-4-6` | **Legacy.** Only used when all three `CLAUDE_*_MODEL` vars are empty (single-deployment fallback). |
242242
| `CLAUDE_MODEL_CAPACITY` | no | `25` | **Legacy.** Capacity for the legacy single-deployment fallback. |
243-
| `ASSIGN_RBAC` | no | `false` | `true` to grant `Foundry User` + `Foundry Project Manager` to `AZURE_PRINCIPAL_ID` (needs `roleAssignments/write`) |
243+
| `ASSIGN_RBAC` | no | `false` | `true` to grant `Cognitive Services User` (least-privilege inference role) on the Foundry account to `AZURE_PRINCIPAL_ID` (needs `roleAssignments/write`) |
244244
| `CLAUDE_CODE_AUTO_INSTALL` | no | `false` | `true` to let the postprovision hook run the official Claude Code installer ([`install.ps1`](https://claude.ai/install.ps1) / [`install.sh`](https://claude.ai/install.sh)) when `claude` isn't already on PATH |
245245
246246
### Choosing which models to deploy
@@ -426,9 +426,9 @@ If the Anthropic SDK ever accepts a callable for `auth_token`, this shim becomes
426426
- **Microsoft Foundry** account (`Microsoft.CognitiveServices/accounts`, kind `AIServices`, SKU `S0`, `allowProjectManagement = true`)
427427
- **Foundry project**
428428
- One **Claude deployment per requested family** (`GlobalStandard`, with the required `modelProviderData` block) — set `CLAUDE_HAIKU_MODEL` / `CLAUDE_SONNET_MODEL` / `CLAUDE_OPUS_MODEL` to control which families. Sonnet/Opus deployments chain on the prior to avoid Foundry's per-account 409s on concurrent create.
429-
- *Optional* RBAC: *Foundry User* + *Foundry Project Manager* on the deploying principal (set `ASSIGN_RBAC=true`). *(These roles were previously called `Azure AI User` / `Azure AI Project Manager`; Azure renamed them — the underlying role GUIDs are unchanged.)*
429+
- *Optional* RBAC: a single `Cognitive Services User` assignment on the Foundry account for the deploying principal (set `ASSIGN_RBAC=true`). This is the [least-privilege role the MS Learn doc recommends](https://learn.microsoft.com/en-us/azure/foundry/foundry-models/how-to/configure-entra-id?tabs=python&pivots=ai-foundry-portal#for-making-authenticated-api-calls) for keyless inference — it grants exactly the `Microsoft.CognitiveServices/accounts/MaaS/*` data action this template's runtime needs and nothing else. If you want broader access (project-scoped APIs, agents, etc.), grant `Foundry User` or `Azure AI Developer` yourself afterwards — see the [permissions matrix](#required-permissions) below.
430430
- **Heads up:** without this (or a manual post-deploy grant), the Python SDK and `claude` CLI will return `401 PermissionDenied` even though `azd up` succeeded. See [Granting data-plane roles after `azd up`](#granting-data-plane-roles-after-azd-up).
431-
- When `ASSIGN_RBAC=true`, the model deployments are ordered to run *after* both role assignments. The role-assignment PUTs return fast (~5 s) but Foundry data-plane RBAC takes a few minutes to propagate; the slow model-deployment LRO (30 s–20 min) absorbs that propagation time so the first call after `azd up` succeeds without retries.
431+
- When `ASSIGN_RBAC=true`, the model deployments are ordered to run *after* the role assignment. The role-assignment PUT returns fast (~5 s) but Foundry data-plane RBAC takes a few minutes to propagate; the slow model-deployment LRO (30 s–20 min) absorbs that propagation time so the first call after `azd up` succeeds without retries.
432432
433433
</details>
434434
@@ -478,7 +478,7 @@ claude/
478478
| Preflight: `Marketplace offer ... not found` | `CLAUDE_MODEL_NAME` is misspelled, the model isn't in the Anthropic-on-Foundry catalog yet, or Anthropic changed the plan-name convention. |
479479
| Preflight: `Quota insufficient` (exit 6) | Requested `CLAUDE_*_CAPACITY` plus existing usage exceeds the per-region quota limit. Lower the requested capacity, free up quota by deleting unused deployments, or [purge soft-deleted accounts](#free-quota-held-by-soft-deleted-accounts) that may still be holding TPM. |
480480
| Quota looks full but you have no live deployments (`az cognitiveservices usage list` shows `currentValue > 0`, deployment still fails with `715-123420` / `InsufficientQuota`) | **Soft-deleted Cognitive Services accounts still reserve quota for 48 h.** A previous `azd down` (or any RG / account delete) puts the AIServices account in a recoverable state that keeps holding TPM. **Fix:** list and purge them: `az cognitiveservices account list-deleted -o table` then `az cognitiveservices account purge --name <name> --location <region> --resource-group <rg>` for each. See [Free quota held by soft-deleted accounts](#free-quota-held-by-soft-deleted-accounts). |
481-
| `401 PermissionDenied: Principal does not have access to API/Operation` intermittently &mdash; same code passes seconds later | Data-plane RBAC propagation lag on a freshly-granted role (`Cognitive Services User` / `Foundry User` / `Azure AI Developer`). The grant can take a few minutes to land on the Foundry data plane even after `az role assignment create` returns. When `ASSIGN_RBAC=true`, this kit serializes the model deployments after the role assignments so the deployment LRO absorbs the propagation wait &mdash; the first call after `azd up` should just work. If you granted the role manually *after* `azd up`, wait a minute and retry; verify the assignment with `az role assignment list --assignee <oid> --scope <foundry-account-id> -o table`. |
481+
| `401 PermissionDenied: Principal does not have access to API/Operation` intermittently &mdash; same code passes seconds later | Data-plane RBAC propagation lag on a freshly-granted role (`Cognitive Services User` / `Foundry User` / `Azure AI Developer`). The grant can take a few minutes to land on the Foundry data plane even after `az role assignment create` returns. When `ASSIGN_RBAC=true`, this kit serializes the model deployments after the role assignment so the deployment LRO absorbs the propagation wait &mdash; the first call after `azd up` should just work. If you granted the role manually *after* `azd up`, wait a minute and retry; verify the assignment with `az role assignment list --assignee <oid> --scope <foundry-account-id> -o table`. |
482482
| `claude -p` returns `The model claude-<family>-... is not available on your foundry deployment. Try --model to switch to ...` | Your user-global `~/.claude/settings.json` has `"model"` set to a family this workspace didn't deploy. The postprovision hook writes a workspace `.claude/settings.json` with `"model"` pinned to a deployed family, which overrides the global &mdash; but if you re-ran `azd up` *before* the hook update, or your global has a per-project override, the workspace pin won't apply. Either re-run `pwsh -File scripts/configure-claude-code.ps1` to regenerate `.claude/settings.json`, pick the family explicitly via `claude -p --model <sonnet\|opus\|haiku>`, or edit `~/.claude/settings.json` to remove the `"model"` line. |
483483
| Windows: `UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f60a'` printing the model's response | The Foundry sample apps happily return emoji and other non-CP1252 characters; the default Windows console (cp1252) can't render them. Either set `$env:PYTHONIOENCODING = "utf-8"` before running, or switch the console to UTF-8 with `chcp 65001`. The Python samples already handle this gracefully, but third-party tooling may not. |
484484
| `check_claude_quota.py` exits with `Could not resolve a subscription id ... [WinError 2] The system cannot find the file specified` | The script falls back to `az account show` to find a subscription, but the Azure CLI isn't on `PATH` in the active shell. Either set `$env:AZURE_SUBSCRIPTION_ID = "<sub-id>"` or pass `--subscription <sub-id>` explicitly. |
@@ -640,7 +640,7 @@ Notes on the output:
640640
|---|---|---|
641641
| Provision Foundry + Claude deployment | `Contributor` (or `Cognitive Services Contributor`) | Resource group / subscription |
642642
| Assign RBAC inside this template (`ASSIGN_RBAC=true`) | `User Access Administrator` or `Owner` | Resource group / subscription |
643-
| Call the Messages API with Entra ID | `Foundry User` *(or `Azure AI Developer` &mdash; see note)* | Foundry account |
643+
| Call the Messages API with Entra ID | `Cognitive Services User` *(template default; see note for broader alternatives)* | Foundry account |
644644
645645
If you do not have `Microsoft.Authorization/roleAssignments/write`, leave `ASSIGN_RBAC=false` (the default) and ask an admin to grant one of the roles below on the Foundry account afterwards.
646646
@@ -672,8 +672,8 @@ Wait 1–3 minutes for the role to propagate to the Foundry data plane before re
672672
673673
| Role | Data action(s) | Notes |
674674
|---|---|---|
675-
| `Cognitive Services User` | `Microsoft.CognitiveServices/*/read` + inference action | The minimum role recommended by [the official docs](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/use-foundry-models-claude#troubleshooting). |
676-
| `Foundry User` | `Microsoft.CognitiveServices/*` | Broadest data-plane access; what this template assigns when `ASSIGN_RBAC=true`. **Previously named `Azure AI User`** &mdash; Azure renamed it, GUID `53ca6127-db72-4b80-b1b0-d745d6d5456d` is unchanged. |
675+
| `Cognitive Services User` | `Microsoft.CognitiveServices/*/read` + inference action | The minimum role recommended by [the official docs](https://learn.microsoft.com/en-us/azure/foundry/foundry-models/how-to/configure-entra-id?tabs=python&pivots=ai-foundry-portal#for-making-authenticated-api-calls), and what this template assigns when `ASSIGN_RBAC=true`. GUID `a97b65f3-24c7-4388-baec-2e87135dc908`. |
676+
| `Foundry User` | `Microsoft.CognitiveServices/*` | Broader data-plane access; useful if you plan to add project-scoped samples (agents, knowledge, evaluators) on top of this template. **Previously named `Azure AI User`** &mdash; Azure renamed it, GUID `53ca6127-db72-4b80-b1b0-d745d6d5456d` is unchanged. |
677677
| `Azure AI Developer` | includes `Microsoft.CognitiveServices/accounts/MaaS/*` | Sufficient for Claude because Claude routes through the **MaaS** data path as a partner/marketplace model. (It is **not** sufficient for first-party Foundry models that route through `accounts/AIServices/*`.) |
678678
679679
> The role `Azure AI Developer` was historically called out as *insufficient* for Foundry inference. That guidance still applies to first-party `AIServices` models, but Claude/Anthropic deployments dispatch through `Microsoft.CognitiveServices/accounts/MaaS/*`, which `Azure AI Developer` already grants. Verified against `claude-sonnet-4-6` on `2025-10-01-preview`.

infra-bicep/infra/foundry.bicep

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ var haikuDeploymentNameVar = empty(haikuModel) ? '' : '${haikuModel}-${nameSuf
3131
var sonnetDeploymentNameVar = empty(sonnetModel) ? '' : '${sonnetModel}-${nameSuffix}'
3232
var opusDeploymentNameVar = empty(opusModel) ? '' : '${opusModel}-${nameSuffix}'
3333

34-
// Built-in role definition IDs.
35-
// NOTE: Azure renamed these roles. The GUIDs are stable.
36-
// 53ca6127-... : "Azure AI User" -> "Foundry User" (data-plane access)
37-
// eadc314b-... : "Azure AI Project Manager" -> "Foundry Project Manager"
38-
var foundryUserRoleId = '53ca6127-db72-4b80-b1b0-d745d6d5456d'
39-
var foundryProjectManagerRoleId = 'eadc314b-1a2d-4efa-be10-5d325db5065e'
34+
// Built-in role definition ID for the documented least-privilege inference
35+
// role on a Foundry account. See:
36+
// https://learn.microsoft.com/azure/foundry/foundry-models/how-to/configure-entra-id#for-making-authenticated-api-calls
37+
// `Cognitive Services User` grants exactly the data action this template's
38+
// runtime needs (`Microsoft.CognitiveServices/accounts/MaaS/*`) and nothing
39+
// else. The broader `Foundry User` / `Azure AI Developer` roles also work
40+
// and are documented in README.md for users who deliberately want more.
41+
var cognitiveServicesUserRoleId = 'a97b65f3-24c7-4388-baec-2e87135dc908'
4042

4143
resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' = {
4244
name: accountName
@@ -68,28 +70,18 @@ resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-previ
6870
properties: {}
6971
}
7072

71-
// Role assignments are declared BEFORE the model deployments so each
72-
// deployment can dependsOn them. The model-deployment LRO can take
73-
// 30s-20min depending on region and family; chaining the role grants
73+
// Role assignment is declared BEFORE the model deployments so each
74+
// deployment can dependsOn it. The model-deployment LRO can take
75+
// 30s-20min depending on region and family; chaining the role grant
7476
// first turns that wait into free RBAC propagation time and makes the
7577
// first call after `azd up` succeed without the usual 5-min lag.
76-
// When rbacEnabled is false, both resources are `if(false)` and Bicep
78+
// When rbacEnabled is false, the resource is `if(false)` and Bicep
7779
// drops the dependsOn edge automatically.
78-
resource foundryUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
79-
name: guid(account.id, principalId, foundryUserRoleId)
80+
resource cognitiveServicesUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
81+
name: guid(account.id, principalId, cognitiveServicesUserRoleId)
8082
scope: account
8183
properties: {
82-
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', foundryUserRoleId)
83-
principalId: principalId
84-
principalType: 'User'
85-
}
86-
}
87-
88-
resource foundryProjectManagerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
89-
name: guid(account.id, principalId, foundryProjectManagerRoleId)
90-
scope: account
91-
properties: {
92-
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', foundryProjectManagerRoleId)
84+
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesUserRoleId)
9385
principalId: principalId
9486
principalType: 'User'
9587
}
@@ -118,8 +110,7 @@ resource haikuDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-
118110
}
119111
dependsOn: [
120112
project
121-
foundryUserAssignment
122-
foundryProjectManagerAssignment
113+
cognitiveServicesUserAssignment
123114
]
124115
}
125116

@@ -145,13 +136,12 @@ resource sonnetDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025
145136
raiPolicyName: 'Microsoft.DefaultV2'
146137
}
147138
// Foundry serializes deployments under one account; chain them to avoid
148-
// 409s on concurrent create. Role assignments are listed too so the
139+
// 409s on concurrent create. The role assignment is listed too so the
149140
// first inference call after `azd up` doesn't hit RBAC propagation lag.
150141
dependsOn: [
151142
project
152143
haikuDeployment
153-
foundryUserAssignment
154-
foundryProjectManagerAssignment
144+
cognitiveServicesUserAssignment
155145
]
156146
}
157147

@@ -179,8 +169,7 @@ resource opusDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-1
179169
dependsOn: [
180170
project
181171
sonnetDeployment
182-
foundryUserAssignment
183-
foundryProjectManagerAssignment
172+
cognitiveServicesUserAssignment
184173
]
185174
}
186175

infra-bicep/infra/main.bicep

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ param location string
3434
@description('Object id of the deploying user/SP. Empty disables RBAC.')
3535
param principalId string = ''
3636

37-
@description('Whether to assign Foundry User + Foundry Project Manager (formerly Azure AI User / Project Manager) to principalId. Set to "true" to enable.')
37+
@description('Whether to assign Cognitive Services User (least-privilege inference role) to principalId on the Foundry account. Set to "true" to enable.')
3838
param assignRbac string = 'false'
3939

4040
@description('Short prefix for resource names.')

infra-terraform/infra/main.tf

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,10 @@ resource "azapi_resource" "claude_haiku" {
138138
# but Foundry data-plane RBAC propagation can take 5+ min. Waiting on the
139139
# model-deployment LRO (30s-20min) in the meantime makes the first call
140140
# after `azd up` work without retries. When ASSIGN_RBAC is false, the
141-
# collections are empty and depends_on is satisfied immediately.
141+
# collection is empty and depends_on is satisfied immediately.
142142
depends_on = [
143143
azapi_resource.project,
144-
azurerm_role_assignment.foundry_user,
145-
azurerm_role_assignment.foundry_project_manager,
144+
azurerm_role_assignment.cognitive_services_user,
146145
]
147146
}
148147

@@ -178,8 +177,7 @@ resource "azapi_resource" "claude_sonnet" {
178177
depends_on = [
179178
azapi_resource.project,
180179
azapi_resource.claude_haiku,
181-
azurerm_role_assignment.foundry_user,
182-
azurerm_role_assignment.foundry_project_manager,
180+
azurerm_role_assignment.cognitive_services_user,
183181
]
184182
}
185183

@@ -215,25 +213,20 @@ resource "azapi_resource" "claude_opus" {
215213
depends_on = [
216214
azapi_resource.project,
217215
azapi_resource.claude_sonnet,
218-
azurerm_role_assignment.foundry_user,
219-
azurerm_role_assignment.foundry_project_manager,
216+
azurerm_role_assignment.cognitive_services_user,
220217
]
221218
}
222219

223220
# --- Optional RBAC --------------------------------------------------------
224-
# NOTE: Azure renamed "Azure AI User" -> "Foundry User" and
225-
# "Azure AI Project Manager" -> "Foundry Project Manager". The GUIDs are
226-
# unchanged, but azurerm matches by name, so we use the current names.
227-
resource "azurerm_role_assignment" "foundry_user" {
221+
# Least-privilege role for Foundry inference, per:
222+
# https://learn.microsoft.com/azure/foundry/foundry-models/how-to/configure-entra-id#for-making-authenticated-api-calls
223+
# `Cognitive Services User` grants exactly the data action this template's
224+
# runtime needs (`Microsoft.CognitiveServices/accounts/MaaS/*`) and nothing
225+
# else. Broader roles (`Foundry User`, `Azure AI Developer`) also work and
226+
# are documented in README.md for users who deliberately want more.
227+
resource "azurerm_role_assignment" "cognitive_services_user" {
228228
count = lower(var.assign_rbac) == "true" && var.principal_id != "" ? 1 : 0
229229
scope = azapi_resource.foundry.id
230-
role_definition_name = "Foundry User"
231-
principal_id = var.principal_id
232-
}
233-
234-
resource "azurerm_role_assignment" "foundry_project_manager" {
235-
count = lower(var.assign_rbac) == "true" && var.principal_id != "" ? 1 : 0
236-
scope = azapi_resource.foundry.id
237-
role_definition_name = "Foundry Project Manager"
230+
role_definition_name = "Cognitive Services User"
238231
principal_id = var.principal_id
239232
}

0 commit comments

Comments
 (0)