Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions internal/api/handler_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
65 changes: 61 additions & 4 deletions internal/api/handler_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading