diff --git a/internal/refreshtoken/item.go b/internal/refreshtoken/item.go new file mode 100644 index 0000000000..e675971c70 --- /dev/null +++ b/internal/refreshtoken/item.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package refreshtoken + +import ( + "time" + + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// Item represents a generic resource with a public ID, update time +// and resource type. +type Item struct { + publicId string + updateTime time.Time + resourceType resource.Type +} + +// GetPublicId gets the public ID of the item. +func (p *Item) GetPublicId() string { + return p.publicId +} + +// GetUpdateTime gets the update time of the item. +func (p *Item) GetUpdateTime() *timestamp.Timestamp { + return timestamp.New(p.updateTime) +} + +// GetResourceType gets the resource type of the item. +func (p *Item) GetResourceType() resource.Type { + return p.resourceType +} diff --git a/internal/refreshtoken/item_test.go b/internal/refreshtoken/item_test.go new file mode 100644 index 0000000000..2811a3975f --- /dev/null +++ b/internal/refreshtoken/item_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package refreshtoken + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRefreshToken_ToPartialResource(t *testing.T) { + ctime := time.Now().AddDate(0, 0, -1) + utime := ctime.Add(time.Hour) + rt, err := New(context.Background(), ctime, utime, resource.Session, []byte("some-hash"), "some-id", utime) + require.NoError(t, err) + res := rt.LastItem() + assert.Equal(t, res.GetPublicId(), "some-id") + assert.Equal(t, res.GetResourceType(), resource.Session) + assert.Equal(t, res.GetUpdateTime(), timestamp.New(utime)) +} diff --git a/internal/refreshtoken/refresh_token.go b/internal/refreshtoken/refresh_token.go new file mode 100644 index 0000000000..72444ee90b --- /dev/null +++ b/internal/refreshtoken/refresh_token.go @@ -0,0 +1,156 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package refreshtoken encapsulates domain logic surrounding +// list endpoint refresh tokens. Refresh 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 refreshtoken + +import ( + "bytes" + "context" + "time" + + "github.com/hashicorp/boundary/internal/boundary" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// UpdatedTimeBuffer is used to automatically adjust the updated +// time of a refresh token to account for delays between overlapping +// database transactions. +const UpdatedTimeBuffer = 30 * time.Second + +// A Token is returned in list endpoints for the purposes of pagination +type Token struct { + CreatedTime time.Time + UpdatedTime time.Time + ResourceType resource.Type + GrantsHash []byte + LastItemId string + LastItemUpdatedTime time.Time +} + +// New creates a new refresh token from a createdTime, resource type, grants hash, and last item information +func New(ctx context.Context, createdTime time.Time, updatedTime time.Time, typ resource.Type, grantsHash []byte, lastItemId string, lastItemUpdatedTime time.Time) (*Token, error) { + const op = "refreshtoken.New" + + if len(grantsHash) == 0 { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + } + if createdTime.After(time.Now()) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "created time is in the future") + } + if createdTime.Before(time.Now().AddDate(0, 0, -30)) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "created time is too old") + } + if updatedTime.Before(createdTime) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "updated time is older than created time") + } + if updatedTime.After(time.Now()) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "updated time is in the future") + } + if lastItemId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing last item ID") + } + if lastItemUpdatedTime.After(time.Now()) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "last item updated time is in the future") + } + + return &Token{ + CreatedTime: createdTime, + UpdatedTime: updatedTime, + ResourceType: typ, + GrantsHash: grantsHash, + LastItemId: lastItemId, + LastItemUpdatedTime: lastItemUpdatedTime, + }, nil +} + +// FromResource creates a new refresh token from a resource and grants hash. +func FromResource(res boundary.Resource, grantsHash []byte) *Token { + t := time.Now() + return &Token{ + CreatedTime: t, + UpdatedTime: t, + ResourceType: res.GetResourceType(), + GrantsHash: grantsHash, + LastItemId: res.GetPublicId(), + LastItemUpdatedTime: res.GetUpdateTime().AsTime(), + } +} + +// LastItem returns the last item stored in the token. +func (rt *Token) LastItem() *Item { + return &Item{ + publicId: rt.LastItemId, + updateTime: rt.LastItemUpdatedTime, + resourceType: rt.ResourceType, + } +} + +// Refresh refreshes a token's updated time. It accounts for overlapping +// database transactions by subtracting UpdatedTimeBuffer from the +// provided timestamp while ensuring that the updated time is never +// before the created time of the token. +func (rt *Token) Refresh(updatedTime time.Time) *Token { + rt.UpdatedTime = updatedTime.Add(-UpdatedTimeBuffer) + if rt.UpdatedTime.Before(rt.CreatedTime) { + rt.UpdatedTime = rt.CreatedTime + } + return rt +} + +// RefreshLastItem refreshes a token's updated time and last item. +// It accounts for overlapping database transactions by subtracting +// UpdatedTimeBuffer from the provided timestamp while ensuring that +// the updated time is never before the created time of the token. +func (rt *Token) RefreshLastItem(res boundary.Resource, updatedTime time.Time) *Token { + rt.LastItemId = res.GetPublicId() + rt.LastItemUpdatedTime = res.GetUpdateTime().AsTime() + rt = rt.Refresh(updatedTime) + return rt +} + +// Validate validates the refresh token. +func (rt *Token) Validate( + ctx context.Context, + expectedResourceType resource.Type, + expectedGrantsHash []byte, +) error { + const op = "refreshtoken.Validate" + if rt == nil { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was missing") + } + if len(rt.GrantsHash) == 0 { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was missing its grants hash") + } + if !bytes.Equal(rt.GrantsHash, expectedGrantsHash) { + return errors.New(ctx, errors.InvalidParameter, op, "grants have changed since refresh token was issued") + } + if rt.CreatedTime.After(time.Now()) { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was created in the future") + } + // Tokens older than 30 days have expired + if rt.CreatedTime.Before(time.Now().AddDate(0, 0, -30)) { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was expired") + } + if rt.UpdatedTime.Before(rt.CreatedTime) { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was updated before its creation time") + } + if rt.UpdatedTime.After(time.Now()) { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token was updated in the future") + } + if rt.LastItemId == "" { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token missing last item ID") + } + if rt.LastItemUpdatedTime.After(time.Now()) { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token last item was updated in the future") + } + if rt.ResourceType != expectedResourceType { + return errors.New(ctx, errors.InvalidParameter, op, "refresh token resource type does not match expected resource type") + } + + return nil +} diff --git a/internal/refreshtoken/refresh_token_test.go b/internal/refreshtoken/refresh_token_test.go new file mode 100644 index 0000000000..300e31cb32 --- /dev/null +++ b/internal/refreshtoken/refresh_token_test.go @@ -0,0 +1,430 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package refreshtoken_test + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/boundary/internal/boundary" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/refreshtoken" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/stretchr/testify/require" +) + +func Test_ValidateRefreshToken(t *testing.T) { + t.Parallel() + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + tests := []struct { + name string + token *refreshtoken.Token + grantsHash []byte + resourceType resource.Type + wantErrString string + wantErrCode errors.Code + }{ + { + name: "valid token", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + }, + { + name: "nil token", + token: nil, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was missing", + wantErrCode: errors.InvalidParameter, + }, + { + name: "no grants hash", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: nil, + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was missing its grants hash", + wantErrCode: errors.InvalidParameter, + }, + { + name: "changed grants hash", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some other hash"), + resourceType: resource.Target, + wantErrString: "grants have changed since refresh token was issued", + wantErrCode: errors.InvalidParameter, + }, + { + name: "created in the future", + token: &refreshtoken.Token{ + CreatedTime: time.Now().AddDate(1, 0, 0), + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was created in the future", + wantErrCode: errors.InvalidParameter, + }, + { + name: "expired", + token: &refreshtoken.Token{ + CreatedTime: time.Now().AddDate(0, 0, -31), + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was expired", + wantErrCode: errors.InvalidParameter, + }, + { + name: "updated before created", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, -1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was updated before its creation time", + wantErrCode: errors.InvalidParameter, + }, + { + name: "updated after now", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: time.Now().AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token was updated in the future", + wantErrCode: errors.InvalidParameter, + }, + { + name: "resource type mismatch", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.SessionRecording, + wantErrString: "refresh token resource type does not match expected resource type", + wantErrCode: errors.InvalidParameter, + }, + { + name: "last item ID unset", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token missing last item ID", + wantErrCode: errors.InvalidParameter, + }, + { + name: "last item ID unset", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "", + LastItemUpdatedTime: fiveDaysAgo, + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token missing last item ID", + wantErrCode: errors.InvalidParameter, + }, + { + name: "updated in the future", + token: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "s_1234567890", + LastItemUpdatedTime: time.Now().AddDate(1, 0, 0), + }, + grantsHash: []byte("some hash"), + resourceType: resource.Target, + wantErrString: "refresh token last item was updated in the future", + wantErrCode: errors.InvalidParameter, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.token.Validate(context.Background(), tt.resourceType, tt.grantsHash) + if tt.wantErrString != "" { + require.ErrorContains(t, err, tt.wantErrString) + require.Equal(t, errors.Convert(err).Code, tt.wantErrCode) + return + } + require.NoError(t, err) + }) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + tests := []struct { + name string + createdTime time.Time + updatedTime time.Time + typ resource.Type + grantsHash []byte + lastItemId string + lastItemUpdatedTime time.Time + want *refreshtoken.Token + wantErrString string + wantErrCode errors.Code + }{ + { + name: "valid refresh token", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + want: &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo.AddDate(0, 0, 1), + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "some id", + LastItemUpdatedTime: fiveDaysAgo, + }, + }, + { + name: "missing grants hash", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: nil, + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "missing grants hash", + wantErrCode: errors.InvalidParameter, + }, + { + name: "new created time", + createdTime: fiveDaysAgo.AddDate(1, 0, 0), + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "created time is in the future", + wantErrCode: errors.InvalidParameter, + }, + { + name: "old created time", + createdTime: fiveDaysAgo.AddDate(-1, 0, 0), + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "created time is too old", + wantErrCode: errors.InvalidParameter, + }, + { + name: "new updated time", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(1, 0, 0), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "updated time is in the future", + wantErrCode: errors.InvalidParameter, + }, + { + name: "updated time older than created time", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(0, 0, -11), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "updated time is older than created time", + wantErrCode: errors.InvalidParameter, + }, + { + name: "missing last item id", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "", + lastItemUpdatedTime: fiveDaysAgo, + wantErrString: "missing last item ID", + wantErrCode: errors.InvalidParameter, + }, + { + name: "new last item updated time", + createdTime: fiveDaysAgo, + updatedTime: fiveDaysAgo.AddDate(0, 0, 1), + typ: resource.Target, + grantsHash: []byte("some hash"), + lastItemId: "some id", + lastItemUpdatedTime: fiveDaysAgo.AddDate(1, 0, 0), + wantErrString: "last item updated time is in the future", + wantErrCode: errors.InvalidParameter, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := refreshtoken.New(context.Background(), tt.createdTime, tt.updatedTime, tt.typ, tt.grantsHash, tt.lastItemId, tt.lastItemUpdatedTime) + if tt.wantErrString != "" { + require.ErrorContains(t, err, tt.wantErrString) + require.Equal(t, errors.Convert(err).Code, tt.wantErrCode) + return + } + require.NoError(t, err) + require.Empty(t, cmp.Diff(got, tt.want)) + }) + } +} + +type fakeTargetResource struct { + boundary.Resource + + publicId string + updateTime *timestamp.Timestamp +} + +func (m *fakeTargetResource) GetResourceType() resource.Type { + return resource.Target +} + +func (m *fakeTargetResource) GetPublicId() string { + return m.publicId +} + +func (m *fakeTargetResource) GetUpdateTime() *timestamp.Timestamp { + return m.updateTime +} + +func TestFromResource(t *testing.T) { + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + res := &fakeTargetResource{ + publicId: "tcp_1234567890", + updateTime: timestamp.New(fiveDaysAgo), + } + + tok := refreshtoken.FromResource(res, []byte("some hash")) + + // Check that it's within 1 second of now according to the system + // If this is flaky... just increase the limit 😬. + require.True(t, tok.CreatedTime.Before(time.Now().Add(time.Second))) + require.True(t, tok.CreatedTime.After(time.Now().Add(-time.Second))) + require.True(t, tok.UpdatedTime.Before(time.Now().Add(time.Second))) + require.True(t, tok.UpdatedTime.After(time.Now().Add(-time.Second))) + + require.Equal(t, tok.ResourceType, res.GetResourceType()) + require.Equal(t, tok.GrantsHash, []byte("some hash")) + require.Equal(t, tok.LastItemId, res.GetPublicId()) + require.True(t, tok.LastItemUpdatedTime.Equal(res.GetUpdateTime().AsTime())) +} + +func TestRefresh(t *testing.T) { + createdTime := time.Now().AddDate(0, 0, -5) + tok := &refreshtoken.Token{ + CreatedTime: createdTime, + UpdatedTime: createdTime, + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "tcp_1234567890", + LastItemUpdatedTime: createdTime, + } + updatedTime := time.Now() + newTok := tok.Refresh(updatedTime) + + require.True(t, newTok.UpdatedTime.Equal(updatedTime.Add(-refreshtoken.UpdatedTimeBuffer))) + require.True(t, newTok.CreatedTime.Equal(createdTime)) + require.Equal(t, newTok.ResourceType, tok.ResourceType) + require.Equal(t, newTok.GrantsHash, tok.GrantsHash) + require.Equal(t, newTok.LastItemId, tok.LastItemId) + require.True(t, newTok.LastItemUpdatedTime.Equal(tok.LastItemUpdatedTime)) +} + +func TestRefreshLastItem(t *testing.T) { + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + tok := &refreshtoken.Token{ + CreatedTime: fiveDaysAgo, + UpdatedTime: fiveDaysAgo, + ResourceType: resource.Target, + GrantsHash: []byte("some hash"), + LastItemId: "tcp_1234567890", + LastItemUpdatedTime: fiveDaysAgo, + } + resUpdatedTime := fiveDaysAgo.AddDate(0, 0, 1) + res := &fakeTargetResource{ + publicId: "tcp_0123456789", + updateTime: timestamp.New(resUpdatedTime), + } + tokUpdatedTime := time.Now() + newTok := tok.RefreshLastItem(res, tokUpdatedTime) + + require.True(t, newTok.UpdatedTime.Equal(tokUpdatedTime.Add(-refreshtoken.UpdatedTimeBuffer))) + require.True(t, newTok.CreatedTime.Equal(fiveDaysAgo)) + require.Equal(t, newTok.ResourceType, tok.ResourceType) + require.Equal(t, newTok.GrantsHash, tok.GrantsHash) + require.Equal(t, newTok.LastItemId, tok.LastItemId) + require.True(t, newTok.LastItemUpdatedTime.Equal(res.updateTime.AsTime())) +}