Skip to content
Open
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
29 changes: 29 additions & 0 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,35 @@ The optional [live preview scenario](tests/cli-interactive-tester-scenarios/tier
is driven through `foundry-extension-scenario-orchestrator`; its setup incurs Azure
cost and is not run automatically.

## Developer Foundry access

Agent predeploy uses the shared core `pkg/foundry` access preparation, also used
by toolbox writes. A non-mutating agent-list probe checks effective data-plane
access first, so working custom or externally managed permissions do not
require role-assignment writes. On a confirmed authorization denial, it checks
inherited Foundry roles and attempts a project-scoped Foundry User assignment
only when needed, then waits for bounded propagation before deployment.
Optional role-inspection or assignment failures warn and let deployment report
the service result. Cancellation or an exhausted post-grant readiness check
stops predeploy; wait and retry if propagation takes longer.

Preparation requires a matching `AZURE_AI_PROJECT_ID` and
`FOUNDRY_PROJECT_ENDPOINT` (falling back to `AZURE_AI_PROJECT_ENDPOINT`), with no
conflicting `AZURE_SUBSCRIPTION_ID`. Guest users and service principals use the
host's user-access tenant for credentials and resource-tenant principal for
assignments. Missing or inconsistent project context warns without granting.

`AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` disables developer access preparation
and hosted-agent permission checks. A non-empty active azd environment value
takes precedence over the process environment; absent or empty values fall
back to the process. Hosted agents retain their separate role-assignment-write
and ACR classic/ABAC checks. Provisioning and read-only diagnostics do not
automatically grant developer roles.

This shared API must be released in core before updating the independently
published extension's azd SDK dependency. Local cross-module builds can use a
Go workspace; do not commit local module replacements.

## Extension telemetry API

Requires azd 1.37.0 or later. Telemetry, deployment preview, and developer RBAC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,13 @@ func TestCreateNewEnvironment_ReusesExistingEnvironmentAfterAlreadyExistsError(t

type testEnvironmentServiceServer struct {
azdext.UnimplementedEnvironmentServiceServer
environments map[string]*azdext.Environment
current *azdext.Environment
values map[string]map[string]string // envName -> key -> value
setKeys []string
getCurrentCalls int
getValuesCalls int
environments map[string]*azdext.Environment
current *azdext.Environment
values map[string]map[string]string // envName -> key -> value
setKeys []string
getCurrentCalls int
getValuesCalls int
getActiveValuesCalls int
}

func (s *testEnvironmentServiceServer) GetCurrent(context.Context, *azdext.EmptyRequest) (*azdext.EnvironmentResponse, error) {
Expand Down Expand Up @@ -468,7 +469,15 @@ func (s *testEnvironmentServiceServer) GetValues(
_ context.Context, req *azdext.GetEnvironmentRequest,
) (*azdext.KeyValueListResponse, error) {
s.getValuesCalls++
values := s.values[req.Name]
name := req.GetName()
if name == "" {
s.getActiveValuesCalls++
if s.current == nil {
return nil, status.Error(codes.NotFound, "current environment not found")
}
name = s.current.GetName()
}
values := s.values[name]
keyValues := make([]*azdext.KeyValue, 0, len(values))
for key, value := range values {
keyValues = append(keyValues, &azdext.KeyValue{Key: key, Value: value})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1288,10 +1288,10 @@ func TestPredeployAgentRBACRunsPerService(t *testing.T) {
client := newTestAzdClient(t, envServer, &testWorkflowServiceServer{})

require.NoError(t, predeployHandler(t.Context(), client, &azdext.ServiceEventArgs{Project: proj, Service: prompt}))
firstCalls := envServer.getCurrentCalls
require.GreaterOrEqual(t, firstCalls, 2)
firstCalls := envServer.getActiveValuesCalls
require.Equal(t, 1, firstCalls)
require.NoError(t, predeployHandler(t.Context(), client, &azdext.ServiceEventArgs{Project: proj, Service: hosted}))
require.GreaterOrEqual(t, envServer.getCurrentCalls, firstCalls+2)
require.Equal(t, firstCalls+1, envServer.getActiveValuesCalls)
}

func TestPredeployVoiceRunsDeveloperRBAC(t *testing.T) {
Expand All @@ -1318,7 +1318,7 @@ func TestPredeployVoiceRunsDeveloperRBAC(t *testing.T) {
require.NoError(t, predeployHandler(t.Context(), client, &azdext.ServiceEventArgs{
Project: proj, Service: svc,
}))
require.GreaterOrEqual(t, envServer.getCurrentCalls, 2)
require.Equal(t, 1, envServer.getActiveValuesCalls)
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ const (
OpCreateAgent = "create_agent"
OpCreateConversation = "create_conversation"
OpCreateTelephonyBinding = "create_telephony_binding"
OpCheckProjectAccess = "check_project_access"
OpGetAgent = "get_agent"
OpGetTelephonyBinding = "get_telephony_binding"
OpUpdateAgent = "update_agent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
package project

import (
"context"
"fmt"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
"github.com/google/uuid"
"github.com/azure/azure-dev/cli/azd/pkg/foundry"
)

const roleAzureAIUser = "53ca6127-db72-4b80-b1b0-d745d6d5456d"
const roleAzureAIUser = foundry.FoundryUserRole

// agentIdentityInfo holds the parsed project information needed for agent identity RBAC.
type agentIdentityInfo struct {
Expand Down Expand Up @@ -69,100 +66,3 @@ func parseAgentIdentityInfo(projectResourceID string) (*agentIdentityInfo, error

return info, nil
}

// assignRoleToIdentity assigns a single RBAC role to a principal at the given scope.
// It is idempotent: existing assignments are detected and skipped.
// Returns true if a new assignment was created, false if it already existed.
func assignRoleToIdentity(
ctx context.Context,
cred *azidentity.AzureDeveloperCLICredential,
principalID string,
roleID string,
roleName string,
scope string,
principalType armauthorization.PrincipalType,
) (bool, error) {
subscriptionID := extractSubscriptionID(scope)
if subscriptionID == "" {
return false, fmt.Errorf("could not extract subscription ID from scope %s for role %s", scope, roleName)
}

client, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil)
if err != nil {
return false, fmt.Errorf("failed to create role assignments client for role %s: %w", roleName, err)
}
return assignRoleToIdentityWithClient(ctx, client, principalID, roleID, roleName, scope, principalType)
}

func assignRoleToIdentityWithClient(
ctx context.Context,
client *armauthorization.RoleAssignmentsClient,
principalID, roleID, roleName, scope string,
principalType armauthorization.PrincipalType,
) (bool, error) {
roleDefinitionSuffix := fmt.Sprintf("/roleDefinitions/%s", roleID)

// Check for existing assignment
pager := client.NewListForScopePager(scope, &armauthorization.RoleAssignmentsClientListForScopeOptions{
Filter: new("atScope()"),
})

alreadyExists := false
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return false, fmt.Errorf("failed to list role assignments for role %s: %w", roleName, err)
}
for _, assignment := range page.Value {
if assignment.Properties != nil &&
assignment.Properties.PrincipalID != nil &&
assignment.Properties.RoleDefinitionID != nil &&
*assignment.Properties.PrincipalID == principalID &&
strings.HasSuffix(*assignment.Properties.RoleDefinitionID, roleDefinitionSuffix) {
alreadyExists = true
break
}
}
if alreadyExists {
break
}
}

if alreadyExists {
return false, nil
}

// Create assignment
fullRoleDefinitionID := fmt.Sprintf(
"/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", extractSubscriptionID(scope), roleID)
roleAssignmentName := uuid.New().String()
parameters := armauthorization.RoleAssignmentCreateParameters{
Properties: &armauthorization.RoleAssignmentProperties{
RoleDefinitionID: new(fullRoleDefinitionID),
PrincipalID: new(principalID),
PrincipalType: &principalType,
},
}

_, err := client.Create(ctx, scope, roleAssignmentName, parameters, nil)
if err != nil {
if strings.Contains(err.Error(), "RoleAssignmentExists") ||
strings.Contains(err.Error(), "409") {
return false, nil
}
return false, fmt.Errorf("failed to create role assignment for role %s: %w", roleName, err)
}

return true, nil
}

// extractSubscriptionID pulls the subscription ID from an Azure resource ID.
func extractSubscriptionID(resourceID string) string {
parts := strings.Split(resourceID, "/")
for i, part := range parts {
if part == "subscriptions" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,12 @@
package project

import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAssignRoleToIdentityRequest(t *testing.T) {
const scope = "/subscriptions/sub-123/resourceGroups/rg/providers/" +
"Microsoft.CognitiveServices/accounts/account/projects/project"
const principalID = "11111111-2222-3333-4444-555555555555"
for _, principalType := range []armauthorization.PrincipalType{
armauthorization.PrincipalTypeUser, armauthorization.PrincipalTypeServicePrincipal,
} {
t.Run(string(principalType), func(t *testing.T) {
var methods []string
transport := storageTestTransport(func(request *http.Request) (*http.Response, error) {
methods = append(methods, request.Method)
body := `{"value":[]}`
switch request.Method {
case http.MethodGet:
require.Equal(t, scope+"/providers/Microsoft.Authorization/roleAssignments", request.URL.Path)
require.Equal(t, "atScope()", request.URL.Query().Get("$filter"))
case http.MethodPut:
require.True(t, strings.HasPrefix(request.URL.Path,
scope+"/providers/Microsoft.Authorization/roleAssignments/"))
var parameters armauthorization.RoleAssignmentCreateParameters
require.NoError(t, json.NewDecoder(request.Body).Decode(&parameters))
require.NotNil(t, parameters.Properties)
require.Equal(t, new("/subscriptions/sub-123/providers/Microsoft.Authorization/roleDefinitions/"+
roleAzureAIUser), parameters.Properties.RoleDefinitionID)
require.Equal(t, new(principalID), parameters.Properties.PrincipalID)
require.Equal(t, new(principalType), parameters.Properties.PrincipalType)
body = `{"properties":{}}`
default:
t.Fatalf("unexpected request method %s", request.Method)
}
return &http.Response{
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})
client, err := armauthorization.NewRoleAssignmentsClient("sub-123", storageTestCredential{}, &arm.ClientOptions{
ClientOptions: azcore.ClientOptions{Transport: transport, Retry: policy.RetryOptions{MaxRetries: -1}},
})
require.NoError(t, err)

created, err := assignRoleToIdentityWithClient(
t.Context(), client, principalID, roleAzureAIUser, "Foundry User", scope, principalType,
)

require.NoError(t, err)
require.True(t, created)
require.Equal(t, []string{http.MethodGet, http.MethodPut}, methods)
})
}
}

func TestParseAgentIdentityInfo(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -146,43 +87,6 @@ func TestParseAgentIdentityInfo(t *testing.T) {
}
}

func TestExtractSubscriptionID(t *testing.T) {
tests := []struct {
name string
resourceID string
want string
}{
{
name: "valid resource ID",
resourceID: "/subscriptions/sub-123/resourceGroups/rg-test/providers/" +
"Microsoft.CognitiveServices/accounts/my-account",
want: "sub-123",
},
{
name: "no subscription segment",
resourceID: "/resourceGroups/rg-test/providers/Microsoft.CognitiveServices/accounts/my-account",
want: "",
},
{
name: "empty string",
resourceID: "",
want: "",
},
{
name: "subscription at end with no value",
resourceID: "/subscriptions/",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractSubscriptionID(tt.resourceID)
assert.Equal(t, tt.want, got)
})
}
}

func TestConstants(t *testing.T) {
assert.Equal(t, "53ca6127-db72-4b80-b1b0-d745d6d5456d", roleAzureAIUser)
}
Loading
Loading