Files
threshold/internal/listtoken/list_token.go
T
Todd 3558589c90 Return and consume InvalidListToken error code (#4141)
* Return and consume InvalidListToken error code
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.InvalidListToken, op, "list token was missing")
case len(tk.GrantsHash) == 0:
return errors.New(ctx, errors.InvalidListToken, op, "list token was missing its grants hash")
case !bytes.Equal(tk.GrantsHash, expectedGrantsHash):
return errors.New(ctx, errors.InvalidListToken, op, "grants have changed since list token was issued")
case tk.CreateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidListToken, 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.InvalidListToken, op, "list token was expired")
case tk.ResourceType != expectedResourceType:
return errors.New(ctx, errors.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, op, "list token's refresh component previous deleted ids time was in the future")
case st.LastItemId == "":
return errors.New(ctx, errors.InvalidListToken, op, "list token's refresh component missing last item ID")
case st.LastItemUpdateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidListToken, 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.InvalidListToken, op, "list tokens's pagination component missing last item ID")
case st.LastItemCreateTime.After(time.Now()):
return errors.New(ctx, errors.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, 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.InvalidListToken, op, "list token's start refresh component previous deleted ids time was in the future")
}
}
return nil
}