Skip to content

fix(auth): bind group account scope entries to account IDs - #1116

Merged
IAmJSD merged 21 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1034
Oct 26, 2026
Merged

IAmJSD merged 21 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1034

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

A group's allowed_accounts can hold an account's display name (the group duplicate modal writes acct.name). The stored name is not bound to any account. AccountScope.Allows matched an entry against the account ID or the name, so once the account a name entry was meant for was deleted or renamed, the entry granted whichever account was later created or renamed to that name. #1032 limited name matching to names exactly one registered account carries. That does not help in this case, because the newcomer is the only account with that name.

Fix (fail closed)

This takes the issue's first option: resolve names to account IDs at grant time and store the IDs. Existing name entries are migrated, and name matching on scope entries is removed.

  1. Matching (internal/auth/account_scope.go): scope entries are compared with the account identifier the caller passes, never with a display name. WithRegisteredNames(names) becomes WithRegisteredAccounts([]RegisteredAccount{ID, Name}). It still lets a caller that knows an account only by name (legacy history and inventory rows keyed by external ID) identify it. The name resolves to the one registered account that carries it, and that account's ID must be in the scope. A shared name resolves to nothing (sec(auth): AccountScope.Allows grants every account that shares an allowed display name #433 semantics kept). Naming an account after an in-scope account's ID gains it nothing.

  2. Grant-time binding (internal/api/handler_groups.go, bindGroupAccounts): on POST /api/groups and PUT /api/groups/{id}, each allowed_accounts entry must be one of:

    • "*";
    • a registered account ID;
    • a registered account's external ID (kept verbatim, as before);
    • a name that exactly one registered account carries and that the acting principal's own scope covers. The name is stored as that account's ID.

    Anything else is a 400 with one message for unknown, ambiguous and out-of-scope names, so the response does not reveal which accounts exist. Entries are never silently dropped, so a restricted list cannot shrink to [] (which means unrestricted). If the account list or the actor's scope cannot be loaded, the write is refused. Omitted (nil), [] and ["*"] pass through unchanged to the existing ceiling checks. Binding runs before CreateGroupAPI/UpdateGroupAPI, so the account ceiling compares IDs.

  3. Migration 000145 (bind_group_account_names_to_ids) rewrites a stored name entry to an account ID only when all of these hold:

    • the entry is not * and not empty;
    • it is not already an account ID or external ID;
    • exactly one account carries the name (any provider, enabled or not);
    • that account's created_at <= groups.updated_at. An account registered after the grant was last written cannot be the account it was meant for, and that is the issue's delete-then-recreate case.

    Order is preserved and entries are only replaced, never dropped. update_groups_updated_at is disabled for the statement (same pattern as 000127), so the rewrite does not move updated_at. Otherwise a re-apply could bind an entry the first run left unresolved. Entries the migration cannot bind stay in place as plain strings. They no longer match by account name, but they still match a caller that passes the same string as an external ID (a known gap, left for a follow-up). The migration header has a query that lists them. Down is a no-op: pre-fix(auth): name-based account scope entries match a later account that reuses a deleted or renamed name #1034 code matches entries by ID too, so rewritten entries keep working after a rollback.

  4. Name-only helpers removed: auth.MatchesAccount and AuthContext.CanAccessAccount (tests only, no production callers), with their tests.

Migration number: 000145. main is at 000144 (#1289 merged on 2026-10-26), so this is next in the chain. #1239, #1241 and #1253 are held on owner decisions and will take 000146 and above. The migration touches only groups.

Behavior changes (please confirm)

  • Name entries stop granting access. A group that scoped an account by name loses it until the migration binds the entry or an admin grants the ID. The migration binds every name that unambiguously identifies an account that predates the grant. Entries it skips are shared names, names no account carries, and names of accounts created after the group's last write. Those entries now fail closed.
  • Group writes reject unbindable entries with 400. The edit modal never sends allowed_accounts, so it is unaffected. The duplicate modal sends ticked names, which now get bound to IDs. If nothing is ticked, it copies the source group's list, and a source group that still holds an unresolved legacy name now gets a 400 asking for an account ID.
  • GET /api/groups shows account IDs where names used to be.
  • A "" entry no longer matches a caller that passes "" as the account ID.
  • One extra ListCloudAccounts per group write that carries non-* entries, plus the existing scope load for restricted actors.

Concurrency

  • Group write vs. account rename or create: binding reads the account list and then stores IDs. If the account is renamed, or another account takes its name, between the read and the write, the grant stays on the account that carried the name when the admin submitted. IDs are server-generated UUIDs (createAccount sets uuid.New()) and are never reused, so a stored ID cannot later point at a different account. A concurrent delete leaves a dangling ID that matches nothing.
  • Migration vs. a concurrent group update: UpdateGroupAPI reads the group and then writes the whole row. If it reads before the migration commits and writes after, it writes the old name entries back. With this PR those entries are inert (no access, fail closed), so I did not add a race test. Real-Postgres coverage of the migration itself is below.

Verification

The branch is merged with origin/main (a14dc8f) at 3a6f92d. The runs below were first done on macOS, then repeated on the merged tree 3a6f92d on Linux, including the full -tags integration suites of ./internal/api/, ./internal/auth/ and ./internal/database/postgres/migrations/ (details in the PR comment for 3a6f92d).

All runs were local with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid. No cloud APIs were called. Evidence is unit and mock tests through HandleRequest and the handlers, plus a real Postgres container (testcontainers) for the migration. There is no real-account run.

  • go build ./..., go vet ./..., go vet -tags integration ./internal/...: pass
  • go test -count=1 ./internal/auth/ ./internal/api/ ./internal/mocks/ ./internal/server/ ./internal/database/postgres/migrations/: pass. go test -race ./internal/auth/: pass.
  • go test -tags integration ./internal/database/postgres/migrations/ -run 'TestMigration_BindGroupAccountNamesToIDs|TestMigrations_DownAllRoundTrip|TestMigrations_FullStackIdempotent': pass
  • Scope-related integration tests on real Postgres: go test -tags integration ./internal/api/ -run 'TestMarketplacePermissionConstraintsHTTP|TestMarketplaceListCancelFailure|TestAdminUpdateUserHTTPKeepsConcurrentMFA' and ./internal/server/ -run TestConfigPermissionConstraintsHTTP: pass
  • golangci-lint v2.10.1 --allow-parallel-runners on ./internal/auth/... ./internal/api/..., and with --build-tags integration on the migrations package: 0 issues
  • gocyclo -over 10 on the changed non-test files: clean. scripts/gofmt-hook.sh and scripts/gosec-hook.sh on the changed packages: clean.
  • git-secrets on the changed files in a fresh git init repo with an isolated HOME: clean. bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.

Fail before, pass after. I reverted account_scope.go, types.go, group_ceiling.go, handler.go and handler_groups.go to origin/main and kept the new tests. These fail on main and pass here:

  • TestHandleRequest_AccountScope_NameReusedAfterDelete: the issue's scenario. A name entry whose account was deleted reaches the account later registered under that name (200 on main, 404 here).
  • TestGroupGrantByName_SurvivesRenameAndNameReuse: create a group by name, rename the account, register a newcomer under the old name. The grant stays with the renamed account.
  • TestGrantScoped_NameEntryDoesNotMatch, TestGetAccount_NameEntry_Returns404, TestGetRIUtilization_ScopeByAccountNameReachesNothing, the name-entry cases of TestHandler_getRIExchangeHistory_AccountScope and TestAuthorizeSessionRevoke_RevokeAny_AccountScope, and TestHandleRequest_AccountScope_SharedDisplayName/a_unique_name_entry_grants_nothing_either.
  • All the write-path tests: TestCreateGroup_BindsAccountNamesToIDs, ..._RefusesUnbindableAccountEntries (unknown, shared by three accounts, empty, outside the actor's scope), ..._ScopedActorBindsInScopeName, ..._AccountListFailureRefusesWrite, ..._NoAccountStoreRefusesWrite, TestUpdateGroup_BindsAccountNamesToIDs, ..._RefusesUnbindableAccountEntry.

Controls pass on both: IDGrantSurvivesRename, the pass-through cases for omitted, [] and *, and OmittedAccountsStayNil.

Mutation probes, each killed by at least one test:

  • Matching:
    • name entries match again;
    • a resolved name grants without the ID check;
    • shared names treated as unique (c >= 1);
    • getAccountScope never registers accounts.
  • Binding:
    • ignore the actor's scope;
    • a third duplicate makes a name unique again;
    • store unknown entries verbatim;
    • create stores unbound entries;
    • a list failure passes entries through.
  • Migration:
    • drop the created_at guard;
    • drop the * exclusion (an account named * would narrow wildcard groups);
    • leave the updated_at trigger on;
    • count >= 1;
    • ignore external IDs.

Existing tests updated: fixtures that scoped by display name (execution scope matrix, history, inventory, analytics, revoke, dashboard, accounts, RI utilization, RI exchange history, and the #1161 analytics provider-scope tests brought in by merging main, unit and integration) now scope by account ID and keep their original assertions. Tests that pinned name matching (TestGrantScoped_MatchesByAccountName, TestGetAccount_InScope_ByName, the #433 shared-name suite, and the by-name cases of #1169's RI exchange history unit and integration tests brought in by merging main) are inverted or reworked. The TestNoUnfailableMockAssertions guard (go test ./internal/mocks/) passes. markdownlint is not installed here, so the CHANGELOG entry was checked by eye against the existing entries.

Out of scope / notes

  • Frontend: the duplicate modal still sends names (cb.value = acct.name). The backend now binds them. Sending IDs would make the modal immune to a same-name race, and it should not copy a source group's unresolved entries. I left this for UI work because this PR does not touch frontend/.
  • External-ID entries keep their literal semantics. Provider external IDs are issued by the provider rather than chosen as labels, so they were not rebound.
  • The migration's created_at guard cannot detect an existing account that was renamed into a dangling name after the grant, or a group edited after the reusing account appeared. In both cases the name is bound to the account that carries it at migration time. Groups that relied on the pre-fix behavior were already exposed to this.
  • Fixed in 13f0025e (was pre-existing): resolveAccountNamesByID, which keyed names by internal ID and external ID in one map, is removed. The scope checks on purchase, dashboard, ladder and reshape now decide on the internal account ID.

Please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/m, type/security, triaged.

Closes #1034

🤖 Generated with Claude Code

A group's allowed_accounts could hold an account's display name. The name
was not bound to any account, so once that account was deleted or renamed
the entry matched whichever account was later created or renamed to the
same name. LeanerCloud#1032 limited name matching to unique names, which does not
help here: the newcomer is the only account carrying the name.

Scope entries now match by account identifier only:

- AccountScope.Allows never compares an entry with a display name. A
  caller that knows an account only by name (legacy rows keyed by external
  ID) still resolves through WithRegisteredAccounts to the one account
  carrying that name, and that account's ID must be in the scope.
- Group create/update bind allowed_accounts at grant time: "*", account
  IDs and external IDs are kept; a name exactly one registered account
  carries, inside the actor's own scope, is stored as that account's ID;
  anything else is a 400. A failed account or scope load refuses the write.
- Migration 000135 rewrites existing name entries to IDs when exactly one
  account carries the name and it predates the group's last write. Other
  entries are left in place and no longer grant access. Down is a no-op.
- The name-only MatchesAccount and AuthContext.CanAccessAccount helpers,
  which had no production callers, are removed.

Closes LeanerCloud#1034

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/security Security finding labels Oct 19, 2026
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): REQUEST_CHANGES 5d34f77

The code and the migration look correct. The only reason for changes is that the branch is stale against origin/main: it does not merge cleanly, and after resolving the conflicts, two of main's tests fail.

Migration order: this PR adds 000135 and is link 2 of a chain that must merge strictly in this order:

#1110 (134) -> #1116 (135) -> #1140 (136) -> #1170 (137) -> #1197 (138) -> #1201 (139)

golang-migrate skips a lower version once a higher one is applied.

Findings:

Attacked:

  • Callers of Allows (20 sites): every name passed in comes from the registry, so resolving it leads back to the same account and creates no new path.
  • Removed functions: MatchesAccount, CanAccessAccount and WithRegisteredNames have no callers left on head or on the merged tree.
  • Other checks:
    • There is no SQL-side name matching on the scope.
    • The seeded system group is rewritten correctly.
    • The migration and the Go binder agree: exact-case matching, ID and external-ID entries kept verbatim, any provider, enabled or not.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp. All 5 were caught:
    • M1, name entries match again: NameEntryNeverMatches, GrantScoped_NameEntryDoesNotMatch and GetAccount_NameEntry_Returns404 FAIL.
    • M2, the binder ignores the actor's scope: TestCreateGroup_RefusesUnbindableAccountEntries FAILS.
    • M3, the created_at guard dropped: the 135 test FAILS.
    • M4, the updated_at trigger left on: the 135 test FAILS.
    • M5, the * exclusion dropped: the 135 test FAILS.
  • Fail-before: the new tests do not compile on base because of the API rename. M1 restores base's name matching and fails them.

Verified locally, with no cloud credentials and testcontainers postgres:16-alpine, under the heavy lock:

  • Head:
    • gofmt: clean.
    • go build ./...: pass. go vet on auth and api, and with -tags integration on migrations: pass.
    • go test -count=1 on auth, api, mocks, server and migrations: pass.
    • -tags integration -run 'TestMigration_BindGroupAccountNamesToIDs|TestMigrations_DownAllRoundTrip|TestMigrations_FullStackIdempotent': pass.
  • Chain tree (origin/main, this PR with conflicts resolved, and fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought #1110's 000134):
    • TestMigration_PurchaseSuppressionsVariant, the 135 test, DownAllRoundTrip and FullStackIdempotent: pass.
    • A scratch chain test: pass.
      • It migrates 133 to 134 to 135, checking the version and dirty flag at each step.
      • It checks the bindings: names are bound; IDs, external IDs, unknown names and * are kept; updated_at is unchanged.
      • It rolls back 2 steps to 133 with the data intact, re-applies to 135 with an identical result, and confirms the trigger fires again.
    • Unit tests: api FAILS on the 2 tests above. auth, mocks, server and migrations pass.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

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>
@IAmJSD

IAmJSD commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Re: Independent review 2 (5d34f77). New head: d980764. I merged origin/main (c8bdd46) rather than rebasing. Migration 000135 is unchanged.

[blocking] Merge conflicts (#1161, cb25d99). Resolved in merge commit d980764. Both sides survive:

[blocking] #1161 tests that scope by display name. They now scope by account ID:

  • TestHandleRequest_Analytics_RawExternalIDProviderScopedOnEveryEndpoint: "data" changed to "u-gcp" at handler_analytics_scope_provider_test.go:217, :224, :233.
  • TestHandleRequest_Analytics_ProviderlessAccountFailsClosed: "x" changed to "u-x" at :248, :252.
  • Also fixed, not in the review: fix(api): scope a scoped user's raw analytics account id to its provider #1161's integration tests in internal/api/handler_analytics_scope_provider_integration_test.go:198, :206, :226 scoped by the name "dev". They now scope by db.awsUUID.
    • With main's version on the merged tree, TestAnalyticsScopedRawExternalIDIsProviderScoped_DB fails in 2 subtests (scope_dev, scoped_UUID_request).
    • TestAnalyticsScopedRawExternalIDConcurrentRegistration_DB passed only vacuously, because every request was refused. Its assertions don't require ok > 0. I left that as is because it is main's test and the count is timing-dependent.

[non-blocking] Down is a no-op. No change. Agreed it is acceptable for this one-way data rewrite.

PR body. The stale "main is at 000130" line now says: main is at 000133, #1110 holds 000134, and this PR must merge after #1110 and before #1140. I also noted the #1161 fixture updates and the re-run on the merged tree. Closes #1034 is unchanged.

Note, unchanged: for a raw external ID, main's scopedAnalyticsAccountFilter calls allowed.Allows(accountID, match.Name). An ID-scoped user therefore reaches the account through name resolution. If the account's display name is shared, the request fails closed (404) even though the external ID is unique. This is not a leak. Passing match.ID would avoid it, but that changes code from #1161, so I left it for a follow-up.

Verification on d980764, local, with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. No cloud APIs were called.

  • go build ./... passes. go vet on api and auth, and with -tags integration on api and migrations, passes.
  • go test -count=1 passes, one package at a time, for auth, api, mocks, server and migrations.
  • go test -tags integration ./internal/api/ (full suite, testcontainers Postgres) passes in 242s.
  • With -tags integration, these pass:
    • migrations: TestMigration_BindGroupAccountNamesToIDs|TestMigrations_DownAllRoundTrip|TestMigrations_FullStackIdempotent
    • server: TestConfigPermissionConstraintsHTTP
  • golangci-lint v2.10.1 --allow-parallel-runners on api and auth, and --build-tags integration on api and migrations: 0 issues. gofmt -l is clean. gocyclo -over 10 on the changed non-test files is clean.
  • git-secrets (--register-aws plus the repo's .gitallowed) on the 28 changed files, in a fresh git init repo with an isolated HOME: clean.

The PR stays a draft.

🤖 Generated with Claude Code

Conflicts, both from LeanerCloud#1169 (ef96ae7), which moved the RI exchange
history scope filter into the store query:

- internal/api/handler_ri_exchange.go: take main's
  loadRIExchangeHistory/exchangeAccountIDsInScope. This branch only
  reworded the scope comment ("UUID or name" -> "UUID"); carry that
  wording into main's relocated doc comment.
- internal/api/router_handlers_test.go: keep main's wantIDs column
  (the account set pushed into GetRIExchangeHistoryForAccounts) and
  this branch's ID-only expectations. A name entry now pushes only the
  literal name, so "prod" sees 0 rows and "dev" pushes only "dev".

Also adapt LeanerCloud#1169's integration test to ID-only matching: the
"scoped to B by name" case becomes "a name entry for B matches
nothing" and asserts an empty history (LeanerCloud#1034).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (a14dc8f) to resolve conflicts. New head: 3a6f92d. This is a normal merge commit, with no rebase or force-push. No cristim review has been posted since review 2 (5d34f77), which was addressed in d980764, so this push only brings the branch up to date. Migration 000135 is unchanged and main ends at 000134 (#1110 merged).

Conflicts. Both came from #1169 (ef96ae7), which moved the RI exchange history scope filter into the store query (GetRIExchangeHistoryForAccounts):

  • internal/api/handler_ri_exchange.go: kept main's loadRIExchangeHistory / exchangeAccountIDsInScope. This PR's only change in that block was the comment "UUID or name" -> "UUID", so I carried that wording into main's relocated doc comment (:2274). No logic change on either side.
  • internal/api/router_handlers_test.go:1023-1029 (TestHandler_getRIExchangeHistory_AccountScope): kept main's wantIDs column (the ID set pushed into the store query) with this PR's ID-only expectations.
    • The name entry "prod" now pushes only ["prod"] and sees 0 rows (renamed "a name entry matches nothing").
    • "dev" pushes only ["dev"] instead of ["444455556666", "dev"].
    • The other rows are main's.

Not a textual conflict, also fixed. #1169's TestRIExchangeHistory_ScopedUserSeesOlderOwnRows_Integration had a "scoped to B by name" case expecting B's 3 rows, which fails under ID-only matching. It is now "a name entry for B matches nothing" and asserts an empty history (handler_ri_exchange_history_scope_integration_test.go:63, :79). The uuid and STS-ID cases and main's limit-after-filter assertions are unchanged.

PR body. Updated the migration-order line (#1110 has merged) and the verification base. Added #1169's by-name cases to the list of inverted tests. Closes #1034 is unchanged.

Verification on 3a6f92d, run locally on Linux with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. No cloud APIs were called.

  • go build ./..., go vet ./..., go vet -tags integration ./internal/...: pass.
  • Unit tests, go test -count=1 one package at a time: auth, api, mocks, server, migrations and config pass.
  • Integration tests, go test -v -tags integration -count=1, one package at a time, testcontainers postgres:16-alpine:
    • ./internal/database/postgres/migrations/, full package: 146 PASS. Includes TestMigration_BindGroupAccountNamesToIDs (000135), TestMigration_PurchaseSuppressionsVariant (000134), TestMigrations_DownAllRoundTrip and TestMigrations_FullStackIdempotent.
    • ./internal/api/, full package: 4412 PASS. Includes the edited fix(api): apply the RI exchange history limit after the account filter #1169 test and fix(api): scope a scoped user's raw analytics account id to its provider #1161's TestAnalyticsScopedRawExternalID*_DB.
    • ./internal/auth/: 1080 PASS.
    • Some tests in each package failed only at container startup (testcontainers reaper unexpected container status "removing" or a wait-for-ready deadline). This VM's shared Docker daemon was heavily loaded. There were no assertion failures. Each of those tests passed when rerun alone by name:
      • migrations: ExecutionsAccountFKRestrict_Rollback, DropUserRoleToGroups, DropUserRoleToGroups_Down, GrantViewConfigToNonAdminGroups, AutoPurchaseSwitchStamps, SplitSavingsPlans
      • api: TestLadderTimelineAmendmentRoutedPersistence, TestDeleteManagedPlanByExecutionStatus, TestUpdatePlan_CapPresencePostgres
      • auth: TestIntegration_ResetConfirmRejectsStaleRead
    • Because of the load I used -timeout=60m for the full packages; CI uses 20m. Two earlier full runs hit the timeout, and every test they completed passed apart from the startup failures above.
  • golangci-lint v2.10.1 --allow-parallel-runners on api and auth, and with --build-tags integration on api, auth and migrations: 0 issues. gofmt -l: clean. gocyclo -over 10 on the changed non-test files: clean.
  • git-secrets (--register-aws plus the repo's .gitallowed) on the 28 changed files in a fresh git init repo with an isolated HOME: clean. A planted fake key was flagged, so the scan was live. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Not run: the gosec hook, because it installs gosec, which I can't do on this VM. The merge changes no non-test Go logic, so CI's gosec step covers it.

The PR stays a draft.

🤖 Generated with Claude Code

IAmJSD and others added 5 commits October 26, 2026 12:22
The account_ids analytics tests that arrived with the main merge (LeanerCloud#1448)
grant scope by account name ("a", "b", "c"). This branch binds group
scope entries to account IDs and no longer matches an entry against a
display name (LeanerCloud#1034), so those name entries authorize nothing and the
scoped cases got 404. List the account IDs in the scope instead, as the
other analytics scope tests on this branch already do. The assertions
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestAnalyticsAccountIDs_SumsSelectedAccountsOnly_DB (from LeanerCloud#1448) grants
the scoped user ["a", "b"], which are account names. Scope entries are
account IDs on this branch (LeanerCloud#1034), so the name entries allowed nothing
and the in-scope query returned 404. Use the created accounts' IDs.
The foreign-id refusal assertion is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestIntegration_HistoryPlanFilter_CrossTenant (from LeanerCloud#1445) grants the
scoped user tenant A by account name. Scope entries are account IDs on
this branch (LeanerCloud#1034), so the name allowed nothing and tenant A's own
plan came back empty. Grant tenant A's ID, as the other history
integration tests on this branch do. The cross-tenant assertions are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (at ecf8493) and fixed the CI failures. New head: 0caf1b17f30e3c9d3c0ad2aa97feea161932a4e7.

Cause: three tests that came in from main still give the scoped user account names as scope entries. This PR makes scope entries account IDs and stops matching an entry against a display name. Under that model a name entry authorizes nothing, so the in-scope requests returned 404 or empty results. The tests were written against the old model. The code is not wrong: the plural-over-singular, allowed-subset and foreign-ID-refusal behaviour is all still enforced.

Fix (tests only, no production code or auth change): the scope fixtures now list account IDs, the same way this PR already updated the sibling analytics and history tests. All assertions are unchanged.

Migration number stays at 000145.

Local checks: build, vet (default, integration, e2e), golangci-lint with and without the integration tag, gocyclo, go test -race short and integration for internal/api, internal/auth and the migrations package, internal/server short tests, and the secrets scan. All pass.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES 0caf1b17f30e3c9d3c0ad2aa97feea161932a4e7

Blocking:

  • internal/auth/account_scope.go:163-175 (Allows) with internal/api/scoping.go:554-569 (resolveAccountNamesByID): there's a new scope bypass for ID-scoped users on the purchase gate.
    • How it happens: Allows(accountID, name) now resolves name to the one account carrying it and grants when that account's ID is in scope. It never checks that the name belongs to the same account as accountID. The callers that pass nameByID[id] (validatePurchaseRecommendationScope scoping.go:181-188, dashboard.go:194, ladder.go:61, ri_exchange.go:1413) build nameByID from both a.ID and a.ExternalID in one map, last writer wins, and ListCloudAccounts orders by name.
    • external_id is free text: validateCloudAccountRequest only requires it to be non-empty with no control characters.
    • Failure scenario: a user scoped by ID to account Z who holds update:accounts on Z sets Z's external_id to victim account Y's UUID and renames Z so it sorts after Y. Then nameByID[Y.UUID] is Z's name, Allows(Y.UUID, "zz-team") resolves to Z.ID, and that ID is in scope. validatePurchaseRecommendationScope accepts a recommendation that buys in Y.
    • On main the same call is refused, because a name only matched a name entry and an ID-scoped group has none. The body calls the shadowing "contrived" and "pre-existing", but this PR is what makes it reachable from ID scopes, the form every group now has.
    • Proven with a scratch test (not pushed). Accounts {Y, "prod-payments", aws, 123456789012} and {Z, "zz-team", azure, external_id=Y}, scope [Z].
      • PR head: validatePurchaseRecommendationScope returns err=<nil> for a rec with CloudAccountID=Y.
      • With main's account_scope.go/types.go/group_ceiling.go/handler.go/handler_groups.go: recommendation 0 targets account Y which is outside your allowed_accounts.
    • Fix: bind the name to the identifier. For example, WithRegisteredAccounts also records each account's ExternalID, and Allows grants via the name only when the resolved account's ID or ExternalID equals accountID. Separately, make resolveAccountNamesByID never let an ExternalID key overwrite an ID key. Add the scenario above as a regression test.

Non-blocking:

  • internal/api/handler_groups.go:263 (bind): a registered ID or external ID is kept verbatim whatever the actor's scope, and the ceiling then refuses it with 403. An unknown entry gets 400. So a scoped holder of create:groups can tell whether a given UUID or external account number is registered. The body's "one message for unknown, ambiguous and out-of-scope" holds for names only.
  • A group that still holds the ID of a since-deleted account now gets 400 when a client re-sends its own list, for example the duplicate modal with nothing ticked. Worth a line in "Behavior changes".
  • Duplicate modal: ticking an account whose display name is shared now fails with 400. It used to store an inert name. Sending IDs from the modal, as the body suggests, fixes both of the points above.
  • Coordination with fix(history): push filters and account scope into the actionable executions read #1464: its executionScope builds the SQL scope from literal entries plus Allows(a.ID, a.Name). That stays a correct superset under the new Allows, but whichever PR merges second should re-run the scoped History tests on the merged tree.

Attacked:

  • Read every AccountScope.Allows call site and where its name argument comes from. Only the nameByID callers can pass a name that belongs to a different account than the ID, and that led to the blocker.
  • Checked:
    • getAccountScope is the only consumer of GetAllowedAccountsAPI.
    • POST/PUT /api/groups is the only group write path.
    • Unresolvable entries are refused, never dropped, so [] can't appear.
  • Migration 000145:
  • Ran:
    • go build ./...; go vet ./...; go vet -tags integration ./internal/....
    • go test -race -short -count=1 on auth, api, mocks, server and migrations (pass).
    • go test -race -tags integration -count=1 on ./internal/api/ (pass, 225s) and ./internal/auth/ (pass).
    • The migrations package with -run 'TestMigration_BindGroupAccountNamesToIDs|TestMigrations_DownAllRoundTrip|TestMigrations_FullStackIdempotent' (pass).
  • Fail-before: in a scratch worktree with the 5 non-test files reverted to origin/main, these fail: TestHandleRequest_AccountScope_NameReusedAfterDelete, TestGroupGrantByName_SurvivesRenameAndNameReuse, TestCreateGroup_BindsAccountNamesToIDs, TestUpdateGroup_BindsAccountNamesToIDs, TestCreateGroup_RefusesUnbindableAccountEntries and TestGrantScoped_NameEntryDoesNotMatch.

Body-stale:

Reviewer: rE7. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

IAmJSD and others added 4 commits October 26, 2026 14:32
…nt ID

LeanerCloud#1464 landed on main with tests that scope the user by the display name
"dev" and with an executionScope case built on WithRegisteredNames. This
branch (LeanerCloud#1034) makes name entries grant nothing and replaces that
constructor, so after merging main those tests no longer compiled or no
longer exercised the scoped path. Scope them by the account's ID, which
is what a group stores now, and pin that a name entry contributes only
its literal value to the SQL scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (rE7) found that an ID-scoped user could pass the purchase gate
for an account outside their scope. resolveAccountNamesByID keyed
names by both account ID and the free-text external ID in one map, so
an editor of in-scope account Z who set Z's external ID to victim Y's
ID got Z's name back for Y. AccountScope.Allows then resolved that
name to Z, which is in scope, and allowed a purchase in Y. The same
map fed the dashboard, ladder and RI reshape filters.

Decide those gates on the record's cloud_account_id itself, the ID
the purchase resolves its credentials from, and delete the dual-keyed
map. Bind the name path in Allows as well, so a name only identifies
an account through that account's own external ID and never through a
value that is some registered account's ID. Callers that pass a name
for an external ID already resolve it within the record's provider or
require exactly one owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to rE7's review of 0caf1b17. New head: 13f0025ee42adb898abc3e12a7610fc9824a1b4b, which adds 0ccf687a and cdd9eeae plus two merges of origin/main. #1464, #1289 and the other recent main commits are included.

Blocking: the name-to-account bypass on the purchase gate. Fixed at two layers.

  • The callers now decide on the account ID. validatePurchaseRecommendationScope, the dashboard rec filter, the ladder filter and reshapeCloudAccountInScope call Allows(cloud_account_id, ""). The purchase resolves its credentials with GetCloudAccount on that same ID, so the gate now checks the account the purchase actually acts on. resolveAccountNamesByID (the map keyed by both ID and external ID) is deleted. It had no other callers.
  • AccountScope.Allows ties the name to the identifier. WithRegisteredAccounts now also records each account's external ID and the set of all internal IDs. The name path grants only when all of these hold:
    • the name belongs to exactly one account;
    • the identifier the caller passed is that account's own external ID;
    • the identifier is not the internal ID of any registered account;
    • that account's ID is in scope.
      So a name looked up for one account can no longer grant a different one.
  • External-ID resolution is never picked by list order. External IDs are unique per provider only, so the scope can't resolve one by itself, and it no longer tries. The callers that still pass a name already pin the account unambiguously:
    • History and inventory use the row's provider, or require exactly one owner when the row has no provider.
    • Revoke uses uniqueAccountByExternalID with the row's provider.
    • Analytics uses externalIDOwner, which refuses an ambiguous ID.
    • RI exchange looks up the AWS account only.
      The Allows doc comment now states this requirement.

Regression tests:

  • TestAccountScope_EditorControlledExternalIDGrantsNothing (api). Your scenario: Y{prod-payments, aws, 123456789012}, Z{zz-team, azure, external_id=Y}, scope [Z], accounts listed in name order. A second case is the ambiguous external ID, where Z's external ID equals Y's account number. Each case checks the purchase gate (403), the dashboard, the ladder and RI reshape, with Z's own ID as a positive control.
  • TestAccountScope_NameMustIdentifyTheAccountOfTheIdentifier (auth): the same scenario at the scope level.
  • TestAccountScope_SharedExternalIDBindsOnlyTheNamedAccount (auth) and TestHandleRequest_AccountScope_AmbiguousExternalIDHistoryRow (api): guards for a shared external number.

Fail-before: I copied the new tests onto 0caf1b17 in a scratch worktree. Both api cases fail there: the purchase gate returns nil, and the dashboard, ladder and reshape checks all admit the victim. The auth scenario test also fails there. All of them pass on the new head. The two shared-number guards pass on both heads; they exist to keep it that way.

The earlier review's note on leftover unbound entries (rB12). This fix does not close it, and I found no small, safe way to close it here.

  • An entry the migration couldn't bind is a plain string. It still matches any caller that passes that exact string as an external ID, because Allows keeps the literal-entry match.
  • At check time a leftover name can't be told apart from a real external-ID entry, and group writes still store external IDs verbatim by design.
  • Closing it needs the entries to record what they are. That means rewriting external-ID entries to internal IDs (a migration plus a bind change) so literal external-ID matching can be retired, or flagging unbound entries. Either is bigger than this PR.
  • What the gap allows: someone who can edit an account's free-text external ID can make that account visible to a group holding a matching leftover string. That gives away an account they already control. It does not give them access to anyone else's.
  • I recommend a follow-up issue for it.

Non-blocking points:

  • bind showing whether an ID is registered (403 vs 400): unchanged. The IDs are server-generated UUIDs or provider account numbers.
  • A group that still holds a deleted account's ID gets a 400 when its own list is re-sent, and the duplicate modal fails for a shared display name. Both are unchanged and still worth a "Behavior changes" line in the body. Having the modal send IDs (frontend follow-up) fixes both.
  • Coordination with fix(history): push filters and account scope into the actionable executions read #1464: it landed on main first, so I re-ran the scoped History tests on the merged tree, both the unit tests and the integration tests (handler_history_*, handler_ri_exchange_history_scope*, as part of the full ./internal/api runs). Two fix(history): push filters and account scope into the actionable executions read #1464 tests scoped the user by the display name "dev", and its executionScope table used WithRegisteredNames. Under this PR a name entry grants nothing, so 0ccf687a scopes them by the account ID. executionScope is still a correct superset: it lists literal entries plus the ID and external ID of each in-scope account.
  • The CHANGELOG entry no longer says unbound entries "stop granting access". It now says they no longer match by name.

Body-stale (not edited here): the "contrived / pre-existing" paragraph about resolveAccountNamesByID and the migration-order line are out of date. That helper is gone, and main is now at 000144, so this PR's 000145 is next.

Checks on 13f0025e:

  • go build ./... and go vet (plain, with -tags integration, and e2e): clean.
  • golangci-lint 2.10.1 on api, auth and migrations, with and without --build-tags integration: 0 issues.
  • gocyclo -over 10 on the changed files: silent.
  • go test -race -short on api, auth, migrations, mocks and server: pass.
  • go test -race -tags integration on api (pass, 230s), plus auth and migrations (pass, on the tree before the last main merge, which touched neither package).
  • Secrets scan: clean, planted key flagged. scripts/test-git-secrets-allowlist.sh: 183 PASS.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 13f0025ee42adb898abc3e12a7610fc9824a1b4b

Blocking:

  • none

Non-blocking:

  • internal/api/handler_groups.go:261: ID and external-ID entries are kept without an actor-scope check, so a restricted group admin gets a different answer for them. A registered but out-of-scope external ID or UUID gets the ceiling's 403 (cannot grant access to cloud account "X"). An unregistered one gets this PR's 400 (does not identify exactly one cloud account). That works as an oracle for which AWS account numbers or subscription IDs are registered. The body's claim that the response "does not reveal which accounts exist" holds for names only. Fix: refuse with the same 400 when !actor.Allows(entry, "") for ID and external-ID entries too. It needs create/update:groups, and the ceiling still blocks the grant, so this is low severity.
  • internal/api/handler_groups.go:261: the external-ID check runs before the name lookup. If any account's (free-text) external ID equals another account's display name, an admin who types that name has the entry stored verbatim as an external ID. It then grants the external-ID account's legacy rows, not the named account. This is an edge case, but resolving to the account ID in both cases would close it.
  • 000145 up.sql:57: the created_at <= groups.updated_at guard is weak when a group got any later write, such as a description or permission edit. In the issue's own scenario (grant, delete, newcomer takes the name, group edited later), the migration binds the name to the newcomer's ID. That turns today's accidental access into a durable grant by ID, which later renames no longer undo. The body states this as a known gap, and it is no worse than main today. Fully fail-closed (bind nothing, have admins re-grant by ID) or an extra ca.updated_at <= src.updated_at guard are the alternatives. That is a product call for the acting owner.
  • Behavior changes not called out in the body:
    • Group writes now 400 on an unregistered external ID, so an account can't be pre-granted before it is onboarded.
    • The duplicate modal (frontend/src/groups/groupModals.ts:578 sends acct.name) can no longer grant an account whose display name is shared. It gets a 400 until the frontend sends IDs.

Attacked:

  • Read every AccountScope.Allows caller. The decision is always on an ID. A name only identifies an account through its own external ID, and never through another account's internal ID (internalIDs). That closes the rE7 shadow-external-ID path, and the test covers purchase, dashboard, ladder and reshape.
  • Checked group writes. The handler is the only writer (no SSO or seed path), the permission check runs before binding, the ceiling runs after on IDs, and [], ["*"] and omitted lists pass through. The migration runs in one implicit transaction (migrate.go:363) and the trigger is re-enabled. The down is safe because main's Allows matches IDs. 000145 follows main's 000144.
  • Fail-before, each restored afterwards:
    • Re-enabled unique-name matching in Allows: NameReusedAfterDelete, SharedDisplayName, GrantScoped_NameEntryDoesNotMatch and GetAccount_NameEntry_Returns404 fail.
    • Bypassed bindGroupAccounts: CreateGroup_BindsAccountNamesToIDs, UpdateGroup_BindsAccountNamesToIDs and GroupGrantByName_SurvivesRenameAndNameReuse fail.
    • Dropped the migration's created_at guard: TestMigration_BindGroupAccountNamesToIDs fails on the reused-name and created-later groups.
  • Ran on 13f0025:
    • go build ./..., go vet ./..., go vet -tags integration ./..., e2e vet: pass.
    • go test -race -short -count=1 on auth, api, mocks, server and migrations: pass.
    • go test -race -tags integration -count=1 on migrations, auth and api (full suites): pass.
    • golangci-lint 2.10.1 with and without --build-tags integration: 0 issues. gocyclo -over 10 on the changed files: clean.

Body-stale:

  • "so the response does not reveal which accounts exist" is true for names only. See the first non-blocking item.

Reviewer: rE10. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 13f0025ee42adb898abc3e12a7610fc9824a1b4b

Blocking:

  • none

Non-blocking:

  • internal/api/handler_groups.go bindGroupAccounts/bind: this adds a small existence oracle on the group write routes for a restricted principal who can create or update groups. An entry that is a registered account ID or external ID passes binding unchecked and then fails the ceiling with 403 "cannot grant X because your own account scope does not include it". Any unregistered string fails binding with 400 "does not identify exactly one cloud account you can access". So the caller can tell whether a given AWS account number / subscription / UUID is registered, even though /api/accounts/list is scoped and hides it. On main, both cases got the same 403 from the ceiling. The impact is low (admin-only route, and the caller has to guess identifiers), but it contradicts the PR body's claim. One fix: on create, also send registered ID/external-ID entries that actor.Allows rejects to the same 400. On update, entries already in the group's stored list have to stay allowed, because the ceiling's non-widening rule permits them.
  • Behaviour change for the acting owner to confirm (the PR flags it too): name entries the migration can't bind (shared names, names no account carries, accounts registered after the group's last write) silently stop granting access on deploy. The migration header has a query that lists them. It would be worth running that query on production data before the migration and sending the result to whoever owns those groups, so a fail-closed lockout isn't the first sign of it.
  • Any client that round-trips a group (GET, then PUT with the same allowed_accounts) now gets a 400 if the group holds an unbound legacy entry, or an ID of an account that has since been deleted. The duplicate modal does this when nothing is ticked. That's fail-closed and documented, but the error text could name the stale entry's cause ("no longer identifies an account").
  • External-ID entries stay unbound (a known gap, acknowledged in the PR). An external-ID entry for a deleted account still matches legacy external-ID-keyed rows of a later account that registers the same external ID. A follow-up issue would keep this from getting lost.
  • From earlier reviews (read after I wrote the verdict above): the oracle in the first item was also raised in the earlier IAmJSD reviews on 098a0d4 and 13f0025, and it's still open. The 13f0025 review also notes that the migration's created_at <= groups.updated_at guard is weak once a group has had any later edit, so a reused name can end up bound for good. I agree. It is no worse than main, and it's a product call.
  • Cosmetic: the migration test still uses m149* names for constants from an older migration number.

Attacked:

  • Read every remaining Allows( call site. Each passes either (ID, Name) of the same account or (externalID, name) resolved provider-scoped or uniquely (historyRowAccountName, accountNameByExternalID, exchangeRecordInScope). The new name path needs accountID == named.ExternalID, rejects internal IDs and then checks named.ID in scope, so a name never matches an entry and an editor-set external ID can't redirect it.
  • Group write paths: CreateGroupAPI/UpdateGroupAPI are only reached via handler_groups.go, and both bind. nil/[]/["*"] pass through to the existing ceiling. A list can't shrink to [] because entries are never dropped.
  • Migration 000145: number is next after main's 000144 (feat(marketplace): poll RI Marketplace listings and record proceeds and AWS fee (backend slice of #27) #1289 merged). Branch contains current origin/main fe11124. The LEFT JOIN LATERAL handles 0 matches (HAVING on the empty aggregate), *, "", NULL and empty arrays. The trigger is disabled and re-enabled inside the migration transaction (same pattern as 000127). Down is a no-op and safe for old code, which matches by ID.
  • Mutation probes / fail-before on 13f0025, each killed by at least one test:
    • Unique-name entries matching again: killed by TestHandleRequest_AccountScope_NameReusedAfterDelete, TestGetAccount_NameEntry_Returns404, TestAccountScope_NameEntryNeverMatches and others.
    • No grant-time binding: killed by TestCreateGroup_BindsAccountNamesToIDs, TestGroupGrantByName_SurvivesRenameAndNameReuse and others.
    • Binding ignoring actor scope: killed by TestCreateGroup_RefusesUnbindableAccountEntries.
    • Dropping the created_at guard: killed by the migration test's reused-name case.
    • Leaving the updated_at trigger enabled: killed by the migration test's updated_at assertion.
  • Ran: go build ./...; go vet ./...; go vet -tags integration ./...; e2e vet; go test -race -short -count=1 on internal/api, internal/auth, internal/database/postgres/migrations, internal/mocks, internal/server (pass); go test -race -tags integration -count=1 on internal/api, internal/auth, internal/database/postgres/migrations (all pass).

Body-stale:

  • "Anything else is a 400 with one message for unknown, ambiguous and out-of-scope names, so the response does not reveal which accounts exist": this holds for names only. Registered IDs and external IDs outside the actor's scope get a 403 from the ceiling instead (see the first non-blocking item).
  • The Verification section describes the merged tree 3a6f92d; the head is now 13f0025.

Reviewer: rE9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 13f0025ee42adb898abc3e12a7610fc9824a1b4b merged onto main (0242dbd0), together with #1116, #1486 in that order (combined tree cf76f566, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 15:59
@IAmJSD
IAmJSD requested a review from cristim as a code owner October 26, 2026 15:59
@IAmJSD
IAmJSD merged commit d39a375 into LeanerCloud:main Oct 26, 2026
41 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on 13f0025e (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly 13f0025ee42adb898abc3e12a7610fc9824a1b4b.

cristim added a commit that referenced this pull request Oct 26, 2026
Group scope entries match account ids only since #1116, so the scoped
case of the uppercase account_ids test now lists ids, not display names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim added a commit that referenced this pull request Oct 26, 2026
…ns (#1497)

* fix(api): canonicalise account_ids and share the account-filter merge

parseAccountIDs now trims and lowercases each id, so history and analytics
callers compare canonical UUIDs: an uppercase id no longer yields an empty
admin result or a refused scoped user. mergeAccountFilter reuses
appendMissing and addExternalIDForProvider instead of its own copy, and the
stale single-account comment in the analytics handler is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(frontend): forward the full account selection to analytics routes

The savings history chart and the trends view sent account_ids only when
exactly one account was selected, so a multi-selection widened to all
accounts for admins and returned 400 for scoped users. Both now forward
every selected id as account_ids; getAnalyticsTrends takes accountIDs
instead of the singular accountID.

Closes #1452

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(api): scope the canonical-id test by account id

Group scope entries match account ids only since #1116, so the scoped
case of the uppercase account_ids test now lists ids, not display names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): name-based account scope entries match a later account that reuses a deleted or renamed name

2 participants