Repository navigation
fix(history): push filters and account scope into the actionable executions read - #1464
Conversation
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>
|
Pushed 070a6af (head 070a6af). Two commits: |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES 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. |
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>
|
Pushed a30bf15 (head a30bf15), answering rE3's review of 070a6af: Blocking: conflict with #1445's plan_id filter
Non-blocking
Checks on a30bf15 (merged with 990f493. Main has since moved to 08105c8, which still merges cleanly.)
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>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: 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. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
…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>
* 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>
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
getHistoryreads the actionable executions (pending, notified, scheduled, approved, running, paused) withGetExecutionsByStatuses(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.truncatedwas set, but the row could not be reached at all, because the actionable class is not paged.Fix
624df0bf):GetExecutionsByStatusesFiltered(ctx, ExecutionsByStatusesQuery)applies the filters and the scope in the WHERE clause, before the LIMIT.GetExecutionsByStatusesis 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.scheduled_datebetween Start and End, inclusive. This is the same as the Go check.recommendationsJSONB; a non-array value reads as empty).cloud_account_idis in the set, or, whencloud_account_idis NULL, some recommendation'scloud_account_idis.plan_idequals the plan-history view'splan_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 matchListHistoryExecutionsPageuses.cloud_account_id, an execution created by the caller. An empty scope keeps only the caller's own unattributed rows, so it fails closed.070a6af0, plan ina30bf154):fetchExecutionsAsHistorybuilds the query from the parsed filters,plan_idincluded, and the request'shistoryScope. Main's fix(history): apply the plan_id filter the plan-history view sends #1445 had routed a plan-filtered actionable read throughListHistoryExecutionsPage; that read is now this one query withPlanIDset, 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.historyScope.allowscan match a row by the name of the account that either identifier resolves to, so both are needed.isNarrowedcountsPlanIDtoo), the stale rows come from a separate unfilteredpending/notifiedread past the 7-day window. That read is capped atDefaultListLimit(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.MockConfigStore.GetExecutionsByStatusesFilteredanswers from aGetExecutionsByStatusesexpectation 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_ScopedPendingNotCrowdedOutByOtherAccountsdrivesHandleRequestas 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: withhandler_history.goatmain(the store and mock changes kept), the response is empty. With the fix it holds exactly the user's row, withtruncated=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_PlanIDpins the predicate. Fail-before: withPlanIDleft out ofactionableExecutionsQueryandisNarrowed, all three fail (empty response withtruncated=true; no sweep query).TestIntegration_GetExecutionsByStatusesFiltered_PlanID(testcontainers Postgres): the plan's row is the only one returned, also withLimit: 1while 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 theplan_idpredicate it fails, and so does main'sTestIntegration_HistoryPlanFilter_ActionableOverCap(fix(history): apply the plan_id filter the plan-history view sends #1445), which now runs through this query.TestHandleRequest_History_ActionableQueryCarriesFilterschecks that provider, start and end reach the query, the limit is 1000, and an unrestricted caller sends no scope.TestHandleRequest_History_StaleSweepIgnoresFiltersAndScopechecks both cases: a narrowed load issues the unfiltered stale read (no scope or filters, End = now - 7d), and an unnarrowed load does not.TestHistoryScope_ExecutionScopeandTestActionableExecutionsQuery_AccountValuescover how the scope and the account filter are translated.TestIntegration_GetExecutionsByStatusesFilteredruns the real SQL on testcontainers Postgres over every account shape:cloud_account_idset, accounts only on recs (single, mixed, external number), no recs,nullrecs, 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 existingGetExecutionsByStatusesintegration and pgxmock tests pass unchanged.git merge origin/main):go build ./...,go vet ./...,go vet -tags integration ./...and the e2e vet pass.--build-tags integration.gocyclo -over 10is clean.go test -race -shortpasses on api, config, mocks and server.go test -race -tags integrationpasses on api, config, mocks and server (testcontainers Postgres).scripts/test-git-secrets-allowlist.shpasses.Not in this PR
Refs #632). The terminal-execution and purchase_history stream (ListHistoryExecutionsPage/ListPurchaseHistoryPage) still applies provider, account and scope in Go (its plan and date bounds are already in SQL). Since feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277 it walks keyset batches past hidden rows, so no row is lost there. The only cost is extra batches for a scoped user whose rows are sparse. The 2026-10-08 scope note 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 put this pushdown in PR2 ("completed-history side too"), so 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 stays open for it. It can reuse this predicate builder in a follow-up, or the owner can drop it 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.🤖 Generated with Claude Code