Skip to content

Commit facc05b

Browse files
authored
fix: azure.ai.connections tenant scoping and connection-less ARM discovery (#9053)
Deploying a `host: azure.ai.connection` service (and running `azd ai connection` commands) failed for multi-tenant / guest users with "Tenant provided in token does not match resource token". The credential was built with no tenant, so tokens were issued for the caller's home tenant instead of the tenant that owns the Foundry resource. Scope the credential to the subscription's user-access tenant, resolved via Account().LookupTenant on AZURE_SUBSCRIPTION_ID with AdditionallyAllowedTenants ["*"], mirroring azure.ai.agents. An empty tenant falls back to the caller's default tenant for flag-only use outside an azd project. Also fix a chicken-and-egg bug on the create path: resolveConnectionContext discovered the ARM subscription and resource group from an existing connection's resource ID, so creating the first connection on a fresh project failed with "no connections found in project; cannot discover ARM context". Prefer the Foundry project's ARM resource ID from the azd environment (AZURE_AI_PROJECT_ID) when it matches the resolved endpoint, falling back to connection-based discovery otherwise. Fixes #9052
1 parent 3168fca commit facc05b

4 files changed

Lines changed: 220 additions & 8 deletions

File tree

cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context.go

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66
import (
77
"context"
88
"fmt"
9+
"log"
910

1011
"azure.ai.connections/internal/exterrors"
1112
"azure.ai.connections/internal/foundry/projectctx"
@@ -14,6 +15,7 @@ import (
1415
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1516
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
1617
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2"
18+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
1719
)
1820

1921
// dataClient is a type alias for the data-plane client (used in endpoint.go).
@@ -51,16 +53,31 @@ func resolveConnectionContext(
5153
return nil, err
5254
}
5355

54-
cred, err := newCredential()
56+
// Read the azd environment once for the values needed to build the clients:
57+
// the subscription's user-access tenant (for credential scoping) and the
58+
// Foundry project's ARM resource ID (for ARM context on connection-less
59+
// projects). Every field is best-effort and may be empty.
60+
envCtx := resolveEnvContext(ctx)
61+
62+
// Scope the credential to the subscription's user-access tenant so tokens are
63+
// issued for the tenant that owns the Foundry resource. Multi-tenant / guest
64+
// users have a home tenant that differs from the resource tenant; without this
65+
// the data-plane and ARM calls below fail with "Tenant provided in token does
66+
// not match resource token". An empty tenant falls back to the caller's
67+
// default tenant (e.g. flag-only use outside an azd project).
68+
cred, err := newCredential(envCtx.tenantID)
5569
if err != nil {
5670
return nil, err
5771
}
5872

5973
// Data-plane client (for list, get-with-credentials, and ARM discovery)
6074
dpClient := connections.NewDataClient(endpoint, cred)
6175

62-
// Discover subscription + resource group from data-plane response
63-
armCtx, err := discoverARMContext(ctx, dpClient)
76+
// Resolve the ARM subscription + resource group. Preferring the azd
77+
// environment's project resource ID lets the first connection be created on a
78+
// project that has none yet (azd up / `azd ai connection create`); discovery
79+
// from an existing connection is the fallback.
80+
armCtx, err := resolveARMContext(ctx, envCtx.projectID, account, project, dpClient)
6481
if err != nil {
6582
return nil, err
6683
}
@@ -84,11 +101,19 @@ func resolveConnectionContext(
84101
}, nil
85102
}
86103

87-
// newCredential creates an Azure credential for API calls.
88-
func newCredential() (azcore.TokenCredential, error) {
89-
cred, err := azidentity.NewAzureDeveloperCLICredential(
90-
&azidentity.AzureDeveloperCLICredentialOptions{},
91-
)
104+
// newCredential creates an Azure credential for API calls. When tenantID is
105+
// non-empty the credential is scoped to that tenant (with all other tenants
106+
// additionally allowed), so multi-tenant / guest users get a token for the
107+
// tenant that owns the Foundry resource. An empty tenantID uses the caller's
108+
// default (home) tenant.
109+
func newCredential(tenantID string) (azcore.TokenCredential, error) {
110+
options := &azidentity.AzureDeveloperCLICredentialOptions{}
111+
if tenantID != "" {
112+
options.TenantID = tenantID
113+
options.AdditionallyAllowedTenants = []string{"*"}
114+
}
115+
116+
cred, err := azidentity.NewAzureDeveloperCLICredential(options)
92117
if err != nil {
93118
return nil, exterrors.Auth(
94119
exterrors.CodeCredentialCreationFailed,
@@ -99,3 +124,77 @@ func newCredential() (azcore.TokenCredential, error) {
99124

100125
return cred, nil
101126
}
127+
128+
// envContext holds the azd-environment-derived values used to build the
129+
// connection clients. Every field is optional; a zero value means the
130+
// corresponding source was unavailable and callers fall back to prior behavior.
131+
type envContext struct {
132+
// tenantID is the user-access tenant for AZURE_SUBSCRIPTION_ID; "" when the
133+
// subscription or tenant lookup is unavailable.
134+
tenantID string
135+
// projectID is AZURE_AI_PROJECT_ID (the Foundry project's ARM resource ID);
136+
// "" when the azd environment does not have it.
137+
projectID string
138+
}
139+
140+
// resolveEnvContext best-effort reads the active azd environment for the values
141+
// needed to build the connection clients: the subscription's user-access tenant
142+
// (credential scoping) and the Foundry project's ARM resource ID (ARM context
143+
// for projects that have no connections yet).
144+
//
145+
// Every field is optional. On a missing azd daemon, environment, or
146+
// subscription the corresponding field is left empty and callers fall back to
147+
// prior behavior. Scoping the credential to the subscription's tenant mirrors
148+
// azure.ai.agents: LookupTenant returns the caller's access tenant for the
149+
// subscription, which is the tenant that owns the Foundry resource - so
150+
// multi-tenant / guest users get a token for that tenant instead of their home
151+
// tenant.
152+
func resolveEnvContext(ctx context.Context) envContext {
153+
var out envContext
154+
155+
azdClient, err := azdext.NewAzdClient()
156+
if err != nil {
157+
log.Printf("connections: no azd client for environment resolution: %v", err)
158+
return out
159+
}
160+
defer azdClient.Close()
161+
162+
envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
163+
if err != nil || envResp.GetEnvironment() == nil {
164+
log.Printf("connections: no active azd environment: %v", err)
165+
return out
166+
}
167+
envName := envResp.GetEnvironment().GetName()
168+
169+
out.projectID = envValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ID")
170+
171+
subID := envValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID")
172+
if subID == "" {
173+
log.Printf("connections: AZURE_SUBSCRIPTION_ID unavailable; using default tenant")
174+
return out
175+
}
176+
177+
tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{
178+
SubscriptionId: subID,
179+
})
180+
if err != nil {
181+
log.Printf("connections: tenant lookup failed for subscription %s: %v", subID, err)
182+
return out
183+
}
184+
out.tenantID = tenantResp.GetTenantId()
185+
186+
return out
187+
}
188+
189+
// envValue reads a single value from the named azd environment, returning ""
190+
// when the key is unset or the read fails.
191+
func envValue(ctx context.Context, azdClient *azdext.AzdClient, envName, key string) string {
192+
resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
193+
EnvName: envName,
194+
Key: key,
195+
})
196+
if err != nil {
197+
return ""
198+
}
199+
return resp.GetValue()
200+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestNewCredential covers both credential shapes: the default (home) tenant
13+
// when no tenant is resolved, and a tenant-scoped credential for multi-tenant /
14+
// guest users. The tenant-scoped branch is what fixes "Tenant provided in token
15+
// does not match resource token".
16+
func TestNewCredential(t *testing.T) {
17+
t.Parallel()
18+
19+
tests := []struct {
20+
name string
21+
tenantID string
22+
}{
23+
{name: "default tenant", tenantID: ""},
24+
{name: "scoped tenant", tenantID: "11111111-1111-1111-1111-111111111111"},
25+
}
26+
27+
for _, tc := range tests {
28+
t.Run(tc.name, func(t *testing.T) {
29+
t.Parallel()
30+
31+
cred, err := newCredential(tc.tenantID)
32+
require.NoError(t, err)
33+
require.NotNil(t, cred)
34+
})
35+
}
36+
}

cli/azd/extensions/azure.ai.connections/internal/cmd/endpoint.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66
import (
77
"context"
88
"fmt"
9+
"log"
910
"net/url"
1011
"strings"
1112
)
@@ -46,6 +47,39 @@ type armContext struct {
4647
ProjectName string
4748
}
4849

50+
// resolveARMContext resolves the ARM subscription + resource group for the
51+
// project.
52+
//
53+
// It prefers projectID - the Foundry project's ARM resource ID, read from the
54+
// azd environment's AZURE_AI_PROJECT_ID - when it is present and matches the
55+
// resolved endpoint's account/project. That path works even when the project
56+
// has no connections yet, which is required to create the first connection via
57+
// `azd up` or `azd ai connection create`. When projectID is missing, malformed,
58+
// or points at a different project, it falls back to discovering the context
59+
// from an existing connection's ARM resource ID.
60+
func resolveARMContext(
61+
ctx context.Context,
62+
projectID, account, project string,
63+
dpClient *dataClient,
64+
) (*armContext, error) {
65+
if projectID != "" {
66+
armCtx, err := parseARMResourceID(projectID)
67+
switch {
68+
case err != nil:
69+
log.Printf("connections: could not parse AZURE_AI_PROJECT_ID %q; "+
70+
"falling back to connection discovery: %v", projectID, err)
71+
case !strings.EqualFold(armCtx.AccountName, account) ||
72+
!strings.EqualFold(armCtx.ProjectName, project):
73+
log.Printf("connections: AZURE_AI_PROJECT_ID %q does not match resolved "+
74+
"endpoint %s/%s; falling back to connection discovery", projectID, account, project)
75+
default:
76+
return armCtx, nil
77+
}
78+
}
79+
80+
return discoverARMContext(ctx, dpClient)
81+
}
82+
4983
// discoverARMContext makes a data-plane list call to discover subscription and
5084
// resource group from the ARM resource IDs embedded in connection responses.
5185
func discoverARMContext(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestResolveARMContext_PrefersEnvProjectID verifies that a matching
14+
// AZURE_AI_PROJECT_ID resolves the ARM context without any data-plane call, so
15+
// the first connection can be created on a project that has none yet. The nil
16+
// dpClient asserts that discovery is not reached on this path.
17+
func TestResolveARMContext_PrefersEnvProjectID(t *testing.T) {
18+
t.Parallel()
19+
20+
projectID := "/subscriptions/sub-123/resourceGroups/rg-abc/providers/" +
21+
"Microsoft.CognitiveServices/accounts/cog-xyz/projects/proj-1"
22+
23+
armCtx, err := resolveARMContext(t.Context(), projectID, "cog-xyz", "proj-1", nil)
24+
require.NoError(t, err)
25+
assert.Equal(t, "sub-123", armCtx.SubscriptionID)
26+
assert.Equal(t, "rg-abc", armCtx.ResourceGroup)
27+
assert.Equal(t, "cog-xyz", armCtx.AccountName)
28+
assert.Equal(t, "proj-1", armCtx.ProjectName)
29+
}
30+
31+
// TestResolveARMContext_MatchIsCaseInsensitive verifies the account/project
32+
// guard tolerates casing differences between the endpoint host and the ARM
33+
// resource ID, which are case-insensitive in Azure.
34+
func TestResolveARMContext_MatchIsCaseInsensitive(t *testing.T) {
35+
t.Parallel()
36+
37+
projectID := "/subscriptions/sub-123/resourceGroups/rg-abc/providers/" +
38+
"Microsoft.CognitiveServices/accounts/Cog-XYZ/projects/Proj-1"
39+
40+
armCtx, err := resolveARMContext(t.Context(), projectID, "cog-xyz", "proj-1", nil)
41+
require.NoError(t, err)
42+
assert.Equal(t, "rg-abc", armCtx.ResourceGroup)
43+
}

0 commit comments

Comments
 (0)