diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index c25b4736b78..9d516fe7c61 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -3060,6 +3060,41 @@ func getSubscriptionDetails(ctx context.Context, azdClient *azdext.AzdClient, su - Validate subscription access before performing operations - Set up proper authentication context for Azure SDK calls +#### GetCurrentPrincipal + +This preview method resolves the current identity for role assignments in a specified subscription. The host returns the object ID in the subscription's resource tenant, which can differ from a guest user's home-tenant object ID. Unlike `LookupTenant`, this method uses the resource tenant rather than the user access tenant. + +| Field | Description | +|---|---| +| Request `subscription_id` | Required subscription ID. No active environment or default subscription is used. | +| Response `object_id` | Object ID of the signed-in identity in the resource tenant, not an application client ID. | +| Response `principal_type` | `PRINCIPAL_TYPE_USER` or `PRINCIPAL_TYPE_SERVICE_PRINCIPAL`, determined from azd's login details. | + +The host reuses its principal lookup, including the ARM token `oid` claim and Graph fallback. Service-principal logins and both system-assigned and user-assigned managed identities return `PRINCIPAL_TYPE_SERVICE_PRINCIPAL`. Access tokens are neither accepted nor returned by this RPC. An empty subscription ID returns `InvalidArgument`; authentication, subscription, and principal lookup failures return errors rather than an empty identity. + +```go +// Import v1beta "github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta". +principal, err := azdClient.AccountBeta().GetCurrentPrincipal(ctx, &v1beta.GetCurrentPrincipalRequest{ + SubscriptionId: subscriptionId, +}) +if err != nil { + return fmt.Errorf("resolving current principal: %w", err) +} + +var principalType string +switch principal.PrincipalType { +case v1beta.PrincipalType_PRINCIPAL_TYPE_USER: + principalType = "User" +case v1beta.PrincipalType_PRINCIPAL_TYPE_SERVICE_PRINCIPAL: + principalType = "ServicePrincipal" +default: + return fmt.Errorf("unsupported principal type: %v", principal.PrincipalType) +} +// Pass principal.ObjectId and principalType to the role assignment. +``` + +This method and its request, response, and enum types are available only in [`v1beta`](../../grpc/proto/azd/extensions/v1beta/account.proto). `Account()` remains the unchanged stable client; use `AccountBeta()` for principal lookup. Older azd hosts return `Unimplemented`. Extensions must consume an SDK release containing the method and require a host release that supports it before removing their existing principal lookup. + --- ### Copilot Service diff --git a/cli/azd/docs/extensions/extension-sdk-reference.md b/cli/azd/docs/extensions/extension-sdk-reference.md index c073942e545..dc1b84c1499 100644 --- a/cli/azd/docs/extensions/extension-sdk-reference.md +++ b/cli/azd/docs/extensions/extension-sdk-reference.md @@ -519,16 +519,20 @@ gRPC client connecting to the azd framework. Auto-discovers the socket via | `Container()` | `ContainerServiceClient` | | `Extension()` | `ExtensionServiceClient` | | `Account()` | `AccountServiceClient` | +| `AccountBeta()` | `v1beta.AccountServiceClient` (preview) | | `Ai()` | `AiModelServiceClient` | | `Copilot()` | `v1beta.CopilotServiceClient` (preview) | | `Telemetry()` | `v1beta.TelemetryServiceClient` (preview) | Always call `defer client.Close()` after creation. -`Compose()`, `Copilot()`, and `Telemetry()` are preview accessors. Import -`github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta` for their -request, response, and enum types. They are intentionally excluded from the -stable `azdext` contract facade until those services graduate to `v1`. +`AccountBeta()`, `Compose()`, `Copilot()`, and `Telemetry()` are preview accessors. Import `github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta` for their request, response, and enum types. Beta-only methods and types are not exposed through the stable `azdext` contract facade. `Account()` still provides the existing stable account methods. + +#### AccountService + +`AccountBeta().GetCurrentPrincipal(ctx, &v1beta.GetCurrentPrincipalRequest{SubscriptionId: subscriptionID})` returns the current identity's `ObjectId` in the subscription's resource tenant and its `PrincipalType` enum. Import `github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta` for these preview types. Use both values for role assignments instead of decoding access tokens in the extension. The subscription ID is required, and no active environment is needed. The stable `Account()` client remains unchanged and does not expose this method. + +See [GetCurrentPrincipal](extension-framework.md#getcurrentprincipal) for the enum mapping, guest-user behavior, and host compatibility requirements. #### TelemetryService diff --git a/cli/azd/grpc/proto/azd/extensions/v1beta/account.proto b/cli/azd/grpc/proto/azd/extensions/v1beta/account.proto index e51b207c5f8..7614a60f3ee 100644 --- a/cli/azd/grpc/proto/azd/extensions/v1beta/account.proto +++ b/cli/azd/grpc/proto/azd/extensions/v1beta/account.proto @@ -14,6 +14,9 @@ service AccountService { // LookupTenant resolves the tenant ID required to access a specific subscription. rpc LookupTenant (LookupTenantRequest) returns (LookupTenantResponse); + + // GetCurrentPrincipal resolves the signed-in identity in the subscription's resource tenant. + rpc GetCurrentPrincipal (GetCurrentPrincipalRequest) returns (GetCurrentPrincipalResponse); } message ListSubscriptionsRequest { @@ -34,3 +37,21 @@ message LookupTenantResponse { // The tenant ID required to access the subscription. string tenant_id = 1; } + +message GetCurrentPrincipalRequest { + // Required subscription ID. The active environment is not used as a default. + string subscription_id = 1; +} + +message GetCurrentPrincipalResponse { + // Object ID in the subscription's resource tenant, not the user's home tenant or an application client ID. + string object_id = 1; + // Principal type determined from the host's login details. + PrincipalType principal_type = 2; +} + +enum PrincipalType { + PRINCIPAL_TYPE_UNSPECIFIED = 0; + PRINCIPAL_TYPE_USER = 1; + PRINCIPAL_TYPE_SERVICE_PRINCIPAL = 2; +} diff --git a/cli/azd/internal/grpcserver/account_service.go b/cli/azd/internal/grpcserver/account_service.go index 59cdfa2d9e0..bd765614b57 100644 --- a/cli/azd/internal/grpcserver/account_service.go +++ b/cli/azd/internal/grpcserver/account_service.go @@ -5,22 +5,84 @@ package grpcserver import ( "context" + "fmt" + "strings" "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + v1beta "github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta" + "github.com/azure/azure-dev/cli/azd/pkg/azureutil" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type accountService struct { azdext.UnimplementedAccountServiceServer - subscriptionsManager *account.SubscriptionsManager + subscriptionsManager interface { + account.SubscriptionResolver + GetSubscriptions(context.Context) ([]account.Subscription, error) + LookupTenant(context.Context, string) (string, error) + } + userProfileService *azapi.UserProfileService + principalTypeProvider interface { + CurrentPrincipalType(context.Context) (auth.PrincipalType, error) + } } -func NewAccountService(subscriptionsManager *account.SubscriptionsManager) azdext.AccountServiceServer { +func NewAccountService( + subscriptionsManager *account.SubscriptionsManager, + userProfileService *azapi.UserProfileService, + authManager *auth.Manager, +) azdext.AccountServiceServer { return &accountService{ - subscriptionsManager: subscriptionsManager, + subscriptionsManager: subscriptionsManager, + userProfileService: userProfileService, + principalTypeProvider: authManager, + } +} + +var _ BetaAccountServiceGetCurrentPrincipalOverride = (*accountService)(nil) + +func (s *accountService) GetCurrentPrincipal( + ctx context.Context, + req *v1beta.GetCurrentPrincipalRequest, +) (*v1beta.GetCurrentPrincipalResponse, error) { + if strings.TrimSpace(req.GetSubscriptionId()) == "" { + return nil, status.Error(codes.InvalidArgument, "subscription id is required") + } + + principalType, err := s.principalTypeProvider.CurrentPrincipalType(ctx) + if err != nil { + return nil, err + } + + var protoType v1beta.PrincipalType + switch principalType { + case auth.UserPrincipalType: + protoType = v1beta.PrincipalType_PRINCIPAL_TYPE_USER + case auth.ServicePrincipalType: + protoType = v1beta.PrincipalType_PRINCIPAL_TYPE_SERVICE_PRINCIPAL + default: + return nil, status.Error(codes.Internal, "unsupported current principal type") + } + + subscription, err := s.subscriptionsManager.GetSubscription(ctx, req.SubscriptionId) + if err != nil { + return nil, fmt.Errorf("getting subscription %s: %w", req.SubscriptionId, err) } + + // Role assignments need the object ID in the resource tenant, even when access uses another tenant. + objectID, err := azureutil.GetCurrentPrincipalId(ctx, s.userProfileService, subscription.TenantId) + if err != nil { + return nil, fmt.Errorf("fetching current principal information: %w", err) + } + + return &v1beta.GetCurrentPrincipalResponse{ + ObjectId: objectID, + PrincipalType: protoType, + }, nil } func (s *accountService) ListSubscriptions( diff --git a/cli/azd/internal/grpcserver/account_service_test.go b/cli/azd/internal/grpcserver/account_service_test.go index 8ba52f6f01c..7a10cb9f909 100644 --- a/cli/azd/internal/grpcserver/account_service_test.go +++ b/cli/azd/internal/grpcserver/account_service_test.go @@ -4,23 +4,43 @@ package grpcserver import ( + "context" + "errors" + "net/http" + "sync/atomic" "testing" + "time" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + v1beta "github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta" + "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" ) func TestNewAccountService(t *testing.T) { t.Parallel() - svc := NewAccountService(nil) + svc := NewAccountService(nil, nil, nil) require.NotNil(t, svc) } func TestAccountService_LookupTenant_EmptySubscriptionId(t *testing.T) { t.Parallel() - svc := NewAccountService(nil) + svc := NewAccountService(nil, nil, nil) _, err := svc.LookupTenant(t.Context(), &azdext.LookupTenantRequest{ SubscriptionId: "", }) @@ -30,3 +50,250 @@ func TestAccountService_LookupTenant_EmptySubscriptionId(t *testing.T) { require.Equal(t, codes.InvalidArgument, st.Code()) require.Contains(t, st.Message(), "subscription id is required") } + +type mockAccountSubscriptions struct { + *account.SubscriptionsManager + mock.Mock +} + +func (m *mockAccountSubscriptions) GetSubscription( + ctx context.Context, subscriptionID string, +) (*account.Subscription, error) { + args := m.Called(ctx, subscriptionID) + subscription, _ := args.Get(0).(*account.Subscription) + return subscription, args.Error(1) +} + +type mockAccountPrincipalType struct { + mock.Mock +} + +func (m *mockAccountPrincipalType) CurrentPrincipalType(ctx context.Context) (auth.PrincipalType, error) { + args := m.Called(ctx) + return args.Get(0).(auth.PrincipalType), args.Error(1) +} + +func TestAccountService_GetCurrentPrincipal(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + accessTenant string + principalType auth.PrincipalType + protoType v1beta.PrincipalType + }{ + {"user", "resource-tenant", auth.UserPrincipalType, v1beta.PrincipalType_PRINCIPAL_TYPE_USER}, + {"guest", "home-tenant", auth.UserPrincipalType, v1beta.PrincipalType_PRINCIPAL_TYPE_USER}, + { + "service principal", "resource-tenant", auth.ServicePrincipalType, + v1beta.PrincipalType_PRINCIPAL_TYPE_SERVICE_PRINCIPAL, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + subscriptions := &mockAccountSubscriptions{} + subscriptions.On("GetSubscription", mock.Anything, "sub-123").Return(&account.Subscription{ + Id: "sub-123", TenantId: "resource-tenant", UserAccessTenantId: tt.accessTenant, + }, nil).Once() + principalTypes := &mockAccountPrincipalType{} + principalTypes.On("CurrentPrincipalType", mock.Anything).Return(tt.principalType, nil).Once() + + mockContext := mocks.NewMockContext(ctx) + azureCloud := cloud.AzurePublic() + armScope := azureCloud.Configuration.Services[azcloud.ResourceManager].Audience + "/.default" + var tokenCalls atomic.Int32 + userProfile := azapi.NewUserProfileService( + &mocks.MockMultiTenantCredentialProvider{TokenMap: map[string]mocks.MockCredentials{ + "resource-tenant": { + GetTokenFn: func( + ctx context.Context, options policy.TokenRequestOptions, + ) (azcore.AccessToken, error) { + tokenCalls.Add(1) + assert.Equal(t, []string{armScope}, options.Scopes) + // No principal-type claims: the host must use login details, not token heuristics. + return azcore.AccessToken{ + Token: mocks.CreateJwtToken(t, map[string]string{"oid": "resource-object-id"}), + ExpiresOn: time.Now().Add(time.Hour), + }, nil + }, + }, + "home-tenant": { + GetTokenFn: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + t.Error("principal lookup must not acquire a home-tenant token") + return azcore.AccessToken{}, errors.New("unexpected home tenant") + }, + }, + }}, + &azcore.ClientOptions{Transport: mockContext.HttpClient}, + azureCloud, + ) + svc := &accountService{ + subscriptionsManager: subscriptions, userProfileService: userProfile, principalTypeProvider: principalTypes, + } + + server := NewServer( + azdext.UnimplementedProjectServiceServer{}, + azdext.UnimplementedEnvironmentServiceServer{}, + azdext.UnimplementedPromptServiceServer{}, + azdext.UnimplementedUserConfigServiceServer{}, + azdext.UnimplementedDeploymentServiceServer{}, + azdext.UnimplementedEventServiceServer{}, + v1beta.UnimplementedComposeServiceServer{}, + azdext.UnimplementedWorkflowServiceServer{}, + azdext.UnimplementedExtensionServiceServer{}, + azdext.UnimplementedServiceTargetServiceServer{}, + azdext.UnimplementedFrameworkServiceServer{}, + azdext.UnimplementedContainerServiceServer{}, + svc, + azdext.UnimplementedAiModelServiceServer{}, + v1beta.UnimplementedCopilotServiceServer{}, + azdext.UnimplementedProvisioningServiceServer{}, + azdext.UnimplementedValidationServiceServer{}, + v1beta.UnimplementedTelemetryServiceServer{}, + ) + info, err := server.Start() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, server.Stop()) }) + token, err := GenerateExtensionToken(&extensions.Extension{Id: "azd.internal.test", Namespace: "test"}, info) + require.NoError(t, err) + ctx = azdext.WithAccessToken(ctx, token) + client, err := azdext.NewAzdClient(azdext.WithAddress(info.Address)) + require.NoError(t, err) + t.Cleanup(client.Close) + + response, err := client.AccountBeta().GetCurrentPrincipal(ctx, &v1beta.GetCurrentPrincipalRequest{ + SubscriptionId: "sub-123", + }) + require.NoError(t, err) + require.Equal(t, "resource-object-id", response.ObjectId) + require.Equal(t, tt.protoType, response.PrincipalType) + + connection, err := grpc.NewClient(info.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, connection.Close()) }) + for _, service := range []string{"azd.extensions.v1.AccountService", "azdext.AccountService"} { + err := connection.Invoke( + ctx, "/"+service+"/GetCurrentPrincipal", + &v1beta.GetCurrentPrincipalRequest{SubscriptionId: "sub-123"}, + &v1beta.GetCurrentPrincipalResponse{}, + ) + require.Equal(t, codes.Unimplemented, status.Code(err)) + } + require.EqualValues(t, 1, tokenCalls.Load()) + subscriptions.AssertExpectations(t) + principalTypes.AssertExpectations(t) + }) + } +} + +func TestAccountService_GetCurrentPrincipal_InvalidRequest(t *testing.T) { + t.Parallel() + for _, request := range []*v1beta.GetCurrentPrincipalRequest{ + nil, {}, {SubscriptionId: " \t"}, + } { + // No dependencies: validation must run before authentication or subscription lookup. + response, err := (&accountService{}).GetCurrentPrincipal(t.Context(), request) + require.Nil(t, response) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + } +} + +func TestAccountService_GetCurrentPrincipal_LookupErrors(t *testing.T) { + t.Parallel() + subscriptionErr := errors.New("subscription unavailable") + for _, tt := range []struct { + name string + principalType auth.PrincipalType + loginErr error + subErr error + wantErr error + wantCode codes.Code + }{ + {name: "not logged in", loginErr: auth.ErrNoCurrentUser, wantErr: auth.ErrNoCurrentUser}, + {name: "login cancelled", loginErr: context.Canceled, wantErr: context.Canceled}, + {name: "unsupported type", principalType: "unknown", wantCode: codes.Internal}, + {name: "subscription", principalType: auth.UserPrincipalType, subErr: subscriptionErr, wantErr: subscriptionErr}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + subscriptions := &mockAccountSubscriptions{} + if tt.subErr != nil { + subscriptions.On("GetSubscription", t.Context(), "sub-123").Return(nil, tt.subErr).Once() + } + principalTypes := &mockAccountPrincipalType{} + principalTypes.On("CurrentPrincipalType", t.Context()).Return(tt.principalType, tt.loginErr).Once() + svc := &accountService{principalTypeProvider: principalTypes, subscriptionsManager: subscriptions} + response, err := svc.GetCurrentPrincipal(t.Context(), &v1beta.GetCurrentPrincipalRequest{ + SubscriptionId: "sub-123", + }) + require.Nil(t, response) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.Equal(t, tt.wantCode, status.Code(err)) + } + subscriptions.AssertExpectations(t) + principalTypes.AssertExpectations(t) + }) + } +} + +func TestAccountService_GetCurrentPrincipal_GraphFallback(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + graphID string + statusCode int + wantError string + }{ + {name: "success", graphID: "graph-object-id", statusCode: http.StatusOK}, + {name: "lookup failure", statusCode: http.StatusForbidden, wantError: "fetching current principal information"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + subscriptions := &mockAccountSubscriptions{} + subscriptions.On("GetSubscription", t.Context(), "sub-123").Return(&account.Subscription{ + Id: "sub-123", TenantId: "resource-tenant", UserAccessTenantId: "home-tenant", + }, nil).Once() + principalTypes := &mockAccountPrincipalType{} + principalTypes.On("CurrentPrincipalType", t.Context()).Return(auth.UserPrincipalType, nil).Once() + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && request.URL.Host == "graph.microsoft.com" + }).RespondFn(func(request *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(request, tt.statusCode, &graphsdk.UserProfile{Id: tt.graphID}) + }) + userProfile := azapi.NewUserProfileService( + &mocks.MockMultiTenantCredentialProvider{TokenMap: map[string]mocks.MockCredentials{ + "resource-tenant": { + GetTokenFn: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{ + Token: mocks.CreateJwtToken(t, map[string]string{}), ExpiresOn: time.Now().Add(time.Hour), + }, nil + }, + }, + }}, + &azcore.ClientOptions{Transport: mockContext.HttpClient}, + cloud.AzurePublic(), + ) + svc := &accountService{ + subscriptionsManager: subscriptions, principalTypeProvider: principalTypes, userProfileService: userProfile, + } + response, err := svc.GetCurrentPrincipal(t.Context(), &v1beta.GetCurrentPrincipalRequest{ + SubscriptionId: "sub-123", + }) + if tt.wantError != "" { + require.ErrorContains(t, err, tt.wantError) + require.Nil(t, response) + } else { + require.NoError(t, err) + require.Equal(t, tt.graphID, response.ObjectId) + require.Equal(t, v1beta.PrincipalType_PRINCIPAL_TYPE_USER, response.PrincipalType) + } + subscriptions.AssertExpectations(t) + principalTypes.AssertExpectations(t) + }) + } +} diff --git a/cli/azd/internal/grpcserver/server.go b/cli/azd/internal/grpcserver/server.go index 33cfeccd91c..5cc1c66e839 100644 --- a/cli/azd/internal/grpcserver/server.go +++ b/cli/azd/internal/grpcserver/server.go @@ -72,7 +72,7 @@ func NewServer( validationService azdext.ValidationServiceServer, telemetryService v1beta.TelemetryServiceServer, ) *Server { - return &Server{ + server := &Server{ projectService: projectService, environmentService: environmentService, promptService: promptService, @@ -93,6 +93,10 @@ func NewServer( telemetryService: telemetryService, betaServiceOverrides: map[BetaService]any{}, } + if principalService, ok := accountService.(BetaAccountServiceGetCurrentPrincipalOverride); ok { + server.WithOptions(WithBetaServiceOverride(BetaAccountService, principalService)) + } + return server } // WithOptions applies optional beta service configuration before the server starts. diff --git a/cli/azd/internal/grpcserver/versioned_services_generated.go b/cli/azd/internal/grpcserver/versioned_services_generated.go index 84c7d249961..fbc5366f81e 100644 --- a/cli/azd/internal/grpcserver/versioned_services_generated.go +++ b/cli/azd/internal/grpcserver/versioned_services_generated.go @@ -65,6 +65,11 @@ type BetaAccountServiceLookupTenantOverride interface { LookupTenant(context.Context, *v1beta.LookupTenantRequest) (*v1beta.LookupTenantResponse, error) } +// BetaAccountServiceGetCurrentPrincipalOverride overrides the beta AccountService.GetCurrentPrincipal method before stable adaptation. +type BetaAccountServiceGetCurrentPrincipalOverride interface { + GetCurrentPrincipal(context.Context, *v1beta.GetCurrentPrincipalRequest) (*v1beta.GetCurrentPrincipalResponse, error) +} + func validateBetaAccountServiceOverride(override any) error { return validateBetaServiceOverride( "AccountService", @@ -72,6 +77,7 @@ func validateBetaAccountServiceOverride(override any) error { reflect.TypeFor[v1beta.AccountServiceServer](), reflect.TypeFor[BetaAccountServiceListSubscriptionsOverride](), reflect.TypeFor[BetaAccountServiceLookupTenantOverride](), + reflect.TypeFor[BetaAccountServiceGetCurrentPrincipalOverride](), ) } @@ -847,6 +853,16 @@ func (a *betaAccountServiceAdapter) LookupTenant( ) } +func (a *betaAccountServiceAdapter) GetCurrentPrincipal( + ctx context.Context, + req *v1beta.GetCurrentPrincipalRequest, +) (*v1beta.GetCurrentPrincipalResponse, error) { + if override, ok := a.override.(BetaAccountServiceGetCurrentPrincipalOverride); ok { + return override.GetCurrentPrincipal(ctx, req) + } + return a.UnimplementedAccountServiceServer.GetCurrentPrincipal(ctx, req) +} + type betaAiModelServiceAdapter struct { v1beta.UnimplementedAiModelServiceServer stable v1.AiModelServiceServer diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index 73ef83056f0..0fb9e71103f 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -1475,7 +1475,8 @@ type LogInDetails struct { // outbound HTTP call) and derive the account identifier from the token claims. // When running in Azure Cloud Shell and no azd-managed user is logged in, // it derives the account from the ambient Cloud Shell credential and reports -// an authenticated user. +// an authenticated user. System-assigned managed identities report a client ID-based +// login with an empty account identifier because no client ID was configured. func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { if m.UseExternalAuth() { claims, err := m.ClaimsForCurrentUser(ctx, nil) @@ -1567,6 +1568,10 @@ func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { LoginType: ClientIdLoginType, Account: *currentUser.ClientID, }, nil + } else if currentUser.ManagedIdentity { + return &LogInDetails{ + LoginType: ClientIdLoginType, + }, nil } return nil, ErrNoCurrentUser diff --git a/cli/azd/pkg/auth/manager_test.go b/cli/azd/pkg/auth/manager_test.go index dfedaa954bf..078d30609e2 100644 --- a/cli/azd/pkg/auth/manager_test.go +++ b/cli/azd/pkg/auth/manager_test.go @@ -1060,6 +1060,14 @@ func TestLoginWithManagedIdentity(t *testing.T) { cred2, err := m.CredentialForCurrentUser(t.Context(), nil) require.NoError(t, err) require.IsType(t, new(azidentity.ManagedIdentityCredential), cred2) + + details, err := m.LogInDetails(t.Context()) + require.NoError(t, err) + require.Equal(t, ClientIdLoginType, details.LoginType) + require.Empty(t, details.Account) + principalType, err := m.CurrentPrincipalType(t.Context()) + require.NoError(t, err) + require.Equal(t, ServicePrincipalType, principalType) }) t.Run("WithClientID", func(t *testing.T) { @@ -1073,6 +1081,14 @@ func TestLoginWithManagedIdentity(t *testing.T) { cred, err := m.LoginWithManagedIdentity(t.Context(), "my-client-id") require.NoError(t, err) require.IsType(t, new(azidentity.ManagedIdentityCredential), cred) + + details, err := m.LogInDetails(t.Context()) + require.NoError(t, err) + require.Equal(t, ClientIdLoginType, details.LoginType) + require.Equal(t, "my-client-id", details.Account) + principalType, err := m.CurrentPrincipalType(t.Context()) + require.NoError(t, err) + require.Equal(t, ServicePrincipalType, principalType) }) } @@ -1373,6 +1389,10 @@ func TestLogInDetails_ServicePrincipalNative(t *testing.T) { require.NoError(t, err) assert.Equal(t, ClientIdLoginType, details.LoginType) assert.Equal(t, "myClientId", details.Account) + + principalType, err := m.CurrentPrincipalType(t.Context()) + require.NoError(t, err) + assert.Equal(t, ServicePrincipalType, principalType) } func TestLogInDetails_InteractiveUser(t *testing.T) { @@ -1400,6 +1420,10 @@ func TestLogInDetails_InteractiveUser(t *testing.T) { require.NoError(t, err) assert.Equal(t, EmailLoginType, details.LoginType) assert.Equal(t, "user@example.com", details.Account) + + principalType, err := m.CurrentPrincipalType(t.Context()) + require.NoError(t, err) + assert.Equal(t, UserPrincipalType, principalType) } func TestLogInDetails_NotLoggedIn(t *testing.T) { @@ -1412,6 +1436,9 @@ func TestLogInDetails_NotLoggedIn(t *testing.T) { _, err := m.LogInDetails(t.Context()) require.Error(t, err) assert.ErrorIs(t, err, ErrNoCurrentUser) + principalType, err := m.CurrentPrincipalType(t.Context()) + require.ErrorIs(t, err, ErrNoCurrentUser) + assert.Empty(t, principalType) } func TestLogInDetails_HomeAccountNotFound(t *testing.T) { diff --git a/cli/azd/pkg/auth/principal.go b/cli/azd/pkg/auth/principal.go new file mode 100644 index 00000000000..1662902a272 --- /dev/null +++ b/cli/azd/pkg/auth/principal.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package auth + +import ( + "context" + "fmt" +) + +// PrincipalType is the identity type used for Azure role assignments. +type PrincipalType string + +const ( + // UserPrincipalType identifies an interactive user. + UserPrincipalType PrincipalType = "User" + // ServicePrincipalType identifies an application or managed identity. + ServicePrincipalType PrincipalType = "ServicePrincipal" +) + +// CurrentPrincipalType returns the principal type from the recorded login details. +func (m *Manager) CurrentPrincipalType(ctx context.Context) (PrincipalType, error) { + loginDetails, err := m.LogInDetails(ctx) + if err != nil { + return "", fmt.Errorf("fetching login details: %w", err) + } + + if loginDetails.LoginType == ClientIdLoginType { + return ServicePrincipalType, nil + } + + return UserPrincipalType, nil +} diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index 325a1fb3fa7..17f0b616496 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -271,6 +271,11 @@ func (c *AzdClient) Account() AccountServiceClient { return c.accountClient } +// AccountBeta returns the preview account service client, including current principal lookup. +func (c *AzdClient) AccountBeta() v1beta.AccountServiceClient { + return v1beta.NewAccountServiceClient(c.connection) +} + // Ai returns the AI model service client. func (c *AzdClient) Ai() AiModelServiceClient { if c.aiClient == nil { diff --git a/cli/azd/pkg/azdext/contracts/v1beta/account.pb.go b/cli/azd/pkg/azdext/contracts/v1beta/account.pb.go index cd52e0a0899..2871975d065 100644 --- a/cli/azd/pkg/azdext/contracts/v1beta/account.pb.go +++ b/cli/azd/pkg/azdext/contracts/v1beta/account.pb.go @@ -24,6 +24,55 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type PrincipalType int32 + +const ( + PrincipalType_PRINCIPAL_TYPE_UNSPECIFIED PrincipalType = 0 + PrincipalType_PRINCIPAL_TYPE_USER PrincipalType = 1 + PrincipalType_PRINCIPAL_TYPE_SERVICE_PRINCIPAL PrincipalType = 2 +) + +// Enum value maps for PrincipalType. +var ( + PrincipalType_name = map[int32]string{ + 0: "PRINCIPAL_TYPE_UNSPECIFIED", + 1: "PRINCIPAL_TYPE_USER", + 2: "PRINCIPAL_TYPE_SERVICE_PRINCIPAL", + } + PrincipalType_value = map[string]int32{ + "PRINCIPAL_TYPE_UNSPECIFIED": 0, + "PRINCIPAL_TYPE_USER": 1, + "PRINCIPAL_TYPE_SERVICE_PRINCIPAL": 2, + } +) + +func (x PrincipalType) Enum() *PrincipalType { + p := new(PrincipalType) + *p = x + return p +} + +func (x PrincipalType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PrincipalType) Descriptor() protoreflect.EnumDescriptor { + return file_azd_extensions_v1beta_account_proto_enumTypes[0].Descriptor() +} + +func (PrincipalType) Type() protoreflect.EnumType { + return &file_azd_extensions_v1beta_account_proto_enumTypes[0] +} + +func (x PrincipalType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PrincipalType.Descriptor instead. +func (PrincipalType) EnumDescriptor() ([]byte, []int) { + return file_azd_extensions_v1beta_account_proto_rawDescGZIP(), []int{0} +} + type ListSubscriptionsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional: filter subscriptions by tenant ID. @@ -203,6 +252,105 @@ func (x *LookupTenantResponse) GetTenantId() string { return "" } +type GetCurrentPrincipalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required subscription ID. The active environment is not used as a default. + SubscriptionId string `protobuf:"bytes,1,opt,name=subscription_id,json=subscriptionId,proto3" json:"subscription_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCurrentPrincipalRequest) Reset() { + *x = GetCurrentPrincipalRequest{} + mi := &file_azd_extensions_v1beta_account_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCurrentPrincipalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCurrentPrincipalRequest) ProtoMessage() {} + +func (x *GetCurrentPrincipalRequest) ProtoReflect() protoreflect.Message { + mi := &file_azd_extensions_v1beta_account_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCurrentPrincipalRequest.ProtoReflect.Descriptor instead. +func (*GetCurrentPrincipalRequest) Descriptor() ([]byte, []int) { + return file_azd_extensions_v1beta_account_proto_rawDescGZIP(), []int{4} +} + +func (x *GetCurrentPrincipalRequest) GetSubscriptionId() string { + if x != nil { + return x.SubscriptionId + } + return "" +} + +type GetCurrentPrincipalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Object ID in the subscription's resource tenant, not the user's home tenant or an application client ID. + ObjectId string `protobuf:"bytes,1,opt,name=object_id,json=objectId,proto3" json:"object_id,omitempty"` + // Principal type determined from the host's login details. + PrincipalType PrincipalType `protobuf:"varint,2,opt,name=principal_type,json=principalType,proto3,enum=azd.extensions.v1beta.PrincipalType" json:"principal_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCurrentPrincipalResponse) Reset() { + *x = GetCurrentPrincipalResponse{} + mi := &file_azd_extensions_v1beta_account_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCurrentPrincipalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCurrentPrincipalResponse) ProtoMessage() {} + +func (x *GetCurrentPrincipalResponse) ProtoReflect() protoreflect.Message { + mi := &file_azd_extensions_v1beta_account_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCurrentPrincipalResponse.ProtoReflect.Descriptor instead. +func (*GetCurrentPrincipalResponse) Descriptor() ([]byte, []int) { + return file_azd_extensions_v1beta_account_proto_rawDescGZIP(), []int{5} +} + +func (x *GetCurrentPrincipalResponse) GetObjectId() string { + if x != nil { + return x.ObjectId + } + return "" +} + +func (x *GetCurrentPrincipalResponse) GetPrincipalType() PrincipalType { + if x != nil { + return x.PrincipalType + } + return PrincipalType_PRINCIPAL_TYPE_UNSPECIFIED +} + var File_azd_extensions_v1beta_account_proto protoreflect.FileDescriptor const file_azd_extensions_v1beta_account_proto_rawDesc = "" + @@ -217,10 +365,20 @@ const file_azd_extensions_v1beta_account_proto_rawDesc = "" + "\x13LookupTenantRequest\x12'\n" + "\x0fsubscription_id\x18\x01 \x01(\tR\x0esubscriptionId\"3\n" + "\x14LookupTenantResponse\x12\x1b\n" + - "\ttenant_id\x18\x01 \x01(\tR\btenantId2\xf1\x01\n" + + "\ttenant_id\x18\x01 \x01(\tR\btenantId\"E\n" + + "\x1aGetCurrentPrincipalRequest\x12'\n" + + "\x0fsubscription_id\x18\x01 \x01(\tR\x0esubscriptionId\"\x87\x01\n" + + "\x1bGetCurrentPrincipalResponse\x12\x1b\n" + + "\tobject_id\x18\x01 \x01(\tR\bobjectId\x12K\n" + + "\x0eprincipal_type\x18\x02 \x01(\x0e2$.azd.extensions.v1beta.PrincipalTypeR\rprincipalType*n\n" + + "\rPrincipalType\x12\x1e\n" + + "\x1aPRINCIPAL_TYPE_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13PRINCIPAL_TYPE_USER\x10\x01\x12$\n" + + " PRINCIPAL_TYPE_SERVICE_PRINCIPAL\x10\x022\xef\x02\n" + "\x0eAccountService\x12v\n" + "\x11ListSubscriptions\x12/.azd.extensions.v1beta.ListSubscriptionsRequest\x1a0.azd.extensions.v1beta.ListSubscriptionsResponse\x12g\n" + - "\fLookupTenant\x12*.azd.extensions.v1beta.LookupTenantRequest\x1a+.azd.extensions.v1beta.LookupTenantResponseBGZEgithub.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta;v1betab\x06proto3" + "\fLookupTenant\x12*.azd.extensions.v1beta.LookupTenantRequest\x1a+.azd.extensions.v1beta.LookupTenantResponse\x12|\n" + + "\x13GetCurrentPrincipal\x121.azd.extensions.v1beta.GetCurrentPrincipalRequest\x1a2.azd.extensions.v1beta.GetCurrentPrincipalResponseBGZEgithub.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta;v1betab\x06proto3" var ( file_azd_extensions_v1beta_account_proto_rawDescOnce sync.Once @@ -234,25 +392,32 @@ func file_azd_extensions_v1beta_account_proto_rawDescGZIP() []byte { return file_azd_extensions_v1beta_account_proto_rawDescData } -var file_azd_extensions_v1beta_account_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_azd_extensions_v1beta_account_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_azd_extensions_v1beta_account_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_azd_extensions_v1beta_account_proto_goTypes = []any{ - (*ListSubscriptionsRequest)(nil), // 0: azd.extensions.v1beta.ListSubscriptionsRequest - (*ListSubscriptionsResponse)(nil), // 1: azd.extensions.v1beta.ListSubscriptionsResponse - (*LookupTenantRequest)(nil), // 2: azd.extensions.v1beta.LookupTenantRequest - (*LookupTenantResponse)(nil), // 3: azd.extensions.v1beta.LookupTenantResponse - (*Subscription)(nil), // 4: azd.extensions.v1beta.Subscription + (PrincipalType)(0), // 0: azd.extensions.v1beta.PrincipalType + (*ListSubscriptionsRequest)(nil), // 1: azd.extensions.v1beta.ListSubscriptionsRequest + (*ListSubscriptionsResponse)(nil), // 2: azd.extensions.v1beta.ListSubscriptionsResponse + (*LookupTenantRequest)(nil), // 3: azd.extensions.v1beta.LookupTenantRequest + (*LookupTenantResponse)(nil), // 4: azd.extensions.v1beta.LookupTenantResponse + (*GetCurrentPrincipalRequest)(nil), // 5: azd.extensions.v1beta.GetCurrentPrincipalRequest + (*GetCurrentPrincipalResponse)(nil), // 6: azd.extensions.v1beta.GetCurrentPrincipalResponse + (*Subscription)(nil), // 7: azd.extensions.v1beta.Subscription } var file_azd_extensions_v1beta_account_proto_depIdxs = []int32{ - 4, // 0: azd.extensions.v1beta.ListSubscriptionsResponse.subscriptions:type_name -> azd.extensions.v1beta.Subscription - 0, // 1: azd.extensions.v1beta.AccountService.ListSubscriptions:input_type -> azd.extensions.v1beta.ListSubscriptionsRequest - 2, // 2: azd.extensions.v1beta.AccountService.LookupTenant:input_type -> azd.extensions.v1beta.LookupTenantRequest - 1, // 3: azd.extensions.v1beta.AccountService.ListSubscriptions:output_type -> azd.extensions.v1beta.ListSubscriptionsResponse - 3, // 4: azd.extensions.v1beta.AccountService.LookupTenant:output_type -> azd.extensions.v1beta.LookupTenantResponse - 3, // [3:5] is the sub-list for method output_type - 1, // [1:3] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 7, // 0: azd.extensions.v1beta.ListSubscriptionsResponse.subscriptions:type_name -> azd.extensions.v1beta.Subscription + 0, // 1: azd.extensions.v1beta.GetCurrentPrincipalResponse.principal_type:type_name -> azd.extensions.v1beta.PrincipalType + 1, // 2: azd.extensions.v1beta.AccountService.ListSubscriptions:input_type -> azd.extensions.v1beta.ListSubscriptionsRequest + 3, // 3: azd.extensions.v1beta.AccountService.LookupTenant:input_type -> azd.extensions.v1beta.LookupTenantRequest + 5, // 4: azd.extensions.v1beta.AccountService.GetCurrentPrincipal:input_type -> azd.extensions.v1beta.GetCurrentPrincipalRequest + 2, // 5: azd.extensions.v1beta.AccountService.ListSubscriptions:output_type -> azd.extensions.v1beta.ListSubscriptionsResponse + 4, // 6: azd.extensions.v1beta.AccountService.LookupTenant:output_type -> azd.extensions.v1beta.LookupTenantResponse + 6, // 7: azd.extensions.v1beta.AccountService.GetCurrentPrincipal:output_type -> azd.extensions.v1beta.GetCurrentPrincipalResponse + 5, // [5:8] is the sub-list for method output_type + 2, // [2:5] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_azd_extensions_v1beta_account_proto_init() } @@ -267,13 +432,14 @@ func file_azd_extensions_v1beta_account_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_azd_extensions_v1beta_account_proto_rawDesc), len(file_azd_extensions_v1beta_account_proto_rawDesc)), - NumEnums: 0, - NumMessages: 4, + NumEnums: 1, + NumMessages: 6, NumExtensions: 0, NumServices: 1, }, GoTypes: file_azd_extensions_v1beta_account_proto_goTypes, DependencyIndexes: file_azd_extensions_v1beta_account_proto_depIdxs, + EnumInfos: file_azd_extensions_v1beta_account_proto_enumTypes, MessageInfos: file_azd_extensions_v1beta_account_proto_msgTypes, }.Build() File_azd_extensions_v1beta_account_proto = out.File diff --git a/cli/azd/pkg/azdext/contracts/v1beta/account_grpc.pb.go b/cli/azd/pkg/azdext/contracts/v1beta/account_grpc.pb.go index 741bffb564c..ac20d9354c6 100644 --- a/cli/azd/pkg/azdext/contracts/v1beta/account_grpc.pb.go +++ b/cli/azd/pkg/azdext/contracts/v1beta/account_grpc.pb.go @@ -22,8 +22,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AccountService_ListSubscriptions_FullMethodName = "/azd.extensions.v1beta.AccountService/ListSubscriptions" - AccountService_LookupTenant_FullMethodName = "/azd.extensions.v1beta.AccountService/LookupTenant" + AccountService_ListSubscriptions_FullMethodName = "/azd.extensions.v1beta.AccountService/ListSubscriptions" + AccountService_LookupTenant_FullMethodName = "/azd.extensions.v1beta.AccountService/LookupTenant" + AccountService_GetCurrentPrincipal_FullMethodName = "/azd.extensions.v1beta.AccountService/GetCurrentPrincipal" ) // AccountServiceClient is the client API for AccountService service. @@ -34,6 +35,8 @@ type AccountServiceClient interface { ListSubscriptions(ctx context.Context, in *ListSubscriptionsRequest, opts ...grpc.CallOption) (*ListSubscriptionsResponse, error) // LookupTenant resolves the tenant ID required to access a specific subscription. LookupTenant(ctx context.Context, in *LookupTenantRequest, opts ...grpc.CallOption) (*LookupTenantResponse, error) + // GetCurrentPrincipal resolves the signed-in identity in the subscription's resource tenant. + GetCurrentPrincipal(ctx context.Context, in *GetCurrentPrincipalRequest, opts ...grpc.CallOption) (*GetCurrentPrincipalResponse, error) } type accountServiceClient struct { @@ -64,6 +67,16 @@ func (c *accountServiceClient) LookupTenant(ctx context.Context, in *LookupTenan return out, nil } +func (c *accountServiceClient) GetCurrentPrincipal(ctx context.Context, in *GetCurrentPrincipalRequest, opts ...grpc.CallOption) (*GetCurrentPrincipalResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCurrentPrincipalResponse) + err := c.cc.Invoke(ctx, AccountService_GetCurrentPrincipal_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AccountServiceServer is the server API for AccountService service. // All implementations must embed UnimplementedAccountServiceServer // for forward compatibility. @@ -72,6 +85,8 @@ type AccountServiceServer interface { ListSubscriptions(context.Context, *ListSubscriptionsRequest) (*ListSubscriptionsResponse, error) // LookupTenant resolves the tenant ID required to access a specific subscription. LookupTenant(context.Context, *LookupTenantRequest) (*LookupTenantResponse, error) + // GetCurrentPrincipal resolves the signed-in identity in the subscription's resource tenant. + GetCurrentPrincipal(context.Context, *GetCurrentPrincipalRequest) (*GetCurrentPrincipalResponse, error) mustEmbedUnimplementedAccountServiceServer() } @@ -88,6 +103,9 @@ func (UnimplementedAccountServiceServer) ListSubscriptions(context.Context, *Lis func (UnimplementedAccountServiceServer) LookupTenant(context.Context, *LookupTenantRequest) (*LookupTenantResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method LookupTenant not implemented") } +func (UnimplementedAccountServiceServer) GetCurrentPrincipal(context.Context, *GetCurrentPrincipalRequest) (*GetCurrentPrincipalResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCurrentPrincipal not implemented") +} func (UnimplementedAccountServiceServer) mustEmbedUnimplementedAccountServiceServer() {} func (UnimplementedAccountServiceServer) testEmbeddedByValue() {} @@ -145,6 +163,24 @@ func _AccountService_LookupTenant_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _AccountService_GetCurrentPrincipal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCurrentPrincipalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AccountServiceServer).GetCurrentPrincipal(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AccountService_GetCurrentPrincipal_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AccountServiceServer).GetCurrentPrincipal(ctx, req.(*GetCurrentPrincipalRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AccountService_ServiceDesc is the grpc.ServiceDesc for AccountService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -160,6 +196,10 @@ var AccountService_ServiceDesc = grpc.ServiceDesc{ MethodName: "LookupTenant", Handler: _AccountService_LookupTenant_Handler, }, + { + MethodName: "GetCurrentPrincipal", + Handler: _AccountService_GetCurrentPrincipal_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "azd/extensions/v1beta/account.proto", diff --git a/cli/azd/pkg/azdext/contracts/versioning_test.go b/cli/azd/pkg/azdext/contracts/versioning_test.go index 9206c46374d..059dfdba173 100644 --- a/cli/azd/pkg/azdext/contracts/versioning_test.go +++ b/cli/azd/pkg/azdext/contracts/versioning_test.go @@ -45,6 +45,21 @@ func TestPreviewOnlyServicesAreExcludedFromStable(t *testing.T) { } } +func TestCurrentPrincipalIsBetaOnly(t *testing.T) { + t.Parallel() + + stable := v1.File_azd_extensions_v1_account_proto + beta := v1beta.File_azd_extensions_v1beta_account_proto + require.Nil(t, stable.Services().ByName("AccountService").Methods().ByName("GetCurrentPrincipal")) + require.NotNil(t, beta.Services().ByName("AccountService").Methods().ByName("GetCurrentPrincipal")) + for _, name := range []protoreflect.Name{"GetCurrentPrincipalRequest", "GetCurrentPrincipalResponse"} { + require.Nil(t, stable.Messages().ByName(name)) + require.NotNil(t, beta.Messages().ByName(name)) + } + require.Nil(t, stable.Enums().ByName("PrincipalType")) + require.NotNil(t, beta.Enums().ByName("PrincipalType")) +} + func TestStableSubsetAllowsAdditiveBetaFieldsAndMethods(t *testing.T) { t.Parallel() diff --git a/cli/azd/pkg/azureutil/principal.go b/cli/azd/pkg/azureutil/principal.go index 39d6d7329cc..9c1e71c4ec0 100644 --- a/cli/azd/pkg/azureutil/principal.go +++ b/cli/azd/pkg/azureutil/principal.go @@ -12,7 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" ) -// GetCurrentPrincipalId returns the object ID of the current principal authenticated with the CLI. +// GetCurrentPrincipalId returns a non-empty object ID of the current principal authenticated with the CLI. // It prefers the oid claim from an ARM access token, falling back to Graph /me when acquiring the // token fails or when the token does not include a usable oid. func GetCurrentPrincipalId(ctx context.Context, userProfile *azapi.UserProfileService, tenantId string) (string, error) { @@ -30,7 +30,10 @@ func GetCurrentPrincipalId(ctx context.Context, userProfile *azapi.UserProfileSe principalId, graphErr := userProfile.GetSignedInUserId(ctx, tenantId) if graphErr == nil { - return principalId, nil + if principalId != "" { + return principalId, nil + } + graphErr = errors.New("signed-in user response did not contain an object id") } return "", fmt.Errorf( diff --git a/cli/azd/pkg/azureutil/principal_test.go b/cli/azd/pkg/azureutil/principal_test.go index 8cc3d41b114..40b38b496ac 100644 --- a/cli/azd/pkg/azureutil/principal_test.go +++ b/cli/azd/pkg/azureutil/principal_test.go @@ -52,39 +52,58 @@ func TestGetCurrentPrincipalId_PrefersOidFromAccessToken(t *testing.T) { func TestGetCurrentPrincipalId_FallsBackToGraphWhenOidMissing(t *testing.T) { t.Parallel() - mockContext := mocks.NewMockContext(t.Context()) - mockContext.HttpClient.When(func(request *http.Request) bool { - return request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/me") - }).RespondFn(func(request *http.Request) (*http.Response, error) { - return mocks.CreateHttpResponseWithBody(request, http.StatusOK, &graphsdk.UserProfile{ - Id: "graph-user-id", - }) - }) + for _, tt := range []struct { + name string + graphID string + }{ + {name: "success", graphID: "graph-user-id"}, + {name: "empty object id"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/me") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, &graphsdk.UserProfile{ + Id: tt.graphID, + }) + }) - userProfile := azapi.NewUserProfileService( - &mocks.MockMultiTenantCredentialProvider{ - TokenMap: map[string]mocks.MockCredentials{ - "resource-tenant": { - GetTokenFn: func(ctx context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) { - return azcore.AccessToken{ - Token: mocks.CreateJwtToken(t, map[string]string{ - "test": "fail", - }), - ExpiresOn: time.Now().Add(time.Hour), - }, nil + userProfile := azapi.NewUserProfileService( + &mocks.MockMultiTenantCredentialProvider{ + TokenMap: map[string]mocks.MockCredentials{ + "resource-tenant": { + GetTokenFn: func( + ctx context.Context, options policy.TokenRequestOptions, + ) (azcore.AccessToken, error) { + return azcore.AccessToken{ + Token: mocks.CreateJwtToken(t, map[string]string{ + "test": "fail", + }), + ExpiresOn: time.Now().Add(time.Hour), + }, nil + }, + }, }, }, - }, - }, - &azcore.ClientOptions{ - Transport: mockContext.HttpClient, - }, - cloud.AzurePublic(), - ) + &azcore.ClientOptions{ + Transport: mockContext.HttpClient, + }, + cloud.AzurePublic(), + ) - principalId, err := GetCurrentPrincipalId(*mockContext.Context, userProfile, "resource-tenant") - require.NoError(t, err) - require.Equal(t, "graph-user-id", principalId) + principalId, err := GetCurrentPrincipalId(*mockContext.Context, userProfile, "resource-tenant") + if tt.graphID == "" { + require.ErrorContains(t, err, "signed-in user response did not contain an object id") + require.ErrorContains(t, err, "getting oid from token: no oid claim") + require.Empty(t, principalId) + } else { + require.NoError(t, err) + require.Equal(t, tt.graphID, principalId) + } + }) + } } func TestGetCurrentPrincipalId_ReturnsJoinedErrorWhenTokenAndGraphFail(t *testing.T) { diff --git a/cli/azd/pkg/infra/provisioning/current_principal_id_provider.go b/cli/azd/pkg/infra/provisioning/current_principal_id_provider.go index 2b20c850581..bad641c7411 100644 --- a/cli/azd/pkg/infra/provisioning/current_principal_id_provider.go +++ b/cli/azd/pkg/infra/provisioning/current_principal_id_provider.go @@ -58,22 +58,12 @@ func (p *principalIDProvider) CurrentPrincipalId(ctx context.Context) (string, e } const ( - UserType PrincipalType = "User" - ServicePrincipalType PrincipalType = "ServicePrincipal" + UserType PrincipalType = auth.UserPrincipalType + ServicePrincipalType PrincipalType = auth.ServicePrincipalType ) -type PrincipalType string +type PrincipalType = auth.PrincipalType func (p *principalIDProvider) CurrentPrincipalType(ctx context.Context) (PrincipalType, error) { - loginDetails, err := p.authManager.LogInDetails(ctx) - if err != nil { - return "", fmt.Errorf("fetching login details: %w", err) - } - - principalType := UserType - if loginDetails.LoginType == auth.ClientIdLoginType { - principalType = ServicePrincipalType - } - - return principalType, nil + return p.authManager.CurrentPrincipalType(ctx) } diff --git a/cli/azd/pkg/output/ux/auth_status.go b/cli/azd/pkg/output/ux/auth_status.go index 2520a9d812c..a3c50284ecb 100644 --- a/cli/azd/pkg/output/ux/auth_status.go +++ b/cli/azd/pkg/output/ux/auth_status.go @@ -38,9 +38,11 @@ func (v *AuthStatusView) ToString(currentIndentation string) string { currentIndentation, output.WithBold("%s", v.Result.Email)) case contracts.AccountTypeServicePrincipal: - return fmt.Sprintf("%sLogged in to Azure as (%s)", - currentIndentation, - output.WithGrayFormat("%s", v.Result.ClientID)) + if v.Result.ClientID != "" { + return fmt.Sprintf("%sLogged in to Azure as (%s)", + currentIndentation, + output.WithGrayFormat("%s", v.Result.ClientID)) + } } return fmt.Sprintf("%sLogged in to Azure", currentIndentation) diff --git a/cli/azd/pkg/output/ux/auth_status_test.go b/cli/azd/pkg/output/ux/auth_status_test.go index 4e3d76705e3..8b21afafa0e 100644 --- a/cli/azd/pkg/output/ux/auth_status_test.go +++ b/cli/azd/pkg/output/ux/auth_status_test.go @@ -50,6 +50,15 @@ func TestAuthStatusView_ToString(t *testing.T) { }, authMode: "azd built in", }, + { + name: "authenticated service principal without client ID", + result: &contracts.StatusResult{ + Status: contracts.AuthStatusAuthenticated, + Type: contracts.AccountTypeServicePrincipal, + }, + authMode: "azd built in", + want: "Logged in to Azure", + }, { name: "authenticated service principal", result: &contracts.StatusResult{ @@ -69,7 +78,7 @@ func TestAuthStatusView_ToString(t *testing.T) { } got := v.ToString("") - if tt.result.Status == contracts.AuthStatusUnauthenticated { + if tt.want != "" { assert.Equal(t, tt.want, got) } else { assert.Contains(t, got, "Logged in to Azure") diff --git a/cli/azd/pkg/output/ux/logged_in.go b/cli/azd/pkg/output/ux/logged_in.go index 59decaf2c4d..e4911f79300 100644 --- a/cli/azd/pkg/output/ux/logged_in.go +++ b/cli/azd/pkg/output/ux/logged_in.go @@ -24,6 +24,10 @@ type LoggedIn struct { } func (cr *LoggedIn) ToString(currentIndentation string) string { + if cr.LoggedInAs == "" { + return currentIndentation + cLoginSuccessMessage + } + switch cr.LoginType { case EmailLoginType: return fmt.Sprintf( @@ -47,7 +51,11 @@ func (cr *LoggedIn) ToString(currentIndentation string) string { } func (cr *LoggedIn) MarshalJSON() ([]byte, error) { + message := cLoginSuccessMessage + if cr.LoggedInAs != "" { + message = fmt.Sprintf("%s as %s", message, cr.LoggedInAs) + } + // reusing the same envelope from console messages - return json.Marshal(output.EventForMessage( - fmt.Sprintf("%s as %s", cLoginSuccessMessage, cr.LoggedInAs))) + return json.Marshal(output.EventForMessage(message)) } diff --git a/cli/azd/pkg/output/ux/logged_in_test.go b/cli/azd/pkg/output/ux/logged_in_test.go new file mode 100644 index 00000000000..695d86be41f --- /dev/null +++ b/cli/azd/pkg/output/ux/logged_in_test.go @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/contracts" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/stretchr/testify/require" +) + +func TestLoggedIn(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + loginType LoginType + account string + wantText string + wantMessage string + }{ + { + name: "system-assigned managed identity", loginType: ClientIdLoginType, + wantText: "Logged in to Azure", wantMessage: "Logged in to Azure\n", + }, + { + name: "user without account name", loginType: EmailLoginType, + wantText: "Logged in to Azure", wantMessage: "Logged in to Azure\n", + }, + { + name: "user", loginType: EmailLoginType, account: "user@example.com", + wantText: "Logged in to Azure as " + output.WithBold("%s", "user@example.com"), + wantMessage: "Logged in to Azure as user@example.com\n", + }, + { + name: "service principal", loginType: ClientIdLoginType, account: "client-id", + wantText: "Logged in to Azure as (" + output.WithGrayFormat("%s", "client-id") + ")", + wantMessage: "Logged in to Azure as client-id\n", + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + view := &LoggedIn{LoginType: tt.loginType, LoggedInAs: tt.account} + require.Equal(t, " "+tt.wantText, view.ToString(" ")) + + data, err := json.Marshal(view) + require.NoError(t, err) + var event struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Data contracts.ConsoleMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(data, &event)) + require.Equal(t, string(contracts.ConsoleMessageEventDataType), event.Type) + require.False(t, event.Timestamp.IsZero()) + require.Equal(t, tt.wantMessage, event.Data.Message) + }) + } +} diff --git a/docs/architecture/extension-framework.md b/docs/architecture/extension-framework.md index 6f961f5430a..96c5f945069 100644 --- a/docs/architecture/extension-framework.md +++ b/docs/architecture/extension-framework.md @@ -67,7 +67,7 @@ Extensions can access these azd services via gRPC: - **Environment** — Read/write environment values and secrets - **User Config** — Read user-level azd configuration - **Deployment** — Access deployment information -- **Account** — Access Azure account details +- **Account**. List subscriptions and resolve access tenants. The `v1beta` client also retrieves the current principal's resource-tenant object ID and type for role assignments. See [GetCurrentPrincipal](../../cli/azd/docs/extensions/extension-framework.md#getcurrentprincipal). - **Prompt** — Display prompts and collect user input - **AI Model** — Query AI model availability and quotas - **Event** — Subscribe to and emit events diff --git a/docs/guides/creating-an-extension.md b/docs/guides/creating-an-extension.md index 0b8b2a2ec03..a6b04d20428 100644 --- a/docs/guides/creating-an-extension.md +++ b/docs/guides/creating-an-extension.md @@ -49,6 +49,8 @@ capabilities: Implement the required interfaces for your declared capabilities. See the extension framework services documentation for interface details. +If your Go extension creates role assignments, use the preview [`AccountBeta().GetCurrentPrincipal`](../../cli/azd/docs/extensions/extension-framework.md#getcurrentprincipal) method with the target subscription ID and request types from `contracts/v1beta`. The host resolves the resource-tenant object ID and principal type without returning an access token. Consume an SDK and host release containing this method before replacing an existing lookup. + ### 4. Build ```bash