diff --git a/README.md b/README.md index 7115202..9e10ff8 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,16 @@ Glassity UI and pass it through the environment: ```sh export GLASSITY_TOKEN= +export GLASSITY_ACCOUNT_ID= # from `glassity auth whoami` +export GLASSITY_AWS_ACCOUNT_ID= # scopes cost reads glassity --output json opp list ``` +The token alone is not enough: tenant-scoped commands need `GLASSITY_ACCOUNT_ID` +(the `account_…` prefix id, printed by `glassity auth whoami` and by `auth +set-token`), and cost commands scope to `GLASSITY_AWS_ACCOUNT_ID` unless +`--aws-account-id` is passed explicitly. + `GLASSITY_TOKEN` takes precedence over the keychain, so nothing is written to disk on the runner. Where a keychain exists, `glassity auth set-token` stores a PAT persistently. diff --git a/docs/configuration.md b/docs/configuration.md index ce9ed76..dabc365 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,8 +85,8 @@ Profile selection precedence: `--profile` beats `GLASSITY_PROFILE`, which beats | `GLASSITY_API_URL` | Fallback alias for `GLASSITY_API_BASE_URL`, consulted only when that variable is unset. | | `GLASSITY_PROFILE` | Selects the active profile. `--profile` wins over it. | | `GLASSITY_CONFIG_HOME` | Directory holding `profile.yaml`. Wins over `XDG_CONFIG_HOME`. | -| `GLASSITY_ACCOUNT_ID` | Overrides `account_prefix_id`. | -| `GLASSITY_AWS_ACCOUNT_ID` | Overrides `aws_account_id`. | +| `GLASSITY_ACCOUNT_ID` | Overrides `account_prefix_id`. Required for tenant-scoped commands when running headless with only `GLASSITY_TOKEN`. | +| `GLASSITY_AWS_ACCOUNT_ID` | Overrides `aws_account_id`. Cost commands fall back to it when `--aws-account-id` is not passed. | | `XDG_CONFIG_HOME` | Base directory for the config file. | | `XDG_CACHE_HOME` | Base directory for the filesystem cache and, on Linux without `XDG_RUNTIME_DIR`, the confirm-token secrets. | | `XDG_STATE_HOME` | Base directory for filesystem mount state. | diff --git a/internal/cli/cost/cost.go b/internal/cli/cost/cost.go index e47fa6f..2d5c121 100644 --- a/internal/cli/cost/cost.go +++ b/internal/cli/cost/cost.go @@ -72,6 +72,7 @@ func newCostCmd(use, short string, fetch costFetcher) *cobra.Command { AccountPrefixID: rt.Profile.AccountPrefixID, }) fmt.Fprintln(rt.Stderr, output.TenantBanner(rt.Profile.TenantLabel(), rt.Profile.AccountPrefixID, rt.Profile.AwsAccountsLabel())) + applyProfileAccountScope(&f, rt) rows, err := fetch(cmd.Context(), client, f) if err != nil { return err @@ -83,12 +84,23 @@ func newCostCmd(use, short string, fetch costFetcher) *cobra.Command { flags.StringVar(&f.StartDate, "start-date", "", "window start (YYYY-MM-DD)") flags.StringVar(&f.EndDate, "end-date", "", "window end (YYYY-MM-DD)") flags.StringVar(&f.Granularity, "granularity", "", "daily|monthly") - flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account") + flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account (default: the profile's account, e.g. GLASSITY_AWS_ACCOUNT_ID)") flags.IntVar(&f.Limit, "limit", 0, "page size (1..100)") flags.IntVar(&f.Page, "page", 0, "page number (1-indexed)") return cmd } +// applyProfileAccountScope falls back to the profile's AWS account (which +// GLASSITY_AWS_ACCOUNT_ID overrides) when --aws-account-id is not given. +// Without this, an unflagged query silently scopes to whatever account the +// server picks as default — which can be a different account than the one +// the caller's environment names. +func applyProfileAccountScope(f *apiclient.CostCommon, rt *cli.Runtime) { + if f.AwsAccountID == "" { + f.AwsAccountID = rt.Profile.AwsAccountID + } +} + // RenderRows is the shared table/json renderer for cost and rec commands. // Exposed so the rec package can reuse it without duplicating the logic. func RenderRows(rt *cli.Runtime, rows []apiclient.CostRow) error { diff --git a/internal/cli/cost/summary.go b/internal/cli/cost/summary.go index b753057..01b80c3 100644 --- a/internal/cli/cost/summary.go +++ b/internal/cli/cost/summary.go @@ -33,6 +33,7 @@ func newCostSummaryCmd() *cobra.Command { AccountPrefixID: rt.Profile.AccountPrefixID, }) fmt.Fprintln(rt.Stderr, output.TenantBanner(rt.Profile.TenantLabel(), rt.Profile.AccountPrefixID, rt.Profile.AwsAccountsLabel())) + applyProfileAccountScope(&f, rt) result, err := client.CostSummary(cmd.Context(), f) if err != nil { return err @@ -44,7 +45,7 @@ func newCostSummaryCmd() *cobra.Command { flags.StringVar(&f.StartDate, "start-date", "", "window start (YYYY-MM-DD)") flags.StringVar(&f.EndDate, "end-date", "", "window end (YYYY-MM-DD)") flags.StringVar(&f.Granularity, "granularity", "", "daily|monthly") - flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account") + flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account (default: the profile's account, e.g. GLASSITY_AWS_ACCOUNT_ID)") flags.IntVar(&f.Limit, "limit", 0, "page size (1..100)") flags.IntVar(&f.Page, "page", 0, "page number (1-indexed)") return cmd diff --git a/internal/cli/cost/summary_test.go b/internal/cli/cost/summary_test.go index 4e77f5a..95eb6b7 100644 --- a/internal/cli/cost/summary_test.go +++ b/internal/cli/cost/summary_test.go @@ -156,3 +156,52 @@ func TestCostSummary_NotAuthenticatedFromServer_PropagatesAsAPIError(t *testing. t.Errorf("Status = %d, want 404", apiErr.Status) } } + +// The profile's AWS account (which GLASSITY_AWS_ACCOUNT_ID overrides) must +// scope unflagged cost queries; before this fallback the server silently +// substituted its own default account. +func TestCostSummary_ProfileAwsAccount_ScopesUnflaggedQuery(t *testing.T) { + var gotAccountID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAccountID = r.URL.Query().Get("aws_account_id") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + })) + t.Cleanup(srv.Close) + + var stdout, stderr bytes.Buffer + rt := newCostTestRuntime(t, srv.URL, output.ModeJSON, &stdout, &stderr) + rt.Profile.AwsAccountID = "111122223333" + + if err := runCostSummary(t, rt); err != nil { + t.Fatalf("runCostSummary: %v (stderr=%s)", err, stderr.String()) + } + if gotAccountID != "111122223333" { + t.Errorf("aws_account_id = %q, want the profile fallback 111122223333", gotAccountID) + } +} + +func TestCostSummary_AwsAccountFlag_WinsOverProfile(t *testing.T) { + var gotAccountID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAccountID = r.URL.Query().Get("aws_account_id") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + })) + t.Cleanup(srv.Close) + + var stdout, stderr bytes.Buffer + rt := newCostTestRuntime(t, srv.URL, output.ModeJSON, &stdout, &stderr) + rt.Profile.AwsAccountID = "111122223333" + + cmd := newCostSummaryCmd() + cmd.SetArgs([]string{"--aws-account-id", "444455556666"}) + cmd.SetOut(rt.Stdout) + cmd.SetErr(rt.Stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.ExecuteContext(cli.WithRuntime(context.Background(), rt)); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + if gotAccountID != "444455556666" { + t.Errorf("aws_account_id = %q, want the explicit flag value", gotAccountID) + } +}