Repository navigation
fix(api): scope a scoped user's raw analytics account id to its provider - #1161
Conversation
A scoped user who requested analytics (history/analytics, history/breakdown,
analytics/trends) by the raw external id of their one registered account got
the provider-unconstrained filter {"": {id}}, so rows of another provider's
unregistered account sharing the number (an AWS account id and a GCP project
number, say) were aggregated too.
The scope check and the filter now come from one cloud_accounts snapshot:
resolveAnalyticsAccountFilter returns the filter for the account the
decision allowed, and a unique raw id resolves to {provider: {id}} under
the registered account's provider. Legacy rows with an empty provider are
no longer matched for scoped users, the same as the dashboard and inventory
intersection. Unrestricted sessions, UUID requests and ids no registered
account has keep their filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 4
✨ Finishing Touches
Comment |
|
VERDICT: APPROVE eb8c974 Reviewed head eb8c974 (1 commit) against origin/main f4387e4. Findings (none blocking):
Issue #624 acceptance:
Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE eb8c974 Findings (none blocking). A related non-blocking note was shared privately with the maintainers, per SECURITY.md.
Against #624:
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
Resolve the conflict in internal/api/handler_analytics.go with main's resolveAnalyticsAccountFilter (LeanerCloud#1161): drop this branch's local accountID lines and keep analyticsInterval/analyticsDimension next to main's account filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bring the branch up to date with main (000133) for review. Conflicts, both from LeanerCloud#1161 (cb25d99), resolved keeping main's LeanerCloud#624 provider-keyed analytics filter and this PR's ID-only scope matching: - handler_analytics_scope_provider_test.go: keep main's loop over the account and raw-external-id scopes, with the account entry as its ID ("u-in") instead of its display name. - handler_per_account_perms_test.go: keep main's rename to resolveAnalyticsAccountFilter and this PR's AccountScope.Allows wording (MatchesAccount is removed by this PR). LeanerCloud#1161 tests that scoped by display name now scope by account ID, since scope entries no longer match names (LeanerCloud#1034): - TestHandleRequest_Analytics_RawExternalIDProviderScopedOnEveryEndpoint ("data" -> "u-gcp") - TestHandleRequest_Analytics_ProviderlessAccountFailsClosed ("x" -> "u-x") - TestAnalyticsScopedRawExternalIDIsProviderScoped_DB and TestAnalyticsScopedRawExternalIDConcurrentRegistration_DB (integration): scope by the registered account's UUID instead of its name "dev". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
getHistoryAnalytics,getHistoryBreakdownandgetAnalyticsTrendsdid the scope check (validateAnalyticsAccountScope) and built the query filter (resolveSingleAccountFilterIDs) in two separate steps. The second step does not know about external ids: any value that is not a registered UUID becomes{"": {id}}.accountFilterClause(in bothinternal/api/analytics_postgres.goandinternal/analytics/postgres_analytics.go) turns that intoaccount_id = ANY(...)with no provider condition. So when a scoped user asked for the raw external id of their one registered account, the scope check passed, and the query also picked up rows of another provider's unregistered account with the same number (for example an AWS account id that equals a GCP project number).Fix
validateAnalyticsAccountScope+analyticsAccountAllowedare replaced byresolveAnalyticsAccountFilter+scopedAnalyticsAccountFilter. For a scoped session these make the scope decision and build the filter from oneListCloudAccountssnapshot:[uuid],{provider: {ext}}{"": {id}}(any provider){owner.Provider: {id}}{"": {id}}{"": {...}}Unrestricted/admin sessions still go through
resolveSingleAccountFilterIDs, so their filters are unchanged.Because the decision and the filter come from the same snapshot, there is no check-then-use gap. If a GCP account with the same id is registered between the scope check and the query, the request either answers with the AWS rows only or is refused as ambiguous. The concurrency test below runs that race on real Postgres.
Decisions
providerare no longer returned to a scoped user who asks by raw id.provider = 'aws' AND account_id = ANY(...)does not match them. Nothing links such a row to the registered account rather than to the other provider's account with the same number, and the dashboard/inventory scope intersection (intersectDualColumnScopere-grouping the""bucket under the allowed provider) already leaves them out. Unrestricted sessions still see them.""bucket when the allow-list names that literal id. There is no provider to pin it to, and the grant itself names only the bare id. Refusing these requests outright would remove access that fix(api): resolve analytics and inventory accounts by provider and external id #622 kept on purpose (TestHandleRequest_Analytics_UnregisteredRawID). If you want that path closed too, tell me and I'll do it as a follow-up.{match.Provider: {id}}, and adding the UUID would also match rows that carry onlycloud_account_id, which is a separate behavior change.Overlap with #1154
#1154 (branch
fix/issue-32, still open) adds real-Postgres tests for the store-level predicate ininternal/analytics. Itsgcp-same-numberfixture shows the store-level behavior this fix depends on: the""key matches the GCP row with the same number and the"aws"key does not. This PR doesn't change either predicate. It changes which key the handler passes, so the two PRs don't conflict and don't depend on each other. The new handler-level integration test here seeds its own copy of the same rows (aws / gcp-same-number / legacy-no-provider) in bothpurchase_historyandsavings_snapshots, because #1154's fixture lives inpackage analytics_testand isn't merged.Verification
All of this ran locally on macOS. The handler tests use mocks or fixtures; the integration tests use a real Postgres testcontainer. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is broken. No real cloud API was called.Fail before / pass after, real Postgres through
HandleRequest(internal/api/handler_analytics_scope_provider_integration_test.go):Before (on main
2d822f39with only the test added):After:
Also passes with
-race(CI runs integration tests with-race).Unit, through
HandleRequestwith mocks (internal/api/handler_analytics_scope_provider_test.go). I put the oldhandler_analytics.goback temporarily and these fail:TestHandleRequest_Analytics_RawExternalIDProviderScopedOnEveryEndpoint(all three endpoints),TestHandleRequest_Analytics_UniqueRawExternalID(it used to assert the leaky{"": {"111"}}shape and now asserts{"aws": {"111"}}), andTestHandleRequest_Analytics_ProviderlessAccountFailsClosed. All of them pass with the fix. The existing #520/#622 cases (ambiguous ids, unrestricted, unregistered, account-list failure) pass both before and after.Other checks:
Not covered: real-account analytics data. There are no real AWS/GCP accounts with colliding ids behind this; the colliding rows come from fixtures.
Out of scope
resolveSingleAccountFilterIDsitself still produces the""bucket for raw ids. The dashboard and inventory paths intersect its output with the allowed scope, and unrestricted analytics uses it unchanged.Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/this-quarter,impact/few,effort/s,type/bug.Closes #624
🤖 Generated with Claude Code