Skip to content

fix(history): push filters and account scope into the actionable executions read - #1464

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

IAmJSD merged 5 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-632

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #632

This is the actionable-class part of the "PR2" half of #632's scope decision. #632 stays open after this PR: the 2026-10-08 scope note also put the account-scope pushdown on the completed-history side into PR2, and this PR leaves that side filtering in Go (see "Not in this PR"). PR1 was #684. The other asks are already merged: paging (#674 via #1277), scope-aware SQL totals (#675 via #1200), the frontend Load more (#676), and ramp-step ordering (#680 via #1038).

Root cause

getHistory reads the actionable executions (pending, notified, scheduled, approved, running, paused) with GetExecutionsByStatuses(statuses, MaxListLimit). Only after that does it apply the provider, account and date filters (matchesExecution) and the caller's account scope (historyScope.filter) in Go. Every tenant's actionable rows counted against the 1000-row cap. Once enough newer rows of other accounts, or rows outside the request filters, existed, a pending approval the caller could act on was never read, so it was missing from History. truncated was set, but the row could not be reached at all, because the actionable class is not paged.

Fix

  • Store (624df0bf): GetExecutionsByStatusesFiltered(ctx, ExecutionsByStatusesQuery) applies the filters and the scope in the WHERE clause, before the LIMIT. GetExecutionsByStatuses is now the unfiltered case of the same query, so its order (future pending ramp steps last, fix(history): future-dated ramp steps can still crowd a due pending approval out of the actionable fetch #680) and its clean-completed exclusion are shared.
    • Date: scheduled_date between Start and End, inclusive. This is the same as the Go check.
    • Provider: some recommendation has this provider (recommendations JSONB; a non-array value reads as empty).
    • Account: cloud_account_id is in the set, or, when cloud_account_id is NULL, some recommendation's cloud_account_id is.
    • Plan: plan_id equals the plan-history view's plan_id (fix(history): apply the plan_id filter the plan-history view sends #1445 / fix(history): apply the plan_id filter the plan-history view sends #1404), the same exact match ListHistoryExecutionsPage uses.
    • Scope: the same account test against every value the scope can allow, or, for a NULL cloud_account_id, an execution created by the caller. An empty scope keeps only the caller's own unattributed rows, so it fails closed.
    • Each predicate is a superset of the Go check, and the Go checks still run unchanged, so SQL never drops a row Go would keep.
  • Handler (070a6af0, plan in a30bf154): fetchExecutionsAsHistory builds the query from the parsed filters, plan_id included, and the request's historyScope. Main's fix(history): apply the plan_id filter the plan-history view sends #1445 had routed a plan-filtered actionable read through ListHistoryExecutionsPage; that read is now this one query with PlanID set, so a plan view keeps its SQL plan filter and the scope pushdown together. matchesExecution's plan check from fix(history): apply the plan_id filter the plan-history view sends #1445 still runs on the result.
    • The scope is sent as its literal entries plus the UUID and external number of every registered account it allows. historyScope.allows can match a row by the name of the account that either identifier resolves to, so both are needed.
    • Unrestricted callers send no scope.
    • The account filter sends the UUIDs and every resolved external number.
  • Stale-approval sweep is not narrowed with it. The History GET is the only place stale pending/notified approvals get expired (and their suppressions released, feat(plans): plan steps resolve real recommendations and show real counts and prices (tier B of #609) #631). Scoping the actionable read would otherwise stop one user's load from expiring another account's stale rows. So when the read is filtered, plan-filtered or scoped (isNarrowed counts PlanID too), the stale rows come from a separate unfiltered pending/notified read past the 7-day window. That read is capped at DefaultListLimit (100) stale rows, newest first, where an unfiltered load used to see every stale row among the 1000-row class read. A larger backlog drains over successive loads. An unfiltered, unrestricted load reuses the class it already read, as before.
  • Mock: MockConfigStore.GetExecutionsByStatusesFiltered answers from a GetExecutionsByStatuses expectation when it has none of its own. That is a valid superset, because the handler filters again, so the existing History tests are unchanged.

Verification

  • TestHandleRequest_History_ScopedPendingNotCrowdedOutByOtherAccounts drives HandleRequest as a user scoped to one account. The unfiltered read returns 1000 newer pending rows of another account, and the scoped read returns the user's own row. Fail-before: with handler_history.go at main (the store and mock changes kept), the response is empty. With the fix it holds exactly the user's row, with truncated=false.
  • TestHandleRequest_History_PlanPendingNotCrowdedOutByOtherPlans: an unrestricted caller loads a plan view. The plan-less read returns 1000 newer pending rows of another plan, and the read carrying the plan returns the plan's own row. TestHandleRequest_History_PlanViewStillSweepsAllStaleApprovals: a plan view still issues the unfiltered stale sweep (no plan, scope or filters). TestIsNarrowed_PlanID pins the predicate. Fail-before: with PlanID left out of actionableExecutionsQuery and isNarrowed, all three fail (empty response with truncated=true; no sweep query).
  • TestIntegration_GetExecutionsByStatusesFiltered_PlanID (testcontainers Postgres): the plan's row is the only one returned, also with Limit: 1 while newer rows of another plan and of no plan exist; the plan is ANDed with the scope; an unknown plan returns nothing. Fail-before: without the plan_id predicate it fails, and so does main's TestIntegration_HistoryPlanFilter_ActionableOverCap (fix(history): apply the plan_id filter the plan-history view sends #1445), which now runs through this query.
  • TestHandleRequest_History_ActionableQueryCarriesFilters checks that provider, start and end reach the query, the limit is 1000, and an unrestricted caller sends no scope.
  • TestHandleRequest_History_StaleSweepIgnoresFiltersAndScope checks both cases: a narrowed load issues the unfiltered stale read (no scope or filters, End = now - 7d), and an unnarrowed load does not.
  • TestHistoryScope_ExecutionScope and TestActionableExecutionsQuery_AccountValues cover how the scope and the account filter are translated.
  • TestIntegration_GetExecutionsByStatusesFiltered runs the real SQL on testcontainers Postgres over every account shape: cloud_account_id set, accounts only on recs (single, mixed, external number), no recs, null recs, and a non-requested status. It checks provider, account, external number, inclusive date bounds, scope (by account, own unattributed rows, NULL creator), filters combined with scope, and that the LIMIT counts only rows in scope. The existing GetExecutionsByStatuses integration and pgxmock tests pass unchanged.
  • Local checks (on the merged head, after git merge origin/main):
    • go build ./..., go vet ./..., go vet -tags integration ./... and the e2e vet pass.
    • golangci-lint v2.10.1 reports 0 issues on api, config, mocks and server, with and without --build-tags integration.
    • gocyclo -over 10 is clean.
    • go test -race -short passes on api, config, mocks and server.
    • go test -race -tags integration passes on api, config, mocks and server (testcontainers Postgres).
    • The secrets scan is clean, and scripts/test-git-secrets-allowlist.sh passes.

Not in this PR

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 26, 2026 09:53
The History view reads its actionable executions (pending approvals and
in-flight purchases) with GetExecutionsByStatuses, capped at
MaxListLimit, and only then applies the provider, account, date and
account-scope filters in Go. Rows of other tenants or outside the
filters therefore count against the cap, so once enough of them exist a
pending approval the caller could act on is never read.

GetExecutionsByStatusesFiltered takes those filters and the scope as an
ExecutionsByStatusesQuery and applies them in the WHERE clause, before
the LIMIT. Each predicate is a superset of the handler's Go check, which
still runs, so SQL never drops a row Go would keep. The account and
scope predicates read cloud_account_id, falling back to the
recommendations' cloud_account_id when it is NULL; the scope also keeps
an unattributed row created by the caller. GetExecutionsByStatuses is
now the unfiltered case of the same query.

The mock answers from a GetExecutionsByStatuses expectation when the new
method has none, which is a valid superset because the handler filters
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getHistory now passes the request's provider, account and date filters
and the caller's account scope to GetExecutionsByStatusesFiltered, so
the MaxListLimit cap on the actionable class counts only executions the
caller could be shown. Before, a scoped user's pending approval could be
crowded out by newer actionable rows of other accounts and silently not
listed. The Go filters and scope check still run on the result.

The scope is sent as the list of every account value it can allow (its
literal entries plus the UUID and external number of each registered
account it allows) and the caller's user ID; an unrestricted caller
sends none.

Expiring stale approvals is not narrowed with it. The History load is
the only thing that expires them and releases their suppressions, so
when the actionable read is filtered or scoped, the stale rows come from
a separate unfiltered pending/notified read past the expiry window.

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

coderabbitai Bot commented Oct 26, 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.

@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm effort/l Weeks urgency/this-quarter Within the quarter impact/many Affects most users type/bug Defect triaged Item has been triaged labels Oct 26, 2026
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 070a6af (head 070a6af). Two commits: 624df0bf adds the store query GetExecutionsByStatusesFiltered, its mock and the Postgres integration test; 070a6af0 wires the History handler to it and keeps the stale-approval sweep unfiltered, with the handler tests. Local build, vet, lint, gocyclo, race unit tests, integration tests (api, config, mocks, server) 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 070a6af099af5d3d43cb11c44c9d8e6944474868

Blocking:

Reviewer: rE3. 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 2 commits October 26, 2026 12:25
Main's LeanerCloud#1445 changed fetchExecutionsAsHistory and fetchExecutionsByClass
to route a plan_id-filtered actionable read through
ListHistoryExecutionsPage. This branch replaces that read with
GetExecutionsByStatusesFiltered, so the resolution keeps the filtered
query and the next commit carries plan_id into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LeanerCloud#1445 pushed the plan-history view's plan_id into SQL for the
actionable class so other plans' pending rows cannot crowd a plan's own
row out of the MaxListLimit cap. With the LeanerCloud#632 pushdown that read is
GetExecutionsByStatusesFiltered, so plan_id has to be one of its
predicates, or the plan view regresses to the LeanerCloud#1445 bug.

ExecutionsByStatusesQuery gains PlanID (exact plan_id match, as
HistoryExecutionsPageQuery has), the handler sets it from the parsed
filters, and isNarrowed counts it, so a plan view still runs the
separate unfiltered stale-approval sweep instead of expiring only the
viewed plan's stale rows. The Go matchesExecution plan check from LeanerCloud#1445
still runs on the result.

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

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a30bf15 (head a30bf15), answering rE3's review of 070a6af:

Blocking: conflict with #1445's plan_id filter

  • 03f0e18c merges origin/main (990f493). In fetchExecutionsAsHistory / fetchExecutionsByClass the resolution keeps the GetExecutionsByStatusesFiltered read, so the ListHistoryExecutionsPage branch from fix(history): apply the plan_id filter the plan-history view sends #1445 is gone from the actionable class. The terminal stream still uses it with PlanID.
  • a30bf154 puts the plan into that query. ExecutionsByStatusesQuery gains PlanID, and executionsByStatusesWhere adds plan_id = $N, the same exact match ListHistoryExecutionsPage uses. actionableExecutionsQuery sets it from the parsed filters. The Go post-filter (matchesExecution's plan check from fix(history): apply the plan_id filter the plan-history view sends #1445) is unchanged and still runs on the result.
  • isNarrowed now counts PlanID, so a plan view still runs the separate unfiltered stale sweep. Its comment says every filter field must be listed there.
  • Tests, each failing before and passing after:
    • TestIntegration_GetExecutionsByStatusesFiltered_PlanID (real Postgres). It is a separate function, so the existing table keeps its seed. It checks that only the plan's row comes back, also with Limit: 1 while newer rows of another plan and of no plan exist, that the plan is ANDed with the scope, and that an unknown plan returns nothing. Without the predicate it fails. Main's TestIntegration_HistoryPlanFilter_ActionableOverCap (fix(history): apply the plan_id filter the plan-history view sends #1445) now goes through this query, and it fails without the predicate too.
    • TestHandleRequest_History_PlanPendingNotCrowdedOutByOtherPlans, TestHandleRequest_History_PlanViewStillSweepsAllStaleApprovals and TestIsNarrowed_PlanID. With PlanID taken out of actionableExecutionsQuery and isNarrowed, all three fail (empty response with truncated=true, no sweep query).

Non-blocking

Checks on a30bf15 (merged with 990f493. Main has since moved to 08105c8, which still merges cleanly.)

  • go build ./..., go vet ./..., go vet -tags integration ./... and the e2e vet pass.
  • golangci-lint v2.10.1 found 0 issues on api, config, mocks and server, with and without --build-tags integration. gocyclo -over 10 is clean.
  • go test -race -short -count=1 and go test -race -tags integration -count=1 pass on api, config, mocks and server.
  • The secrets scan is clean and the planted key was flagged. test-git-secrets-allowlist.sh gives 183 PASS, 0 FAIL.

The resolved head needs a fresh review.

LeanerCloud#1460 replaced the hand-written mockConfigStoreForHealth with the shared
mocks.MockConfigStore, which already has GetExecutionsByStatusesFiltered,
so take main's test_helpers_test.go and drop this branch's stub method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 9b426881442e9214af41ff2ed00ed6e204db9582

Blocking:

  • none

Non-blocking:

  • internal/api/handler_history_actionable.go:81 (staleExecutionsToSweep): every narrowed first-page load (any filter, a plan view, or any scoped user) now issues a second store read just for the sweep. It's cheap (pending/notified, scheduled_date <= now-7d, LIMIT 100), and the effective cap is unchanged because expireStaleExecutions already truncated to DefaultListLimit. It's still a per-load query that only exists to drive a write side effect from a GET, so it's worth a line on fix(history): pending approvals can fall off the first page because filters are applied after the executions cap; add paging and push provider/account/date filters into the executions SQL #632 if the sweep moves to the scheduler later.
  • internal/api/handler_history_actionable.go:60 (executionScope): a registered account with an empty ExternalID puts "" into the bound array. That's harmless, because SQL may keep a rec with cloud_account_id: "" and Go then drops it on the creator-only path. Skipping empty values would keep the array clean.
  • fix(auth): bind group account scope entries to account IDs #1116 (bind group scope entries to IDs) rewrites AccountScope and touches the history scope tests. Whichever of the two merges second needs a re-run of TestHistoryScope_ExecutionScope and the scoped History tests on the merged tree. The superset argument below holds for ID-only scopes too.

Attacked:

  • Superset proof, SQL vs Go, so the pushdown can never hide a row Go would show:
    • Scope: historyScope.allows returns true either by a literal entry, which is bound as-is, or by the name of a registered account. That name requires Allows(a.ID, a.Name), and that account's ID and ExternalID are bound. The row's AccountID is exec.cloud_account_id, or else the collapsed rec account, so it's always in the bound set. The unattributed case maps to COALESCE(created_by_user_id::text,'') = user, the same as p.CreatedByUserID == s.userID, including an empty user.
    • Account filter, provider (json:"provider") and inclusive date bounds: each is checked against matchesExecution and accountMatchesFilters, and SQL keeps a superset in every case.
  • Stale sweep: isStaleExecution keys on ScheduledDate, the same column as the End bound, so the unfiltered sweep read can't miss a stale row the old class read would have found.
  • Ran: go build ./...; go vet (with and without -tags integration) on api, config, mocks; go test -race -short -count=1 on api, config, mocks (pass); go test -race -tags integration -run 'GetExecutionsByStatuses|History' on config and api against testcontainers Postgres (pass).
  • Fail-before: with internal/api/handler_history.go checked out from origin/main, TestHandleRequest_History_ScopedPendingNotCrowdedOutByOtherAccounts and TestHandleRequest_History_StaleSweepIgnoresFiltersAndScope fail. Restored afterwards.
  • Refs #632 (not Closes) is correct: the completed-history pushdown from the 2026-10-08 scope note is still open.

Body-stale:

  • none

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

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 9b426881442e9214af41ff2ed00ed6e204db9582 merged onto main (43182c00), together with #1464 in that order (combined tree 0c19ef85, 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 1 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 14:02
@IAmJSD
IAmJSD requested a review from cristim as a code owner October 26, 2026 14:02
@IAmJSD
IAmJSD merged commit df0de7e 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: 1 approving independent review(s) on 9b426881, green CI, and the combined-tree verification above. Squash-merged at exactly 9b426881442e9214af41ff2ed00ed6e204db9582.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
…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>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
* fix(auth): bind group account scope entries to account IDs

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. #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 #1034

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

* test(api): scope the history-summary fixture by account ID

The history-summary integration tests from #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 (#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>

* chore(db): renumber the group scope binding migration to 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. 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>

* chore(db): renumber bind_group_account_names_to_ids migration 000149 -> 000145

golang-migrate only applies versions above the database's current one,
so PRs must merge in migration-number order. 000140-000144 had been
taken by PRs that are now held on owner decisions (#1239, #1241,
#1253). The ready PRs move to 000140-000145 so they can merge ahead of
them, and the held ones will take the next free numbers when decided.
Rename only: the SQL statements are unchanged, and the test version
constants and the comments naming the migration follow the new number.

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

* test(api): register the wire-format group account for grant-time binding

TestUserGroupWrites_WireFormat came in from main (#1261) and builds a
Handler with no config store. Since #1034, group writes bind every
allowed_accounts entry to a registered account and refuse the write when
the account list cannot be loaded, so POST /api/groups answered 500.
The test now registers its account ID in a store mock, as the binding
tests do; the wire format it pins is unchanged.

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

* test(api): scope the History paging user by account ID, not name

TestIntegration_HistoryPaging_ScopedUserNeverPagesIntoAnotherTenant came
in from main (#1277) and grants the scoped user tenant A by its display
name. Since #1034 an allowed_accounts entry matches by cloud account ID
only, so that user saw none of tenant A's rows and the walk returned
only its own unscoped execution. The grant now names tenant A's ID, as
the other scope tests on this branch do; what the test asserts is
unchanged.

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

* test(analytics): scope account_ids fixtures by account ID

The account_ids analytics tests that arrived with the main merge (#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 (#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>

* test(analytics): scope the account_ids DB test by account ID

TestAnalyticsAccountIDs_SumsSelectedAccountsOnly_DB (from #1448) grants
the scoped user ["a", "b"], which are account names. Scope entries are
account IDs on this branch (#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>

* test(history): scope the plan_id filter DB test by account ID

TestIntegration_HistoryPlanFilter_CrossTenant (from #1445) grants the
scoped user tenant A by account name. Scope entries are account IDs on
this branch (#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>

* test(api): scope #1464's actionable History tests by account ID

#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 (#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>

* fix(auth,api): bind account names to the identifier in scope checks

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>

---------

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/l Weeks impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant