Skip to content

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

Draft
IAmJSD wants to merge 8 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1034
Draft

IAmJSD wants to merge 8 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 000135 (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 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.

  4. Name-only helpers removed: auth.MatchesAccount and AuthContext.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_suppressions and 000135 only groups, so the two do not interact.

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.
  • Pre-existing and unchanged: resolveAccountNamesByID keys 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

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

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • 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

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold), re-review: APPROVE 3a6f92d

Both blockers from my REQUEST_CHANGES at 5d34f77 are fixed. The code and migration are unchanged from the version I passed then.

Not a rebase: the branch was updated with two merges of main (d980764, 3a6f92d) on top of the unchanged 5d34f77, which is why the range-diff shows 5d34f77 = 5d34f77. I reviewed what the merges changed with git show --remerge-diff.

Migration order: this is still 000135, and origin/main (f5ad8c6) now ends at 000134 because #1110 merged. The merged tree holds exactly 000134 and 000135. The rest of the chain must still merge strictly in order:

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

Blockers:

  • git merge-tree --write-tree origin/main 3a6f92d is clean. The merged tree's diff against origin/main is identical to the PR's diff against its merge-base: 29 files, +976/-361.
  • The two fix(api): scope a scoped user's raw analytics account id to its provider #1161 tests now pass. Only their scope fixtures changed, and every assertion is kept:
    • RawExternalIDProviderScopedOnEveryEndpoint: "data" became "u-gcp".
    • ProviderlessAccountFailsClosed: "x" became "u-x".
    • UniqueRawExternalID: "in" became "u-in".

What the merges changed beyond a plain merge:

  • Production code: only loadRIExchangeHistory (internal/api/handler_ri_exchange.go). The merge keeps main's fix(api): RI exchange history caps at 500 rows before the account filter runs #308 refactor, which pushes the scope into the store query, and only its comment changes ("UUID or name" to "UUID").
    • It uses exchangeAccountIDsInScope and exchangeRecordInScope, both of which go through Allows.
    • A literal name entry goes to SQL as a literal ID and matches no row. That is what exchangeRecordInScope already accepted, so no new path opens.
    • Main added no new scope or name-matching production code in this range (grepped the non-test diff).
  • Tests: these follow the intended fix(auth): name-based account scope entries match a later account that reuses a deleted or renamed name #1034 semantics, and none weakens a test.
    • router_handlers_test.go: "scoped by account name" became "a name entry matches nothing", and the "different account by name" case expects ["dev"].
    • handler_ri_exchange_history_scope_integration_test.go: the "by name" case was removed, and a subtest now checks that a name entry for B matches nothing.
    • handler_analytics_scope_provider_integration_test.go: the fixtures use db.awsUUID instead of the account name.

Mutation probes on a scratch copy of the merged tree, each restored and confirmed identical with cmp:

  • P1: Allows compares the scope with the display name again.
    • RawExternalIDProviderScopedOnEveryEndpoint FAILS on all 3 endpoints, and UniqueRawExternalID FAILS.
    • So the switched fixtures really depend on ID matching.
    • The PR's own TestAccountScope_NameEntryNeverMatches, TestGrantScoped_NameEntryDoesNotMatch and TestGetAccount_NameEntry_Returns404 also FAIL.
  • P2: a raw external ID returns the {"": {id}} bucket instead of its account's provider. RawExternalIDProviderScopedOnEveryEndpoint (3 endpoints) and UniqueRawExternalID FAIL.
  • P3: match.Provider == "" || dropped. ProviderlessAccountFailsClosed FAILS.
    • With the old "x" fixture, the 404 came from the entry being out of scope.
    • With "u-x", the account is in scope, so the 404 now proves the fail-closed check itself. The fixture switch made this test stronger.

Findings (none blocking):

Verified locally on the merged tree, with GOTOOLCHAIN=go1.26.10, no cloud credentials, under the heavy lock, and testcontainers only:

  • Static checks and unit tests:
    • gofmt: clean.
    • go build ./...: pass.
    • go vet ./internal/api/ ./internal/auth/, plus -tags integration on migrations and api: pass.
    • go test -count=1 ./internal/api/ ./internal/auth/ ./internal/mocks/ ./internal/server/: ok.
  • Migration integration tests: TestMigration_BindGroupAccountNamesToIDs, TestMigrations_DownAllRoundTrip, TestMigrations_FullStackIdempotent and TestMigration_PurchaseSuppressionsVariant (134) all PASS, with 0 skips.
  • CI-selected integration sets: the set from scripts/integration-test-selection.sh (internal/api, 97 tests; internal/auth, 23): ok.
  • A scratch 134 -> 135 up/down/up cycle: PASS.
    • The version and the dirty flag were checked at each step.
    • A unique name is bound to its ID.
    • An unknown name, an external ID, a shared name, an account created after the grant, and * are all kept.
    • The bindings are identical after down and after re-up.
    • The update_groups_updated_at trigger fires again.

Same reviewer as the earlier review, re-reviewing the new head. Review only: nothing was pushed, and the GitHub review state is unchanged.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 3a6f92d

Re-review of #1116 (closes #1034) at 3a6f92d, previously REQUEST_CHANGES on 5d34f77. Reviewed git diff origin/main...3a6f92df with origin/main = f5ad8c6 (merge base a14dc8f, 91 commits behind). git merge-tree --write-tree origin/main 3a6f92df is clean (tree ff5d9a0c); every run below used that merged tree.

The earlier blockers are resolved:

  • The fix(api): scope a scoped user's raw analytics account id to its provider #1161 conflicts in the api test files are gone. The merge commit's combined diff only re-words the handler_ri_exchange.go:2274 comment and brings in main's ri-exchange history files.
  • The display-name-scoped analytics, history and inventory tests (unit and integration) now use account-ID fixtures and keep their assertions.
  • None of the 91 main commits since a14dc8f adds or changes an Allows(, getAccountScope or allowed_accounts caller, so no new name-dependent reader slips past.
  • Migration 000135 is still free on main, which ends at 000134, and it touches only groups.

One finding was reported privately to the maintainers. None of the findings below blocks.

Findings:

  • nit internal/database/postgres/migrations/000135_bind_group_account_names_to_ids_test.go:55-57 - uses a per-test RequirePostgresContainer + Cleanup, and no t.Parallel(). Main (test: share one Postgres server per package across integration tests #1400/test(migrations): run integration tests in parallel; harden shared test server #1401) moved every other migration test to the shared server with testhelpers.RequireEmptyDatabase + t.Parallel() (see 000134_..._test.go:48-50). It is now the only migration test that starts its own container. That works, but it is slower and runs serially in the sharded integration job. Suggest matching the 000134 pattern.
  • low (documented in the PR body) 000135_...up.sql:57-58 - the created_at <= groups.updated_at guard reads the group's last write, not its grant time. Failure scenario: an account is deleted, a newcomer takes its name, then any unrelated edit bumps the group's updated_at. The migration then binds the name to the newcomer for good. The newcomer already had that access before this PR, so nothing widens, but the grant becomes permanent. Acceptable as a follow-up.
  • CI on 3a6f92d is red (run 39901582612), but every failure is from the old base: govulncheck x509 (credentials/cipher.go:169), image advisories, and internal/config TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which the PR does not touch. The PR needs a fresh run on a head merged with current main.

Attacked:

  • Matching: account_scope.go:163-187 compares only IDs. A name resolves to an ID only when exactly one account carries it, and that ID must be in scope. An empty ID never matches. Per-permission Constraints.AccountIDs (service_group.go:411) was already ID-only.
  • Binding: handler_groups.go:182-263 refuses on a nil config store or a list/scope load failure. It never drops an entry, and nil, [] and ["*"] pass through.
  • Migration: read the SQL. It skips "*" and "", keeps IDs and external IDs, needs count = 1, keeps order, and disables the trigger only for the UPDATE.
  • Mutation 1: name entries match again in Allows. 5+ api tests fail (killed).
  • Mutation 2: bind ignores the actor's scope. TestCreateGroup_RefusesUnbindableAccountEntries fails (killed).
  • Mutation 3: a list failure passes entries through. ..._AccountListFailureRefusesWrite fails (killed).
  • Mutation 4: drop the migration's created_at guard. TestMigration_BindGroupAccountNamesToIDs fails on reused-name and created-later (killed).
  • Merged-tree runs:
    • go build ./... and go vet pass.
    • go test -count=1 of auth, api, server, mocks and migrations: all ok.
    • go test -tags integration ./internal/database/... ./internal/auth/...: all ok (database, migrations, testhelpers, auth).
    • go test -tags integration ./internal/api/: ok.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

@IAmJSD both reviews approve 3a6f92df (chain link 2, migration 000135, next after #1110's 000134). CI on this head is red only from the old merge base (the govulncheck x509 alert fixed by #1236, plus unrelated failures). Could you merge or rebase onto current main so CI runs on a fresh merge ref? It merges next in the chain once green; #1140, #1170, #1197 and #1201 follow in that order.

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

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

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Migration-ordering note: #1140, #1170, #1197 and #1201 have merged, so main now runs migrations up to 000139, and environments that deploy main will be at 139. This PR still adds 000135_bind_group_account_names_to_ids. golang-migrate only applies versions above the database's current one, so after this merges 000135 would be silently skipped on every already-migrated database. @IAmJSD, when you next refresh this branch, please renumber it to the next free number at that point (000140 today; 000143 if #1239 and #1240 land first). Please also check that it doesn't depend on running before 136-139.

IAmJSD and others added 4 commits October 25, 2026 15:59
The history-summary integration tests from LeanerCloud#1200 scoped their user with
the tenant's display name. This branch binds allowed_accounts entries to
account IDs and makes a bare name entry match nothing (LeanerCloud#1034), so the
scoped user saw no tenant A rows and the totals and listed-rows checks
failed. Grant the scope by tenant A's account ID, the form group writes
now store. The assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now ships migrations up to 000139, so 000135 would sit below
versions a deployed database has already applied and golang-migrate
would never run it. Open PRs hold 000140-000148, so this takes 000149
and must merge after that chain. The SQL is unchanged; only the file
names, header comments, test version and test fixture IDs move.

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

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI fix pushed. New head: 098a0d4e.

1. History-summary integration failures (from #1200 on main)

TestIntegration_HistorySummary_TotalsCoverAllInScopeRows/scoped_user_sees_only_tenant_A_totals and TestIntegration_HistorySummary_MatchesListedRowsUnderTheCap/scoped/* failed because their fixture scoped the user by display name: scopedAuth.grantScoped(env.tenantA.Name). This PR deliberately makes a bare name entry in allowed_accounts match nothing (#1034), because a name is not bound to an account and could later grant whichever account takes that name. Under this branch the scoped user therefore saw no tenant A rows. Before the fix, the totals came back as 1 instead of 130 (only the user's own ambient pending execution), and scoped/account_A and scoped/legacy_account_id returned empty lists.

The binding in this PR is correct and the fixture used the legacy form, so I changed the fixture, not the code. Commit ecb84b29 grants the scope by env.tenantA.ID, which is the form group writes now store. No assertion changed. With this change both tests pass, including the legacy rows that carry only the external account number, so ID binding still resolves those rows to tenant A.

2. Migration renumbered 000135 -> 000149

main now ships migrations up to 000139, so 000135 would sit below versions a deployed database has already applied and golang-migrate would never run it. Commit d05012f2 renames the up, down and test files to 000149_bind_group_account_names_to_ids.*. It also updates the header comments, the test's const version, the m135* fixture names and IDs, the comment in internal/auth/account_scope.go and the CHANGELOG entry. The SQL is unchanged. Open PRs hold 000140-000148 (#1239, #1240, #1241, #1253, #1302, #1268, #1287, #1289), so this PR must merge after that 000140-000148 chain.

Other changes: I merged origin/main twice as plain merges. The only conflict was in CHANGELOG.md, where I kept both sides.

Checks run locally on the new head:

  • go build ./..., go vet ./..., go vet -tags integration ./... and the tests/e2e vet all pass.
  • golangci-lint v2.10.1 reports 0 issues on internal/api, internal/auth and internal/database/postgres/migrations, both with and without --build-tags integration.
  • gocyclo -over 10 on the changed non-test files reports nothing.
  • go test -race -short passes on all three packages.
  • go test -race -tags integration:
    • internal/api passes, including both history-summary tests.
    • internal/database/postgres/migrations passes, including TestMigration_BindGroupAccountNamesToIDs at version 149.
    • internal/auth passes.
    • On this heavily loaded host, some tests first failed only because their Postgres container did not start within its 30s wait. I reran those tests until they passed.
  • The secrets scan of the changed files prints "clean; planted key flagged".
  • bash scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

@IAmJSD

IAmJSD commented Oct 25, 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 098a0d4e9cd2254bee8508ff9c825bc95a3679c0

Blocking:

  • none

Non-blocking:

  • internal/api/handler_groups.go:190 (bindGroupAccounts): names get one uniform 400. Registered IDs and external IDs, however, pass binding without a scope check and are then judged by the ceiling in checkAccountGrant. A restricted actor can therefore still tell "registered but outside my scope" (ceiling refusal) from "not registered" (400). On main, unknown entries were stored, so there was no difference to observe. In practice this oracle reveals whether a given AWS account ID or Azure subscription is registered. It is minor, and you could close it by also requiring actor.Allows for ID and external-ID entries, or by reusing groupAccountEntryMsg for the ceiling refusal.
  • GET /api/groups now returns UUIDs where admins used to see names. The PR says so, but the frontend still renders entries verbatim, so a UI follow-up that maps IDs back to names is worth filing along with the duplicate-modal change the body already defers.
  • The new Allows semantics give ID-scoped users the legacy rows keyed by external ID, through ID → name → unique-account → ID. On main, an ID-only scope did not match those rows. This is intended and correct while names are unique (I traced historyRowAccountName, exchangeRecordInScope and the revoke path, and each derives the name from the same account list). It is still a visibility widening for existing ID-scoped groups, and the CHANGELOG entry could mention it.

Attacked:

  • Matching: checked every Allows caller (scoping.go, history/summary, revoke, RI exchange incl. exchangeAccountIDsInScope, analytics, ladder, dashboard, accounts, marketplace). Each passes either the account's own ID/name pair or a name derived from the row's own account, so the name lookup cannot grant a different account. Removed helpers have no non-test callers. Group writes only go through the two patched handlers. ListCloudAccounts with an empty filter returns all providers, enabled or not, and accounts have no soft delete, so uniqueness is computed over the full set on both the Go and SQL sides.
  • Migration 000149: the number is unique across open PRs (140-148 taken, main at 139). The trigger update_groups_updated_at exists (000001). The file runs in the implicit golang-migrate transaction (no non-transactional statements), and groups is small, so the brief lock from DISABLE TRIGGER is fine. HAVING count(*)=1 AND bool_and(created_at <= updated_at) without GROUP BY gives zero or one row. NULL/empty lists are untouched. The down migration is a no-op, which is safe because main's Allows also matched by ID. Rolling deploy: old instances that write names produce inert entries (fail closed).
  • Fail-before:
    • Mutant that restores name-entry matching in Allows: TestHandleRequest_AccountScope_NameReusedAfterDelete, TestGrantScoped_NameEntryDoesNotMatch and TestGetAccount_NameEntry_Returns404 FAIL.
    • Mutant that makes bindGroupAccounts pass entries through: TestCreateGroup_BindsAccountNamesToIDs FAILs, along with _NoAccountStoreRefusesWrite and TestUpdateGroup_BindsAccountNamesToIDs.
    • Mutant that makes the 000149 up a no-op: TestMigration_BindGroupAccountNamesToIDs FAILs.
    • All pass at the head.
  • Ran:
    • go build ./... and go vet (plain, -tags integration, and e2e): clean.
    • go test -race -short -count=1 on ./internal/auth/ ./internal/api/ ./internal/mocks/ ./internal/database/postgres/migrations/: ok.
    • go test -race -tags integration -count=1 on ./internal/api/ (full, 128s), ./internal/auth/ and ./internal/database/postgres/migrations/ (full): ok.
    • golangci-lint v2.10.1, with and without --build-tags integration: 0 issues.
    • gocyclo -over 10 on the changed files: silent.

Body-stale:

Reviewer: rB8. 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 25, 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 098a0d4e9cd2254bee8508ff9c825bc95a3679c0

Blocking:

  • none

Non-blocking:

Attacked:

  • Matching: checked every AccountScope.Allows caller. Each one derives accountName from the same account it passes as accountID (by ID, by provider+external ID, or by STS ID), so the new name to unique account to ID-in-scope resolution can only re-identify that same account. It cannot be steered to an in-scope account by a mismatched name. A shared name resolves to nothing.
  • Binding: an out-of-scope name, a shared name (3 copies), an unknown name and "" all 400 before CreateGroupAPI/UpdateGroupAPI. A list or scope load failure refuses the write. nil/[]/["*"] pass through to the existing ceiling checks. The only group writers are the two handlers (one Postgres store).
  • Migration: read the SQL for NULL/empty arrays, NULL elements, the * and external-ID exclusions, HAVING count(*)=1 with an empty set, the trigger disable inside the transaction, and down as a no-op that is safe because old code matches by ID. TestMigration_BindGroupAccountNamesToIDs also round-trips down and up and checks updated_at is unmoved.
  • Fail-before (mutation): re-adding entry-equals-unique-name matching in Allows fails TestHandleRequest_AccountScope_NameReusedAfterDelete (the issue's scenario), TestGrantScoped_NameEntryDoesNotMatch and TestGetAccount_NameEntry_Returns404. Making bind store unknown or out-of-scope names verbatim fails the refusal tests. Restored afterwards.
  • Ran: go build ./..., go vet (plain, -tags integration, and e2e), golangci-lint v2.10.1 on auth, api and migrations (both tag sets, 0 issues), gocyclo -over 10 (silent), go test -race -short on auth, api, mocks, server and migrations, and go test -race -tags integration on migrations, auth and api (real Postgres). All green.

Body-stale:

Reviewer: rB12. 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 added a commit that referenced this pull request Oct 25, 2026
…to admins (#1263)

Two of the three low-severity findings grouped in the 2026-09-02 audit
issue.

A02-015: the CSRF half was fixed by #1104. The CORS half was not: the
handler's Access-Control-Allow-Methods and the Lambda Function URL CORS
config (Terraform lambda module and the CloudFormation stack) listed
GET, POST, PUT and DELETE only, while the router serves PATCH
/api/plans/{id} and PATCH /api/ladder/tranches/{id}. All three now list
PATCH. A test reads the router table and requires every routed verb
(HEAD excepted, it is CORS-safelisted) in the preflight's list.

A02-020: config.AccountRegistration.ReferenceToken was tagged
json:"reference_token", so GET /api/registrations and
GET /api/registrations/{id} returned each registrant's private status
token to admins. It is now json:"-"; submitRegistration still returns
it from its own explicit map.

A03-015 (account scope matching display names) is left to the open
group account-scope binding PR #1116, which makes the same change.

Refs #310

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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