Repository navigation
fix(api): scope plans list, inventory filter and RI quote to allowed accounts - #499
Conversation
…accounts GET /api/plans never resolved the caller's account scope, so a scoped user listed every tenant's plans, and account_ids naming another tenant narrowed the list to that tenant. The store query also ORed in every plan with no plan_accounts row regardless of the filter, so unassigned plans leaked to any account-filtered caller. - listPlans intersects account_ids with allowed_accounts through intersectAccountFilterScope and returns an empty list when nothing is left. Unassigned plans stay visible only to unrestricted callers, matching requirePlanAccess. - PurchasePlanFilter.IncludeUnassigned gates the unassigned disjunct in buildListPlansQuery; only unrestricted callers set it. - fetchCommitmentRecords routes an explicit account_id through intersectAccountFilterScope, so an out-of-scope account is no longer read from the store (the callers' post-filter already hid the rows). - getExchangeQuote applies requireReshapeAccountScope, the gate executeExchange uses, so a scoped user cannot quote the deployment account's RIs when it is outside their scope. Closes #29 Closes #384
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (9)
Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour. 📝 WalkthroughWalkthroughThe API now scopes plan and commitment account filters to the caller’s allowed accounts and rejects RI-exchange quotes for out-of-scope deployment accounts. Account-filtered plan queries include unassigned plans only when enabled; unrestricted plan listing enables that option. ChangesTenant account scoping
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant Client
participant listPlans
participant scopedPlanFilter
participant PurchasePlanStore
Client->>listPlans: Request plans with account_ids
listPlans->>scopedPlanFilter: Build filter from session scope and account_ids
scopedPlanFilter-->>listPlans: Scoped filter and account availability
listPlans->>PurchasePlanStore: ListPurchasePlans with scoped filter
PurchasePlanStore-->>listPlans: Matching plans
listPlans-->>Client: Plan list
Merge Risk: ⚪ Minimal · up to The change scopes plan, inventory, and RI-exchange quote reads to the caller's allowed accounts, and no actionable merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 3 | ❌ 1 | ❓ 1❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
Full details: Linked Issues checkExplanation
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
…liation in the notification email (#1278) Since go#497/#499 an auto exchange whose accept response was lost stays in AutoExchangeResult.Failed with OutcomeUnknown set. buildExchangeNotificationData dropped the flag, and the completed-email template lists only rows with an empty Error, so the exchange vanished from the email: a run whose only outcome was unknown arrived as "RI Exchanges Completed (1 exchanges)" with nothing listed. Carry OutcomeUnknown into email.RIExchangeItem, render those exchanges first in their own "NEEDS RECONCILIATION - verify in AWS before acting" group with the record ID and the resolve request, keep them out of the completed list, and switch the subject (SNS/SES and SMTP) to "RI Exchanges Need Reconciliation (k of n exchanges)" when any are present. Definite failures keep their current handling. Closes #1055 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What
Finishes the tenant-scoping sweep from #29 and #384 for three read paths:
GET /api/plans(listPlans, sec(plans): listPlans does not scope account_ids to caller's allowed_accounts #29 and sec(api): finish tenant-scoping sweep: inventory filter, RI-exchange quote, plans list (follow-ups to #374/#379) #384 item 3). The handler discarded the session and passed the client'saccount_idsstraight to the store. A scoped user got every tenant's plans with no params, and?account_ids=<other tenant>narrowed the list to that tenant. It now intersectsaccount_idswithallowed_accountsthroughintersectAccountFilterScopeand returns an empty list when nothing remains.buildListPlansQuery, owner comment on sec(plans): listPlans does not scope account_ids to caller's allowed_accounts #29). TheOR NOT EXISTS (plan_accounts)disjunct was unconditional, so every unassigned plan came back to any account-filtered caller. It is now gated behind a newPurchasePlanFilter.IncludeUnassigned, and only unrestricted callers set it. Scoped callers never see unassigned plans. This matchesrequirePlanAccess(by-id reads already 404 them for scoped users) and the marketplace/revoke precedent (unattributed rows are refused to scoped users).fetchCommitmentRecords, sec(api): finish tenant-scoping sweep: inventory filter, RI-exchange quote, plans list (follow-ups to #374/#379) #384 item 1). An explicitaccount_idnow goes throughintersectAccountFilterScopelike the dashboard does, and the empty-scope sentinel short-circuit is kept. Before this change an out-of-scope account was read from the store, and only the callers' in-memory post-filter hid the rows. So this item is hardening, not a live leak.getExchangeQuote, sec(api): finish tenant-scoping sweep: inventory filter, RI-exchange quote, plans list (follow-ups to #374/#379) #384 item 2). It now appliesrequireReshapeAccountScope, the gateexecuteExchangeuses: 403 when the deployment's AWS account is outside the session scope. A quote is a single action, not a list, so it uses the same 403 as execute rather than the empty list the list siblings return. A scope-lookup error stays a 500 (fail closed).#384 item 4 was already covered by #389. Revoke endpoints (#404) are untouched.
How verified
Ran on macOS with
GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true:go build ./...,go vet(with and without-tags integration) forinternal/api,internal/configandinternal/server, plusgolangci-lint run(0 issues) and the pre-commit hooks (including gocyclo): all pass.go test ./internal/api/ ./internal/config/ ./internal/server/: all ok.postgres:16-alpine):go test -tags integration ./internal/config/ -run TestPostgresStoreDB_ListPurchasePlans_UnassignedBucketpasses. It now has three cases: an admin sees the unassigned plan, a scoped caller sees neither the unassigned plan nor the other account's plan, and the no-filter path is unchanged.Failing before / passing after. Each fix file was reverted to
origin/mainwith the new tests kept, and the tests were run:All of them pass with the fix. A narrower mutation (
IncludeUnassigned: truefor scoped callers too) failsTestListPlans_ScopedCallerSeesOnlyInScopePlans.The handler tests use a store fake that applies the same predicate as
buildListPlansQuery, so a wrong filter fails on an assertion, not on an unstubbed-mock panic. The quote tests use failing AWS credentials, so they never call EC2. This evidence comes from fixtures and mocks; the store predicate itself is checked against real Postgres.Frontend:
plans.tsrenders the "Unassigned" section only when such plans are present, so scoped users simply don't get that section. The inventory and RI-exchange pages need no change.Deliberately left
Closes #29
Refs #384
Summary by CodeRabbit