Repository navigation
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>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
|
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 |
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 000135 (
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 and match nothing. 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: main ends at 000134 (#1110, merged), so this PR takes 000135. It must merge before #1140 (000136); golang-migrate skips a lower version once a higher one is applied. 000134 touches only
purchase_suppressionsand 000135 onlygroups, so the two do not interact.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.resolveAccountNamesByIDkeys names by internal ID and external ID in one map. An account whose external ID equals another account's internal UUID could shadow that account's name in the lookup. This is contrived, but worth a separate look.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