diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index f4769dc6e..d589f235b 100644 --- a/internal/api/handler_config.go +++ b/internal/api/handler_config.go @@ -23,7 +23,7 @@ func sourceCloud() string { } // Configuration handlers -func (h *Handler) getConfig(ctx context.Context) (*ConfigResponse, error) { +func (h *Handler) getConfig(ctx context.Context, req *events.LambdaFunctionURLRequest) (*ConfigResponse, error) { globalCfg, err := h.config.GetGlobalConfig(ctx) if err != nil { return nil, err @@ -34,12 +34,21 @@ func (h *Handler) getConfig(ctx context.Context) (*ConfigResponse, error) { return nil, err } - return &ConfigResponse{ - Global: globalCfg, - Services: services, - SourceCloud: sourceCloud(), - SourceIdentity: h.resolveSourceIdentity(ctx), - }, nil + resp := &ConfigResponse{ + Global: globalCfg, + Services: services, + SourceCloud: sourceCloud(), + } + + // SourceIdentity contains the host cloud account ID (AWS account number, + // Azure tenant ID, etc.). Only expose it to admin sessions so that + // non-admin users cannot extract the cloud identity of the CUDly host + // account (issue #407). + if _, adminErr := h.requireAdmin(ctx, req); adminErr == nil { + resp.SourceIdentity = h.resolveSourceIdentity(ctx) + } + + return resp, nil } // preserveOmittedRecommendationFields merges persisted GlobalConfig values diff --git a/internal/api/handler_config_test.go b/internal/api/handler_config_test.go index 247cd9803..132e01a41 100644 --- a/internal/api/handler_config_test.go +++ b/internal/api/handler_config_test.go @@ -14,6 +14,12 @@ import ( func TestHandler_getConfig(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + } globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws"}, @@ -25,12 +31,17 @@ func TestHandler_getConfig(t *testing.T) { {Provider: "aws", Service: "rds", Enabled: true}, } + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(globalCfg, nil) mockStore.On("ListServiceConfigs", ctx).Return(serviceConfigs, nil) - handler := &Handler{config: mockStore} + handler := &Handler{config: mockStore, auth: mockAuth} - result, err := handler.getConfig(ctx) + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + } + result, err := handler.getConfig(ctx, req) require.NoError(t, err) assert.NotNil(t, result.Global) @@ -327,7 +338,8 @@ func TestHandler_getConfig_GlobalConfigError(t *testing.T) { handler := &Handler{config: mockStore} - result, err := handler.getConfig(ctx) + req := &events.LambdaFunctionURLRequest{} + result, err := handler.getConfig(ctx, req) assert.Error(t, err) assert.Nil(t, result) } @@ -345,11 +357,56 @@ func TestHandler_getConfig_ListServiceConfigsError(t *testing.T) { handler := &Handler{config: mockStore} - result, err := handler.getConfig(ctx) + req := &events.LambdaFunctionURLRequest{} + result, err := handler.getConfig(ctx, req) assert.Error(t, err) assert.Nil(t, result) } +// Regression tests for issue #407: SourceIdentity (cloud account ID, Azure +// tenant ID) must only be included in responses for admin sessions. + +func TestHandler_getConfig_SourceIdentity_AdminOnly(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + adminSession := &Session{UserID: "admin-user"} + userSession := &Session{UserID: "regular-user"} + + globalCfg := &config.GlobalConfig{EnabledProviders: []string{"aws"}} + mockStore.On("GetGlobalConfig", ctx).Return(globalCfg, nil) + mockStore.On("ListServiceConfigs", ctx).Return([]config.ServiceConfig{}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) + mockAuth.On("HasPermissionAPI", mock.Anything, "admin-user", mock.Anything, mock.Anything).Return(true, nil) + + handler := &Handler{config: mockStore, auth: mockAuth} + + t.Run("admin sees SourceIdentity", func(t *testing.T) { + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + } + result, err := handler.getConfig(ctx, req) + require.NoError(t, err) + // resolveSourceIdentity always returns a non-nil struct (best-effort, + // returns an empty struct on failure). The key invariant is that admin + // sessions receive the field and non-admin sessions do not. + require.NotNil(t, result.SourceIdentity) + }) + + t.Run("regression #407: non-admin does not receive SourceIdentity", func(t *testing.T) { + mockAuth.On("HasPermissionAPI", ctx, "regular-user", mock.Anything, mock.Anything).Return(false, nil) + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer user-token"}, + } + result, err := handler.getConfig(ctx, req) + require.NoError(t, err) + assert.Nil(t, result.SourceIdentity, + "SourceIdentity must be nil for non-admin sessions (issue #407)") + }) +} + func TestHandler_getServiceConfig_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) diff --git a/internal/api/router.go b/internal/api/router.go index e5a3e700e..30a4286eb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -421,7 +421,7 @@ func (r *Router) upcomingPurchasesHandler(ctx context.Context, req *events.Lambd } func (r *Router) getConfigHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getConfig(ctx) + return r.h.getConfig(ctx, req) } func (r *Router) updateConfigHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {