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
12 changes: 9 additions & 3 deletions internal/api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -73,6 +78,7 @@ func (c *client) CurrentUser(ctx context.Context) (CurrentUser, error) {
id
name
email
agentsEnabled
organizations {
id
name
Expand Down
2 changes: 1 addition & 1 deletion internal/cmds/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` does.
Expand Down
21 changes: 11 additions & 10 deletions internal/cmds/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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")
Expand All @@ -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
}
}
Expand All @@ -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
}

Expand All @@ -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")
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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,
}

Expand Down
124 changes: 124 additions & 0 deletions internal/cmds/org_agents_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 3 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 7 additions & 3 deletions pkg/auth/user_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading