diff --git a/internal/api/dashboard/client.go b/internal/api/dashboard/client.go index d361062..ae41b67 100644 --- a/internal/api/dashboard/client.go +++ b/internal/api/dashboard/client.go @@ -28,9 +28,14 @@ type Role struct { } type CurrentUser struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + // AgentsEnabled reports whether Infracost Agents is enabled for this user + // directly (rather than via one of their orgs). Driven server-side by the + // coast-access entitlement targeted at the user. When true the Agents + // commands / MCP tools are enabled regardless of the active org's flag. + AgentsEnabled bool `json:"agentsEnabled"` Organizations []Organization `json:"organizations"` } @@ -73,6 +78,7 @@ func (c *client) CurrentUser(ctx context.Context) (CurrentUser, error) { id name email + agentsEnabled organizations { id name diff --git a/internal/cmds/mcp.go b/internal/cmds/mcp.go index a04fd49..15bbb01 100644 --- a/internal/cmds/mcp.go +++ b/internal/cmds/mcp.go @@ -987,7 +987,7 @@ func setOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, // resolved slug + agentsEnabled flag so the Agents gate reflects the // newly-selected org rather than the one resolved at startup. cfg.Org = in.Slug - applyActiveOrgByID(cfg, uc.Organizations, orgID) + applyActiveOrgByID(cfg, uc, orgID) // Persist so the next MCP session (or CLI invocation) starts on // the same org, mirroring what `infracost org switch ` does. diff --git a/internal/cmds/org.go b/internal/cmds/org.go index 0402bd2..42174d5 100644 --- a/internal/cmds/org.go +++ b/internal/cmds/org.go @@ -43,7 +43,7 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour if err != nil { return err } - applyActiveOrgByID(cfg, uc.Organizations, orgID) + applyActiveOrgByID(cfg, uc, orgID) return nil } @@ -52,7 +52,7 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour if slug, readErr := auth.ReadLocalOrg(wd); readErr == nil && slug != "" { orgID, _, resolveErr := auth.ResolveOrgID(slug, uc.Organizations) if resolveErr == nil { - applyActiveOrgByID(cfg, uc.Organizations, orgID) + applyActiveOrgByID(cfg, uc, orgID) return nil } logging.WithError(resolveErr).Msg("local .infracost/org references unknown org, ignoring") @@ -63,7 +63,7 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour if uc.SelectedOrgID != "" { for _, org := range uc.Organizations { if org.ID == uc.SelectedOrgID { - applyActiveOrg(cfg, org) + applyActiveOrg(cfg, uc, org) return nil } } @@ -72,7 +72,7 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour // No org context set — if single org, use it silently. if len(uc.Organizations) == 1 { - applyActiveOrg(cfg, uc.Organizations[0]) + applyActiveOrg(cfg, uc, uc.Organizations[0]) return nil } @@ -82,7 +82,7 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour if pickErr == nil { for _, org := range uc.Organizations { if org.Slug == slug { - applyActiveOrg(cfg, org) + applyActiveOrg(cfg, uc, org) uc.SelectedOrgID = org.ID if saveErr := cfg.Auth.SaveUserCache(uc); saveErr != nil { logging.WithError(saveErr).Msg("failed to save org selection") @@ -102,19 +102,19 @@ func resolveOrg(ctx context.Context, cfg *config.Config, source oauth2.TokenSour // applyActiveOrg records the resolved active organization on cfg, including // the per-org agentsEnabled flag the Agents commands / MCP tools gate on and // the slug used for org-scoped dashboard links. -func applyActiveOrg(cfg *config.Config, org auth.CachedOrganization) { +func applyActiveOrg(cfg *config.Config, uc *auth.UserCache, org auth.CachedOrganization) { cfg.OrgID = org.ID cfg.OrgSlug = org.Slug - cfg.AgentsEnabled = org.AgentsEnabled + cfg.AgentsEnabled = org.AgentsEnabled || uc.AgentsEnabled } // applyActiveOrgByID looks up org id in orgs and applies it via applyActiveOrg. // id has already been validated by auth.ResolveOrgID, so a miss is unexpected; // we still set OrgID defensively so the caller isn't left without one. -func applyActiveOrgByID(cfg *config.Config, orgs []auth.CachedOrganization, id string) { - for _, org := range orgs { +func applyActiveOrgByID(cfg *config.Config, uc *auth.UserCache, id string) { + for _, org := range uc.Organizations { if org.ID == id { - applyActiveOrg(cfg, org) + applyActiveOrg(cfg, uc, org) return } } @@ -193,6 +193,7 @@ func cacheUser(cfg *config.Config, user dashboard.CurrentUser) *auth.UserCache { ID: user.ID, Name: user.Name, Email: user.Email, + AgentsEnabled: user.AgentsEnabled, Organizations: orgs, } diff --git a/internal/cmds/org_agents_test.go b/internal/cmds/org_agents_test.go new file mode 100644 index 0000000..2906ebb --- /dev/null +++ b/internal/cmds/org_agents_test.go @@ -0,0 +1,124 @@ +package cmds + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infracost/cli/internal/api/dashboard" + "github.com/infracost/cli/internal/config" + "github.com/infracost/cli/pkg/auth" +) + +// TestApplyActiveOrg_AgentsEnabled covers the FIX-416 gate: Agents is enabled +// when the coast-access entitlement is set on either the user directly or the +// active org. A user-level grant is always-on regardless of the active org. +func TestApplyActiveOrg_AgentsEnabled(t *testing.T) { + tests := []struct { + name string + userEnabled bool + orgEnabled bool + want bool + }{ + {"neither user nor org enabled", false, false, false}, + {"org enabled only", false, true, true}, + {"user enabled only is always-on", true, false, true}, + {"both enabled", true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + uc := &auth.UserCache{AgentsEnabled: tt.userEnabled} + org := auth.CachedOrganization{ID: "org-1", Slug: "acme", AgentsEnabled: tt.orgEnabled} + + applyActiveOrg(cfg, uc, org) + + assert.Equal(t, tt.want, cfg.AgentsEnabled) + assert.Equal(t, "org-1", cfg.OrgID) + assert.Equal(t, "acme", cfg.OrgSlug) + }) + } +} + +// TestApplyActiveOrgByID exercises the by-ID lookup, including the multi-org +// case at the heart of FIX-416: a user with user-level coast-access whose +// active org doesn't have it should still pass the gate. +func TestApplyActiveOrgByID(t *testing.T) { + orgs := []auth.CachedOrganization{ + {ID: "org-a", Slug: "org-a", AgentsEnabled: false}, + {ID: "org-b", Slug: "org-b", AgentsEnabled: true}, + } + + t.Run("user-level flag enables gate even when active org is off", func(t *testing.T) { + cfg := &config.Config{} + uc := &auth.UserCache{AgentsEnabled: true, Organizations: orgs} + + applyActiveOrgByID(cfg, uc, "org-a") + + assert.Equal(t, "org-a", cfg.OrgID) + assert.True(t, cfg.AgentsEnabled) + }) + + t.Run("falls back to the active org flag when user-level is off", func(t *testing.T) { + cfg := &config.Config{} + uc := &auth.UserCache{AgentsEnabled: false, Organizations: orgs} + + applyActiveOrgByID(cfg, uc, "org-a") + assert.False(t, cfg.AgentsEnabled, "org-a has Agents off") + + // Switching to an org that has it on flips the gate. + applyActiveOrgByID(cfg, uc, "org-b") + assert.True(t, cfg.AgentsEnabled, "org-b has Agents on") + }) + + t.Run("unknown id sets OrgID defensively", func(t *testing.T) { + cfg := &config.Config{} + uc := &auth.UserCache{AgentsEnabled: false, Organizations: orgs} + + applyActiveOrgByID(cfg, uc, "missing") + + assert.Equal(t, "missing", cfg.OrgID) + }) +} + +// TestCacheUser_PersistsAgentsEnabled guards the wire from the dashboard +// currentUser response through to the cached user: without copying +// user.AgentsEnabled into the UserCache the whole user-level gate is a no-op. +func TestCacheUser_PersistsAgentsEnabled(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Auth: auth.Config{ + InternalConfig: auth.InternalConfig{ + UserCachePath: filepath.Join(dir, "user.json"), + }, + }, + } + + user := dashboard.CurrentUser{ + ID: "user-1", + Name: "Alice", + Email: "alice@acme.io", + AgentsEnabled: true, + Organizations: []dashboard.Organization{ + {ID: "org-a", Name: "Org A", Slug: "org-a", AgentsEnabled: false}, + {ID: "org-b", Name: "Org B", Slug: "org-b", AgentsEnabled: true}, + }, + } + + uc := cacheUser(cfg, user) + require.NotNil(t, uc) + assert.True(t, uc.AgentsEnabled, "user-level agentsEnabled should be cached") + require.Len(t, uc.Organizations, 2) + assert.False(t, uc.Organizations[0].AgentsEnabled) + assert.True(t, uc.Organizations[1].AgentsEnabled) + + // It should also round-trip through the on-disk cache so later CLI + // invocations (which reload rather than re-fetch) keep the grant. + loaded, err := cfg.Auth.LoadUserCache() + require.NoError(t, err) + require.NotNil(t, loaded) + assert.True(t, loaded.AgentsEnabled) +} diff --git a/internal/config/config.go b/internal/config/config.go index f05f12f..89dedd6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,9 +41,9 @@ type Config struct { // scoped dashboard links (e.g. the Agents waitlist). OrgSlug string - // AgentsEnabled mirrors the resolved active org's agentsEnabled flag (the - // dashboard's coast-access entitlement). The Agents commands and MCP tools - // gate on it; see ensureAgentsEnabled. + // AgentsEnabled is the resolved Agents entitlement the Agents commands and + // MCP tools gate on (see ensureAgentsEnabled). It is true when the coast- + // access entitlement is set on either the user directly or the active org AgentsEnabled bool // ClaudePath is the path to the Claude CLI binary. Defaults to "claude" (looked up on PATH). diff --git a/pkg/auth/user_cache.go b/pkg/auth/user_cache.go index 3486945..fb44782 100644 --- a/pkg/auth/user_cache.go +++ b/pkg/auth/user_cache.go @@ -24,9 +24,13 @@ type CachedOrganization struct { // UserCache stores the current user's identity and organization memberships. // It is populated on login and used to resolve --org flag values without an API call. type UserCache struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + // AgentsEnabled mirrors the dashboard's user-level agentsEnabled flag. When + // true, Agents is enabled regardless of which org is active; otherwise the + // gate falls back to the active org's CachedOrganization.AgentsEnabled. + AgentsEnabled bool `json:"agentsEnabled,omitempty"` Organizations []CachedOrganization `json:"organizations"` SelectedOrgID string `json:"selectedOrgId,omitempty"` UpdatedAt time.Time `json:"updatedAt"`