Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ require (
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0 // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
Expand Down
32 changes: 29 additions & 3 deletions internal/api/handler_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,20 @@ func (h *Handler) resolveTargetCoverage(ctx context.Context) float64 {
// aggressive for "skip this scheduled run". This version emits actual
// execution rows so Cancel can target just the planned purchase via
// DELETE /api/purchases/planned/{id}.
//
// The optional provider, account_ids and legacy account_id query parameters
// narrow the rows like the summary route does (issue #713, see
// parseUpcomingFilter); they never widen the session's allowed_accounts.
func (h *Handler) getUpcomingPurchases(ctx context.Context, req *events.LambdaFunctionURLRequest) (*UpcomingPurchaseResponse, error) {
session, err := h.requirePermission(ctx, req, "view", "purchases")
if err != nil {
return nil, err
}
// Issue #713: the topbar provider/account chips, validated before any read.
filter, err := h.parseUpcomingFilter(ctx, session, req.QueryStringParameters)
if err != nil {
return nil, err
}

executions, err := h.config.GetPendingExecutions(ctx)
if err != nil {
Expand All @@ -445,6 +454,17 @@ func (h *Handler) getUpcomingPurchases(ctx context.Context, req *events.LambdaFu
if err != nil {
return nil, fmt.Errorf("failed to get allowed accounts: %w", err)
}
upcoming, err := h.upcomingInScope(ctx, scope, filter, executions, planMap)
if err != nil {
return nil, err
}
return &UpcomingPurchaseResponse{Purchases: upcoming}, nil
}

// upcomingInScope builds the upcoming rows for the executions whose plan still
// exists, that the session scope can see, and that pass the caller's filter,
// in input order.
func (h *Handler) upcomingInScope(ctx context.Context, scope auth.AccountScope, filter upcomingFilter, executions []config.PurchaseExecution, planMap map[string]*config.PurchasePlan) ([]UpcomingPurchase, error) {
// Memoises GetPlanAccounts, mirroring getPlannedPurchases.
planAccounts := make(map[string][]config.CloudAccount)

Expand All @@ -464,10 +484,16 @@ func (h *Handler) getUpcomingPurchases(ctx context.Context, req *events.LambdaFu
if !ok {
continue
}
upcoming = append(upcoming, upcomingFromExecution(plan, &exec))
row := upcomingFromExecution(plan, &exec)
ok, err = filter.matches(ctx, h, &row, &exec, planAccounts)
if err != nil {
return nil, err
}
if ok {
upcoming = append(upcoming, row)
}
}

return &UpcomingPurchaseResponse{Purchases: upcoming}, nil
return upcoming, nil
}

// firstServiceConfig returns the ServiceConfig for the lexicographically first
Expand Down
99 changes: 99 additions & 0 deletions internal/api/handler_dashboard_upcoming_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package api

import (
"context"
"strings"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// upcomingFilter is the optional provider and account narrowing for
// GET /api/dashboard/upcoming (issue #713), so the Home widget can follow the
// same topbar chips as /api/dashboard/summary. The zero value keeps every row
// the session scope already allows.
type upcomingFilter struct {
// accounts is nil when the caller sent no account filter. Otherwise it
// holds the requested accounts already intersected with the session's
// allowed_accounts by resolveDashboardAccountScope, so an out-of-scope
// account is dropped (an empty set matches nothing) instead of widening
// the result.
accounts *dualColumnAccountSet
// provider is "" for every provider.
provider string
}

// dualColumnAccountSet is a resolved account filter: cloud_accounts UUIDs plus
// external account numbers grouped by provider ("" = provider unknown, from a
// legacy account_id that is not a known UUID).
type dualColumnAccountSet struct {
externalIDsByProvider map[string][]string
uuids []string
}

// parseUpcomingFilter reads the provider, account_ids and legacy account_id
// query parameters with the same validation and precedence as
// getDashboardSummary: an unknown provider or a malformed account_ids is a 400,
// and account_ids wins over account_id.
func (h *Handler) parseUpcomingFilter(ctx context.Context, session *Session, params map[string]string) (upcomingFilter, error) {
provider, err := providerFilterParam(params["provider"])
if err != nil {
return upcomingFilter{}, err
}
f := upcomingFilter{provider: provider}

requested, err := parseAccountIDs(params["account_ids"])
if err != nil {
return upcomingFilter{}, NewClientError(400, err.Error())
}
if len(requested) == 0 && params["account_id"] == "" {
// No account chip: leave the rows to the session scope alone.
return f, nil
}
uuids, externalIDsByProvider, err := h.resolveDashboardAccountScope(ctx, session, params)
if err != nil {
return upcomingFilter{}, err
}
f.accounts = &dualColumnAccountSet{uuids: uuids, externalIDsByProvider: externalIDsByProvider}
return f, nil
}

// matches reports whether an upcoming row passes the filter. The provider is
// compared with the row's provider (its plan's first service, as shown in the
// widget). An execution matches the account filter when any account it can buy
// in is selected: a plan-level parent fans out to every plan account, so it is
// relevant to each of them, while a fan-out child carries its single account.
func (f upcomingFilter) matches(ctx context.Context, h *Handler, row *UpcomingPurchase, exec *config.PurchaseExecution, planAccounts map[string][]config.CloudAccount) (bool, error) {
if f.provider != "" && !strings.EqualFold(row.Provider, f.provider) {
return false, nil
}
if f.accounts == nil {
return true, nil
}
accounts, err := h.executionAccounts(ctx, exec, planAccounts)
if IsNotFoundError(err) {
return false, nil
}
if err != nil {
return false, err
}
return f.accounts.matchesAny(accounts), nil
}

// matchesAny reports whether any account is in the set, by UUID or by external
// account number under its provider (or under "" for an unknown provider).
func (s *dualColumnAccountSet) matchesAny(accounts []config.CloudAccount) bool {
for i := range accounts {
a := &accounts[i]
if stringInSlice(a.ID, s.uuids) {
return true
}
if a.ExternalID == "" {
continue
}
if stringInSlice(a.ExternalID, s.externalIDsByProvider[a.Provider]) ||
stringInSlice(a.ExternalID, s.externalIDsByProvider[""]) {
return true
}
}
return false
}
Loading
Loading