Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3459d43
Add private Azure Monitor path for VNet-injected Foundry projects
Nov 6, 2026
c7801e8
Test private link conflict check edge cases
Nov 6, 2026
f6e424c
Add Azure Monitor terms to cspell dictionaries
Nov 6, 2026
1c20aa4
Connect existing App Insights component on BYO-egress private networks
Nov 6, 2026
ee24b66
Address review: gate private monitoring, exact owned scope, workspace…
Nov 6, 2026
b7313f7
Approve private monitoring before planning, keep BYO private link rec…
Nov 6, 2026
98d3177
Keep owned private monitoring on conflict, reuse preserved keyless co…
Nov 6, 2026
87a4154
Give a reuse-specific recovery message when a failed private reuse-co…
Nov 6, 2026
7c625d2
Document shared-VNet impact of the Azure Monitor private link scope a…
Nov 6, 2026
6b1e633
Support private monitoring on managed egress
Nov 7, 2026
1ae333f
Fix spelling in managed egress monitoring docs
Nov 7, 2026
5d0689f
Include principal in managed-egress network approver role assignment …
Nov 7, 2026
ed32120
Include network-approver-role-assignment module in Bicep ejects
Nov 7, 2026
4238d99
Add account self private endpoint rule for managed-egress hosted agents
Nov 7, 2026
f75cce2
Document managed-egress self private endpoint rule and validation status
Nov 7, 2026
6eb19dd
Add ampls to agents extension cspell words
Nov 7, 2026
208fee0
Canonicalize and clean up the owned Private Link Scope output; block …
Nov 9, 2026
5ecc514
Grant hosted agent identity Monitoring Metrics Publisher on deploy
Nov 9, 2026
bc46375
Refresh App Insights component after granting hosted agent publisher …
Nov 9, 2026
fcde878
Gate hosted-agent monitoring grant to private azd-created components,…
Nov 9, 2026
ebd6c9d
Re-apply monitoring sync tag on deploy when it is missing
Nov 10, 2026
cb5e71b
Surface monitoring grant warning for Activity-only agents and test ta…
Nov 10, 2026
39a3d5e
Split monitoring publisher grant into focused files and centralize co…
Nov 10, 2026
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
2 changes: 2 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ words:
- abbrs
- acrpush
- alnum
- ampls
- azuremonitor
- foundryproject
- agentignore
- ADLS
Expand Down
15 changes: 14 additions & 1 deletion cli/azd/extensions/azure.ai.agents/docs/private-networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,20 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo

When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed.

Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring, whether `monitoring:` is omitted, empty, or names a component, so provisioning is never blocked, and azd prints a warning when it skips monitoring. Set up monitoring for such projects outside azd.
Automatic Application Insights monitoring works for a private network with your own agent subnet (BYO egress, `agentSubnet` set) and for managed egress (see below). For a component azd creates, azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration.

Provisioning is never blocked by monitoring in unsupported cases. With your own agent subnet you can also connect an existing Application Insights resource: azd creates its own Private Link Scope, private endpoint and DNS zones and links the component and its workspace to the scope without modifying the component (local authentication must stay enabled). Because azd does not own the component, it cannot re-update it; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it.

Shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr).

With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbound rules to the managed network and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoints are approved:

- `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope.
- `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected.

If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant applies only to azd-created components on the private path (not public-network projects or components you supply), and `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` skips it. On every deploy azd also re-applies the component tag if it is missing, which recovers from an earlier failed refresh. It is best effort: if it is skipped or fails, azd adds a warning with the `az role assignment create` command to the deploy note, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place.

**Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision.

For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, including with private account networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,12 @@ func writeExistingProjectBicepTemplates(
{"templates/modules/foundry-project.bicep", "modules/foundry-project.bicep"},
{"templates/modules/monitoring.bicep", "modules/monitoring.bicep"},
{"templates/modules/monitoring-connection.bicep", "modules/monitoring-connection.bicep"},
{"templates/modules/monitor-private-link.bicep", "modules/monitor-private-link.bicep"},
{"templates/modules/monitoring-resync.bicep", "modules/monitoring-resync.bicep"},
{
"templates/modules/network-approver-role-assignment.bicep",
"modules/network-approver-role-assignment.bicep",
},
}
if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) {
registrySource, err := fs.ReadFile(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package project

import (
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

// appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output
// instead of subprocess stderr, which the host discards. Activity-only agents produce no artifacts,
// so a resource artifact for the component carries the warning instead.
func appendDeployWarning(result *azdext.ServiceDeployResult, componentID, warning string) {
if result == nil || strings.TrimSpace(warning) == "" {
return
}
if len(result.Artifacts) == 0 && componentID != "" {
result.Artifacts = append(result.Artifacts, &azdext.Artifact{
Kind: azdext.ArtifactKind_ARTIFACT_KIND_RESOURCE,
Location: componentID,
LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE,
Metadata: map[string]string{
"label": "Application Insights",
"clickable": "false",
},
})
}
for i := len(result.Artifacts) - 1; i >= 0; i-- {
artifact := result.Artifacts[i]
if artifact == nil {
continue
}
if artifact.Metadata == nil {
artifact.Metadata = map[string]string{}
}
if existing := artifact.Metadata["note"]; existing != "" {
artifact.Metadata["note"] = existing + "\n\nWarning: " + warning
} else {
artifact.Metadata["note"] = "Warning: " + warning
}
return
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package project

import "time"

// Environment keys written by the azure.ai.projects provisioning extension. They are defined
// separately in that Go module, so these values must stay identical to its outputs.
const (
envFoundryMonitoringMode = "AZD_FOUNDRY_MONITORING_MODE"
envMonitoringPrivateLinkScope = "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID"
envApplicationInsightsResource = "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"
envSkipRoleAssignments = "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"

// monitoringModeCreate means azd created the Application Insights component.
monitoringModeCreate = "create"
)

const (
roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb"
roleMonitoringMetricsPublisherName = "Monitoring Metrics Publisher"

// monitoringRoleSyncTag is merged onto the component after a grant so Application Insights
// refreshes its cached ingestion authorization.
monitoringRoleSyncTag = "azd-monitoring-role-sync"

monitoringGrantAttempts = 4
)

// defaultMonitoringGrantRetryDelay is how long to wait between grant attempts. A new instance
// identity can take a short while to replicate before ARM accepts it as a role assignment principal.
const defaultMonitoringGrantRetryDelay = 15 * time.Second
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package project

import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"

"azureaiagent/internal/pkg/azure"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
)

// monitoringGrantRetryDelay is a variable so tests can avoid waiting.
var monitoringGrantRetryDelay = defaultMonitoringGrantRetryDelay

type monitoringRoleAssigner func(
ctx context.Context,
principalID, roleID, roleName, scope string,
principalType armauthorization.PrincipalType,
) (bool, error)

// monitoringComponentToucher refreshes the component after a role assignment. Application Insights
// caches Entra authorization for ingestion, and without a component update a new Monitoring Metrics
// Publisher assignment kept returning 403 for over 20 minutes in live runs.
//
// created reports whether this deploy created the assignment. When it did not, the toucher only
// updates the component if the sync tag is missing, which recovers from an earlier failed touch.
type monitoringComponentToucher func(ctx context.Context, componentID string, created bool) error

// privateMonitoringComponentID returns the App Insights component only when azd created it and
// connected it through the private path (Private Link Scope). Public-network projects and
// user-supplied components return "" so azd never changes their role assignments or tags.
func privateMonitoringComponentID(azdEnv map[string]string) string {
if strings.TrimSpace(azdEnv[envFoundryMonitoringMode]) != monitoringModeCreate ||
strings.TrimSpace(azdEnv[envMonitoringPrivateLinkScope]) == "" {
return ""
}
return strings.TrimSpace(azdEnv[envApplicationInsightsResource])
}

// roleAssignmentsSkipped reports whether the user opted out of azd writing role assignments, using
// the same precedence as the developer RBAC check: environment value first, then the process.
func roleAssignmentsSkipped(azdEnv map[string]string) bool {
value := strings.TrimSpace(azdEnv[envSkipRoleAssignments])
if value == "" {
value = os.Getenv(envSkipRoleAssignments)
}
skip, err := strconv.ParseBool(value)
return err == nil && skip
}

func monitoringGrantCommand(principalID, componentID string) string {
return fmt.Sprintf(
"az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+
"--role \"%s\" --scope %s",
principalID, roleMonitoringMetricsPublisherName, componentID,
)
}

// grantHostedAgentMonitoringPublisher lets a hosted agent's instance identity publish telemetry to the
// project's private App Insights component. The identity only exists after the agent version is
// created, so this cannot be done in the provisioning template.
//
// It is best effort and never fails the deploy. It returns a warning for the deploy note when
// telemetry may still be rejected, or "" when nothing needs the user's attention.
func grantHostedAgentMonitoringPublisher(
ctx context.Context,
assign monitoringRoleAssigner,
touch monitoringComponentToucher,
azdEnv map[string]string,
principalID string,
) string {
componentID := privateMonitoringComponentID(azdEnv)
principalID = strings.TrimSpace(principalID)
if componentID == "" || principalID == "" {
return ""
}
if roleAssignmentsSkipped(azdEnv) {
return fmt.Sprintf(
"AZD_AGENT_SKIP_ROLE_ASSIGNMENTS is set, so azd did not grant the hosted agent identity access to "+
"Application Insights. Hosted-agent telemetry is rejected with 403 until it has the role:\n %s",
monitoringGrantCommand(principalID, componentID),
)
}

var lastErr error
for attempt := 1; attempt <= monitoringGrantAttempts; attempt++ {
created, err := assign(
ctx, principalID, roleMonitoringMetricsPublisher, roleMonitoringMetricsPublisherName,
componentID, armauthorization.PrincipalTypeServicePrincipal,
)
if err == nil {
if touch != nil {
if touchErr := touch(ctx, componentID, created); touchErr != nil {
return fmt.Sprintf(
"Granted the hosted agent identity Monitoring Metrics Publisher, but could not refresh %s: %v. "+
"Hosted-agent telemetry can return 403 for a long time until the component is updated; "+
"changing any tag on it forces a refresh.",
componentID, touchErr,
)
}
}
return ""
}
lastErr = err
if !strings.Contains(err.Error(), "PrincipalNotFound") || attempt == monitoringGrantAttempts {
break
}
select {
case <-ctx.Done():
return ""
case <-time.After(monitoringGrantRetryDelay):
}
}

return fmt.Sprintf(
"Could not grant the hosted agent identity Monitoring Metrics Publisher on %s: %v\n"+
"Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s",
componentID, lastErr, monitoringGrantCommand(principalID, componentID),
)
}

func newMonitoringRoleAssigner(credential azcore.TokenCredential, componentID string) (monitoringRoleAssigner, error) {
client, err := armauthorization.NewRoleAssignmentsClient(
extractSubscriptionID(componentID), credential, azure.NewArmClientOptions(),
)
if err != nil {
return nil, fmt.Errorf("failed to create role assignments client: %w", err)
}
return func(
ctx context.Context,
principalID, roleID, roleName, scope string,
principalType armauthorization.PrincipalType,
) (bool, error) {
return assignRoleToIdentityWithClient(ctx, client, principalID, roleID, roleName, scope, principalType)
}, nil
}

// ensureHostedAgentMonitoringPublisher wires the real ARM clients into grantHostedAgentMonitoringPublisher
// and returns its warning for the deploy note.
func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher(
ctx context.Context,
azdEnv map[string]string,
principalID string,
) string {
componentID := privateMonitoringComponentID(azdEnv)
if componentID == "" || strings.TrimSpace(principalID) == "" {
return ""
}
assign, err := newMonitoringRoleAssigner(p.credential, componentID)
if err != nil {
return fmt.Sprintf(
"Could not grant the hosted agent identity Monitoring Metrics Publisher: %v\n"+
"Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s",
err, monitoringGrantCommand(strings.TrimSpace(principalID), componentID),
)
}
touch := func(ctx context.Context, componentID string, created bool) error {
tags, err := armresources.NewTagsClient(
extractSubscriptionID(componentID), p.credential, azure.NewArmClientOptions(),
)
if err != nil {
return err
}
return refreshMonitoringRoleSyncTag(ctx, tags, componentID, created, time.Now())
}
return grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID)
}
Loading
Loading