From f0b1ec5b428ae5d97571908fd0f5b13aaee9fb41 Mon Sep 17 00:00:00 2001 From: Davide Merli <53702616+davidemerli@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:37:31 +0000 Subject: [PATCH 1/2] fix(cost): scope unflagged queries to the profile's AWS account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLASSITY_AWS_ACCOUNT_ID was read into the profile but never consulted by the cost commands — only --aws-account-id reached the request, so an unflagged query silently fell back to whatever account the server treats as the default. In an agent context that reads as "this tenant has no cost data" whenever the default account happens to be empty. Cost commands now fall back to the profile's account when the flag is absent; an explicit --aws-account-id still wins. Two tests pin the fallback and the flag precedence. --- internal/cli/cost/cost.go | 14 ++++++++- internal/cli/cost/summary.go | 3 +- internal/cli/cost/summary_test.go | 49 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) 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) + } +} From 024381fb51780204172fd9c16bedbbf1ce498e36 Mon Sep 17 00:00:00 2001 From: Davide Merli <53702616+davidemerli@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:37:38 +0000 Subject: [PATCH 2/2] docs: document the full headless environment set The Headless and CI section said GLASSITY_TOKEN alone was enough. It is not: every tenant-scoped command then fails with "no active tenant" until GLASSITY_ACCOUNT_ID is exported too, and cost reads scope to GLASSITY_AWS_ACCOUNT_ID unless --aws-account-id is passed. The README example now exports all three, and the configuration reference says when the two account variables matter. --- README.md | 7 +++++++ docs/configuration.md | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) 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. |