Files
threshold/internal/listtoken/list_token.go
T
Johan Brandhorst-Satzkorn af61b688c6 internal/listtoken: remove overzealous validation
Validating that the update time of the last item is after the
create time of the token cannot account for items whose
update time are earlier than the original query because of
the added database transaction timeout buffer. Remove the
check.
2024-01-05 07:43:20 -08:00

413 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
// Package listtoken encapsulates domain logic surrounding
// list endpoint tokens. List tokens are used when users
// paginate through results in our list endpoints, and also to
// allow users to request new, updated and deleted resources.
package listtoken
import (
"bytes"
"context"
"fmt"
"time"
"github.com/hashicorp/boundary/internal/boundary"
"github.com/hashicorp/boundary/internal/db/timestamp"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/types/resource"
)
// A Token is returned in list endpoints for the purposes of pagination.
// A Token has a subtype, which defines which stage in the list pagination
// lifecycle is in place. The transitions between subtypes can be seen as
// a state machine with the following diagram:
//
// ,---------------------.
// | Initial Request |
// `---------------------'
// * *
// / | More pages in initial phase
// / |
// | ,---------------------.
// | | PaginationToken | *-. More results in this page
// | `---------------------' <-'
// | *
// | No more results | End of initial pagination phase
// | |
// ,----------------------.
// | StartRefreshToken | *-. End of refresh phase
// `----------------------' <-'
// * ^
// | More results | End of refresh phase
// | *
// ,--------------------.
// | RefreshToken | *-. More result in this page
// `--------------------' <-'
//
// For more information, please consult ICU-110
type Token struct {
// The create time of the token. Constant for the lifetime
// of the token.
CreateTime time.Time
// The resource type of the list endpoint this token
// is associated with. Constant for the lifetime
// of the token.
ResourceType resource.Type
// A hash of the grants of the user who made the original
// request. Only used to ensure that grants have not changed
// between requests. Constant for the lifetime of
// the token.
GrantsHash []byte
// The specific subtype of this token. Always
// set ot either PaginationToken, StartRefreshToken
// or RefreshToken.
Subtype TokenSubtype
}
// TokenSubtype is used to create a discriminated union of types
// that can be used as a subtype for a list token.
type TokenSubtype interface {
isTokenSubtype()
}
// Pagination token represents a pagination token subtype to a list
// token. It is used during the initial pagination phase.
type PaginationToken struct {
// The ID of the last item on the previous page.
LastItemId string
// The create time of the last item on the previous page.
LastItemCreateTime time.Time
}
func (*PaginationToken) isTokenSubtype() {}
// StartRefreshToken represents the transition between two phases,
// either the initial pagination phase and the first refresh phase,
// or between refresh phases.
type StartRefreshToken struct {
// The end time of the phase previous to this one,
// which should be used as the lower bound for the
// new refresh phase.
PreviousPhaseUpperBound time.Time
// The timestamp of the transaction that last listed the deleted IDs,
// for use as a lower bound in the next deleted IDs list.
PreviousDeletedIdsTime time.Time
}
func (*StartRefreshToken) isTokenSubtype() {}
// RefreshToken represents a refresh phase.
type RefreshToken struct {
// The upper bound for the timestamp comparisons in
// this refresh phase. This is equal to the time that
// the first request in this phase was processed.
// Constant for the lifetime of the refresh phase.
PhaseUpperBound time.Time
// The lower bound for the timestamp comparisons in
// this refresh phase. This is equal to the initial
// create time of the token if the previous phase was
// the initial pagination phase, or the upper bound of
// the previous refresh phase otherwise.
// Constant for the lifetime of the refresh phase.
PhaseLowerBound time.Time
// The timestamp of the transaction that last listed the deleted IDs,
// for use as a lower bound in the next deleted IDs list.
PreviousDeletedIdsTime time.Time
// The ID of the last item on the previous page.
LastItemId string
// The update time of the last item on the previous page.
LastItemUpdateTime time.Time
}
func (*RefreshToken) isTokenSubtype() {}
// NewPagination creates a new token with the pagination subtype.
func NewPagination(
ctx context.Context,
createTime time.Time,
typ resource.Type,
grantsHash []byte,
lastItemId string,
lastItemCreateTime time.Time,
) (*Token, error) {
const op = "listtoken.NewPagination"
switch {
case len(grantsHash) == 0:
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash")
case createTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is in the future")
case createTime.Before(time.Now().AddDate(0, 0, -30)):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is too old")
case lastItemId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing last item ID")
case lastItemCreateTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "last item create time is in the future")
}
return &Token{
CreateTime: createTime,
ResourceType: typ,
GrantsHash: grantsHash,
Subtype: &PaginationToken{
LastItemId: lastItemId,
LastItemCreateTime: lastItemCreateTime,
},
}, nil
}
// NewStartRefresh creates a new token with a start-refresh subtype.
func NewStartRefresh(
ctx context.Context,
createTime time.Time,
typ resource.Type,
grantsHash []byte,
previousDeletedIdsTime time.Time,
previousPhaseUpperBound time.Time,
) (*Token, error) {
const op = "listtoken.NewStartRefresh"
switch {
case len(grantsHash) == 0:
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash")
case createTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is in the future")
case createTime.Before(time.Now().AddDate(0, 0, -30)):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is too old")
case previousDeletedIdsTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "previous deleted ids time is in the future")
case previousPhaseUpperBound.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "previous phase upper bound is in the future")
}
return &Token{
CreateTime: createTime,
ResourceType: typ,
GrantsHash: grantsHash,
Subtype: &StartRefreshToken{
PreviousPhaseUpperBound: previousPhaseUpperBound,
PreviousDeletedIdsTime: previousDeletedIdsTime,
},
}, nil
}
// NewRefresh creates a new token with a refresh subtype.
func NewRefresh(
ctx context.Context,
createTime time.Time,
typ resource.Type,
grantsHash []byte,
previousDeletedIdsTime time.Time,
phaseUpperBound time.Time,
phaseLowerBound time.Time,
lastItemId string,
lastItemUpdateTime time.Time,
) (*Token, error) {
const op = "listtoken.NewRefresh"
switch {
case len(grantsHash) == 0:
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash")
case createTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is in the future")
case createTime.Before(time.Now().AddDate(0, 0, -30)):
return nil, errors.New(ctx, errors.InvalidParameter, op, "create time is too old")
case previousDeletedIdsTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "previous deleted ids time is in the future")
case phaseUpperBound.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "phase upper bound is in the future")
case phaseLowerBound.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "phase lower bound is in the future")
case phaseLowerBound.After(phaseUpperBound):
return nil, errors.New(ctx, errors.InvalidParameter, op, "phase lower bound is after phase upper bound")
case lastItemId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing last item ID")
case lastItemUpdateTime.After(time.Now()):
return nil, errors.New(ctx, errors.InvalidParameter, op, "last item update time is in the future")
}
return &Token{
CreateTime: createTime,
ResourceType: typ,
GrantsHash: grantsHash,
Subtype: &RefreshToken{
PhaseUpperBound: phaseUpperBound,
PhaseLowerBound: phaseLowerBound,
PreviousDeletedIdsTime: previousDeletedIdsTime,
LastItemId: lastItemId,
LastItemUpdateTime: lastItemUpdateTime,
},
}, nil
}
// LastItem returns the last item stored in the token.
// This will differ depending on whether the token has
// a pagination, start-refresh or refresh subtype.
func (tk *Token) LastItem(ctx context.Context) (*Item, error) {
const op = "listtoken.(*Token).LastItem"
switch st := tk.Subtype.(type) {
case *PaginationToken:
return &Item{
publicId: st.LastItemId,
createTime: timestamp.New(st.LastItemCreateTime),
resourceType: tk.ResourceType,
}, nil
case *RefreshToken:
return &Item{
publicId: st.LastItemId,
updateTime: timestamp.New(st.LastItemUpdateTime),
resourceType: tk.ResourceType,
}, nil
case *StartRefreshToken:
// No item available when starting a new refresh phase.
return nil, errors.New(ctx, errors.Internal, op, "start refresh tokens have no last item")
default:
return nil, errors.New(ctx, errors.Internal, op, fmt.Sprintf("unexpected token subtype: %T", st))
}
}
// Transition transitions the token to the next state
// in the state machine. See the documentation for the
// [Token] type for an overview of the state machine.
func (tk *Token) Transition(
ctx context.Context,
completeListing bool,
lastItem boundary.Resource,
deletedIdsTime time.Time,
listTime time.Time,
) error {
const op = "listtoken.(*Token).Transition"
switch st := (tk.Subtype).(type) {
case *PaginationToken:
if completeListing {
// If this is the last page in the pagination, create a
// start refresh token so subsequent requests are informed
// that they need to start a new refresh phase.
tk.Subtype = &StartRefreshToken{
// In the next refresh phase, both deleted
// ids and the items listing is relative
// to the create time of this token.
PreviousDeletedIdsTime: tk.CreateTime,
PreviousPhaseUpperBound: tk.CreateTime,
}
return nil
}
// Note: this is not a complete listing, which implies that
// lastItem is populated.
tk.Subtype = &PaginationToken{
LastItemId: lastItem.GetPublicId(),
LastItemCreateTime: lastItem.GetCreateTime().AsTime(),
}
case *StartRefreshToken:
if completeListing {
// If this is the only page in the pagination, create a
// start refresh token so subsequent requests are informed
// that they need to start a new refresh phase.
tk.Subtype = &StartRefreshToken{
PreviousDeletedIdsTime: deletedIdsTime,
PreviousPhaseUpperBound: listTime,
}
return nil
}
tk.Subtype = &RefreshToken{
PhaseUpperBound: listTime,
PhaseLowerBound: st.PreviousPhaseUpperBound,
PreviousDeletedIdsTime: deletedIdsTime,
LastItemId: lastItem.GetPublicId(),
LastItemUpdateTime: lastItem.GetUpdateTime().AsTime(),
}
case *RefreshToken:
if completeListing {
// If this is the only page in the pagination, create a
// start refresh token so subsequent requests are informed
// that they need to start a new refresh phase.
tk.Subtype = &StartRefreshToken{
PreviousDeletedIdsTime: deletedIdsTime,
PreviousPhaseUpperBound: st.PhaseUpperBound,
}
return nil
}
// Note: this is not a complete listing, which implies that
// lastItem is populated.
tk.Subtype = &RefreshToken{
PhaseUpperBound: st.PhaseUpperBound,
PhaseLowerBound: st.PhaseLowerBound,
PreviousDeletedIdsTime: deletedIdsTime,
LastItemId: lastItem.GetPublicId(),
LastItemUpdateTime: lastItem.GetUpdateTime().AsTime(),
}
default:
return errors.New(ctx, errors.Internal, op, fmt.Sprintf("unexpected token subtype: %T", st))
}
return nil
}
// Validate validates the contents of the token.
func (tk *Token) Validate(
ctx context.Context,
expectedResourceType resource.Type,
expectedGrantsHash []byte,
) error {
const op = "listtoken.Validate"
switch {
case tk == nil:
return errors.New(ctx, errors.InvalidParameter, op, "list token was missing")
case len(tk.GrantsHash) == 0:
return errors.New(ctx, errors.InvalidParameter, op, "list token was missing its grants hash")
case !bytes.Equal(tk.GrantsHash, expectedGrantsHash):
return errors.New(ctx, errors.InvalidParameter, op, "grants have changed since list token was issued")
case tk.CreateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token was created in the future")
// Tokens older than 30 days have expired
case tk.CreateTime.Before(time.Now().AddDate(0, 0, -30)):
return errors.New(ctx, errors.InvalidParameter, op, "list token was expired")
case tk.ResourceType != expectedResourceType:
return errors.New(ctx, errors.InvalidParameter, op, "list token resource type does not match expected resource type")
}
switch st := tk.Subtype.(type) {
case *RefreshToken:
switch {
case st.PhaseUpperBound.Before(tk.CreateTime):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's phase upper bound was before its creation time")
case st.PhaseUpperBound.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's phase upper bound was in the future")
case st.PhaseLowerBound.Before(tk.CreateTime):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's phase lower bound was before its creation time")
case st.PhaseLowerBound.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's phase lower bound was in the future")
case st.PhaseUpperBound.Before(st.PhaseLowerBound):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's phase upper bound was before the phase lower bound")
case st.PreviousDeletedIdsTime.Before(tk.CreateTime):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component previous deleted ids time was before its creation time")
case st.PreviousDeletedIdsTime.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component previous deleted ids time was in the future")
case st.LastItemId == "":
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component missing last item ID")
case st.LastItemUpdateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's refresh component's last item was updated in the future")
}
case *PaginationToken:
switch {
case st.LastItemId == "":
return errors.New(ctx, errors.InvalidParameter, op, "list tokens's pagination component missing last item ID")
case st.LastItemCreateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's pagination component's last item was created in the future")
}
case *StartRefreshToken:
switch {
case st.PreviousPhaseUpperBound.Before(tk.CreateTime):
return errors.New(ctx, errors.InvalidParameter, op, "list token's start refresh component's previous phase upper bound was before its creation time")
case st.PreviousPhaseUpperBound.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's start refresh component's previous phase upper bound was in the future")
case st.PreviousDeletedIdsTime.Before(tk.CreateTime):
return errors.New(ctx, errors.InvalidParameter, op, "list token's start refresh component previous deleted ids time was before its creation time")
case st.PreviousDeletedIdsTime.After(time.Now()):
return errors.New(ctx, errors.InvalidParameter, op, "list token's start refresh component previous deleted ids time was in the future")
}
}
return nil
}