Repository navigation
fix(auth): bind group account scope entries to account IDs - #1116
Conversation
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>
|
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:
Verified locally, with no cloud credentials and testcontainers postgres:16-alpine, under the heavy lock:
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>
|
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:
[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. Note, unchanged: for a raw external ID, main's Verification on d980764, local, with
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>
|
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 (
Not a textual conflict, also fixed. #1169's 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. Verification on 3a6f92d, run locally on Linux with
The PR stays a draft. 🤖 Generated with Claude Code |
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>
|
Merged 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, |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES Blocking:
Non-blocking:
Attacked:
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. |
…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>
|
Reply to rE7's review of Blocking: the name-to-account bypass on the purchase gate. Fixed at two layers.
Regression tests:
Fail-before: I copied the new tests onto 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.
Non-blocking points:
Body-stale (not edited here): the "contrived / pre-existing" paragraph about Checks on
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
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. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
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. |
|
Local verification at |
|
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 |
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>
…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>
Root cause
A group's
allowed_accountscan hold an account's display name (the group duplicate modal writesacct.name). The stored name is not bound to any account.AccountScope.Allowsmatched 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.
Matching (
internal/auth/account_scope.go): scope entries are compared with the account identifier the caller passes, never with a display name.WithRegisteredNames(names)becomesWithRegisteredAccounts([]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.Grant-time binding (
internal/api/handler_groups.go,bindGroupAccounts): onPOST /api/groupsandPUT /api/groups/{id}, eachallowed_accountsentry must be one of:"*";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 beforeCreateGroupAPI/UpdateGroupAPI, so the account ceiling compares IDs.Migration 000145 (
bind_group_account_names_to_ids) rewrites a stored name entry to an account ID only when all of these hold:*and not empty;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_atis disabled for the statement (same pattern as 000127), so the rewrite does not moveupdated_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.Name-only helpers removed:
auth.MatchesAccountandAuthContext.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)
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/groupsshows account IDs where names used to be.""entry no longer matches a caller that passes""as the account ID.ListCloudAccountsper group write that carries non-*entries, plus the existing scope load for restricted actors.Concurrency
createAccountsetsuuid.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.UpdateGroupAPIreads 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 integrationsuites 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 throughHandleRequestand 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/...: passgo 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': passgo test -tags integration ./internal/api/ -run 'TestMarketplacePermissionConstraintsHTTP|TestMarketplaceListCancelFailure|TestAdminUpdateUserHTTPKeepsConcurrentMFA'and./internal/server/ -run TestConfigPermissionConstraintsHTTP: pass--allow-parallel-runnerson./internal/auth/... ./internal/api/..., and with--build-tags integrationon the migrations package: 0 issuesgocyclo -over 10on the changed non-test files: clean.scripts/gofmt-hook.shandscripts/gosec-hook.shon the changed packages: clean.git initrepo 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.goandhandler_groups.gotoorigin/mainand 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 ofTestHandler_getRIExchangeHistory_AccountScopeandTestAuthorizeSessionRevoke_RevokeAny_AccountScope, andTestHandleRequest_AccountScope_SharedDisplayName/a_unique_name_entry_grants_nothing_either.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*, andOmittedAccountsStayNil.Mutation probes, each killed by at least one test:
c >= 1);getAccountScopenever registers accounts.created_atguard;*exclusion (an account named*would narrow wildcard groups);updated_attrigger on;count >= 1;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. TheTestNoUnfailableMockAssertionsguard (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
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 touchfrontend/.created_atguard 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.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