diff --git a/Makefile b/Makefile index 33cd93b67..dc2226355 100644 --- a/Makefile +++ b/Makefile @@ -244,6 +244,8 @@ protobuild: @protoc-go-inject-tag -input=./internal/gen/controller/servers/servers.pb.go @protoc-go-inject-tag -input=./sdk/pbs/controller/api/resources/policies/policy.pb.go @protoc-go-inject-tag -input=./internal/gen/controller/api/services/policy_service.pb.go + @protoc-go-inject-tag -input=./sdk/pbs/controller/api/resources/billing/billing.pb.go + @protoc-go-inject-tag -input=./internal/gen/controller/api/services/billing_service.pb.go # these protos, services and openapi artifacts are purely for testing purposes diff --git a/api/billing/active_users.gen.go b/api/billing/active_users.gen.go new file mode 100644 index 000000000..a75fb982a --- /dev/null +++ b/api/billing/active_users.gen.go @@ -0,0 +1,35 @@ +// Code generated by "make api"; DO NOT EDIT. +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package billing + +import ( + "time" + + "github.com/hashicorp/boundary/api" +) + +type ActiveUsers struct { + Count uint32 `json:"count"` + StartTime time.Time `json:"start_time,omitempty"` + EndTime time.Time `json:"end_time,omitempty"` +} + +// Client is a client for this collection +type Client struct { + client *api.Client +} + +// Creates a new client for this collection. The submitted API client is cloned; +// modifications to it after generating this client will not have effect. If you +// need to make changes to the underlying API client, use ApiClient() to access +// it. +func NewClient(c *api.Client) *Client { + return &Client{client: c.Clone()} +} + +// ApiClient returns the underlying API client +func (c *Client) ApiClient() *api.Client { + return c.client +} diff --git a/api/billing/custom.go b/api/billing/custom.go new file mode 100644 index 000000000..51496b044 --- /dev/null +++ b/api/billing/custom.go @@ -0,0 +1,63 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package billing + +import ( + "context" + "fmt" + "net/url" + + "github.com/hashicorp/boundary/api" +) + +type MonthlyActiveUsersResult struct { + Items []*ActiveUsers + response *api.Response +} + +func (r MonthlyActiveUsersResult) GetItems() any { + return r.Items +} + +func (r MonthlyActiveUsersResult) GetResponse() *api.Response { + return r.response +} + +func (c *Client) MonthlyActiveUsers(ctx context.Context, opt ...Option) (*MonthlyActiveUsersResult, error) { + opts, apiOpts := getOpts(opt...) + + if c.client == nil { + return nil, fmt.Errorf("nil client") + } + + req, err := c.client.NewRequest(ctx, "GET", "billing:monthly-active-users", nil, apiOpts...) + if err != nil { + return nil, fmt.Errorf("error creating MonthlyActiveUsers request: %w", err) + } + + if len(opts.queryMap) > 0 { + q := url.Values{} + for k, v := range opts.queryMap { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("error performing client request during MonthlyActiveUsers call: %w", err) + } + + mau := new(MonthlyActiveUsersResult) + mau.Items = []*ActiveUsers{} + apiErr, err := resp.Decode(mau) + if err != nil { + return nil, fmt.Errorf("error decoding MonthlyActiveUsers response: %w", err) + } + if apiErr != nil { + return nil, apiErr + } + mau.response = resp + return mau, nil +} diff --git a/api/billing/option.gen.go b/api/billing/option.gen.go new file mode 100644 index 000000000..932777f7f --- /dev/null +++ b/api/billing/option.gen.go @@ -0,0 +1,104 @@ +// Code generated by "make api"; DO NOT EDIT. +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package billing + +import ( + "fmt" + "strings" + + "github.com/hashicorp/boundary/api" +) + +// Option is a func that sets optional attributes for a call. This does not need +// to be used directly, but instead option arguments are built from the +// functions in this package. WithX options set a value to that given in the +// argument; DefaultX options indicate that the value should be set to its +// default. When an API call is made options are processed in the order they +// appear in the function call, so for a given argument X, a succession of WithX +// or DefaultX calls will result in the last call taking effect. +type Option func(*options) + +type options struct { + postMap map[string]interface{} + queryMap map[string]string + withAutomaticVersioning bool + withSkipCurlOutput bool + withFilter string + withListToken string +} + +func getDefaultOptions() options { + return options{ + postMap: make(map[string]interface{}), + queryMap: make(map[string]string), + } +} + +func getOpts(opt ...Option) (options, []api.Option) { + opts := getDefaultOptions() + for _, o := range opt { + if o != nil { + o(&opts) + } + } + var apiOpts []api.Option + if opts.withSkipCurlOutput { + apiOpts = append(apiOpts, api.WithSkipCurlOutput(true)) + } + if opts.withFilter != "" { + opts.queryMap["filter"] = opts.withFilter + } + if opts.withListToken != "" { + opts.queryMap["list_token"] = opts.withListToken + } + return opts, apiOpts +} + +// If set, and if the version is zero during an update, the API will perform a +// fetch to get the current version of the resource and populate it during the +// update call. This is convenient but opens up the possibility for subtle +// order-of-modification issues, so use carefully. +func WithAutomaticVersioning(enable bool) Option { + return func(o *options) { + o.withAutomaticVersioning = enable + } +} + +// WithSkipCurlOutput tells the API to not use the current call for cURL output. +// Useful for when we need to look up versions. +func WithSkipCurlOutput(skip bool) Option { + return func(o *options) { + o.withSkipCurlOutput = true + } +} + +// WithListToken tells the API to use the provided list token +// for listing operations on this resource. +func WithListToken(listToken string) Option { + return func(o *options) { + o.withListToken = listToken + } +} + +// WithFilter tells the API to filter the items returned using the provided +// filter term. The filter should be in a format supported by +// hashicorp/go-bexpr. +func WithFilter(filter string) Option { + return func(o *options) { + o.withFilter = strings.TrimSpace(filter) + } +} + +func WithEndTime(inEndTime string) Option { + return func(o *options) { + o.queryMap["end_time"] = fmt.Sprintf("%v", inEndTime) + } +} + +func WithStartTime(inStartTime string) Option { + return func(o *options) { + o.queryMap["start_time"] = fmt.Sprintf("%v", inStartTime) + } +} diff --git a/internal/api/genapi/input.go b/internal/api/genapi/input.go index fc8e8f363..3aa2d7dea 100644 --- a/internal/api/genapi/input.go +++ b/internal/api/genapi/input.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/accounts" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/authmethods" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/authtokens" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/credentiallibraries" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/credentials" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/credentialstores" @@ -65,6 +66,7 @@ type fieldInfo struct { Query bool SkipDefault bool JsonTags []string // Appended to a field's `json` tag (comma separated) + AllowEmpty bool } type structInfo struct { @@ -138,6 +140,8 @@ type structInfo struct { // fieldFilter is a set of field names that will not result in generated API // fields fieldFilter []string + + allowEmpty bool } var inputStructs = []*structInfo{ @@ -224,6 +228,39 @@ var inputStructs = []*structInfo{ createResponseTypes: []string{CreateResponseType, ReadResponseType, UpdateResponseType, DeleteResponseType, ListResponseType}, recursiveListing: true, }, + { + inProto: &billing.ActiveUsers{}, + outFile: "billing/active_users.gen.go", + templates: []*template.Template{ + clientTemplate, + }, + fieldOverrides: []fieldInfo{ + { + Name: "Count", + ProtoName: "count", + FieldType: "uint32", + AllowEmpty: true, + }, + }, + extraFields: []fieldInfo{ + { + Name: "StartTime", + ProtoName: "start_time", + FieldType: "string", + SkipDefault: true, + Query: true, + }, + { + Name: "EndTime", + ProtoName: "end_time", + FieldType: "string", + SkipDefault: true, + Query: true, + }, + }, + pluralResourceName: "billing", + versionEnabled: true, + }, // User related resources { inProto: &users.Account{}, diff --git a/internal/api/genapi/templates.go b/internal/api/genapi/templates.go index 703de83ff..e62a43553 100644 --- a/internal/api/genapi/templates.go +++ b/internal/api/genapi/templates.go @@ -94,6 +94,9 @@ func fillTemplates() { if len(override.JsonTags) != 0 { field.JsonTags = override.JsonTags } + if override.AllowEmpty { + field.AllowEmpty = true + } in.generatedStructure.fields[i] = field } } @@ -715,7 +718,7 @@ import ( ) type {{ .Name }} struct { {{ range .Fields }} -{{ .Name }} {{ .FieldType }} `, "`json:\"{{ .ProtoName }}{{ if ( ne ( len ( .JsonTags ) ) 0 ) }},{{ stringsjoin .JsonTags \",\" }}{{ end }},omitempty\"`", `{{ end }} +{{ .Name }} {{ .FieldType }} `, "`json:\"{{ .ProtoName }}{{ if ( ne ( len ( .JsonTags ) ) 0 ) }},{{ stringsjoin .JsonTags \",\" }}{{ end }}{{ if ( not .AllowEmpty ) }},omitempty{{ end }}\"`", `{{ end }} {{ if ( not ( eq ( len ( .CreateResponseTypes ) ) 0 ) )}} response *api.Response {{ else if ( eq .Name "Error" ) }} diff --git a/internal/authtoken/authtoken_test.go b/internal/authtoken/authtoken_test.go index 432c056a5..898193d15 100644 --- a/internal/authtoken/authtoken_test.go +++ b/internal/authtoken/authtoken_test.go @@ -130,13 +130,17 @@ func TestAuthToken_DbCreate(t *testing.T) { ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") rootWrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, rootWrapper) kms := kms.TestKms(t, conn, rootWrapper) - org, _ := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper)) + org, _ := iam.TestScopes(t, iamRepo) wrapper, err := kms.GetWrapper(context.Background(), org.GetPublicId(), 1) require.NoError(t, err) + user := iam.TestUser(t, iamRepo, org.GetPublicId()) am := password.TestAuthMethods(t, conn, org.GetPublicId(), 1)[0] acct := password.TestAccount(t, conn, am.GetPublicId(), "name1") createdAuthToken := TestAuthToken(t, conn, kms, org.GetPublicId()) + _, err = iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{acct.GetPublicId()}) + require.NoError(t, err) testAuthTokenId := func() string { id, err := NewAuthTokenId(ctx) diff --git a/internal/authtoken/repository_test.go b/internal/authtoken/repository_test.go index fe08d6eff..0b34a066e 100644 --- a/internal/authtoken/repository_test.go +++ b/internal/authtoken/repository_test.go @@ -890,7 +890,9 @@ func Test_CloseExpiredPendingTokens(t *testing.T) { repo, err := NewRepository(ctx, rw, rw, kmsCache) require.NoError(t, err) - org, _ := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper)) + iamRepo := iam.TestRepo(t, conn, rootWrapper) + org, _ := iam.TestScopes(t, iamRepo) + user := iam.TestUser(t, iamRepo, org.GetPublicId()) databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) @@ -900,6 +902,11 @@ func Test_CloseExpiredPendingTokens(t *testing.T) { accts := password.TestMultipleAccounts(t, conn, authMethodId, cnt) for i := 0; i < cnt; i++ { + user, _, err = iamRepo.LookupUser(ctx, user.GetPublicId()) + require.NoError(t, err) + + _, _ = iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{accts[i].GetPublicId()}) + at := allocAuthToken() id, err := NewAuthTokenId(ctx) require.NoError(t, err) diff --git a/internal/billing/active_users.go b/internal/billing/active_users.go new file mode 100644 index 000000000..dec4c826e --- /dev/null +++ b/internal/billing/active_users.go @@ -0,0 +1,15 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import "time" + +// The ActiveUsersCount field is the number of unique users +// counted between the start and end dates. +// The start date is inclusive and the end date is exclusive. +type ActiveUsers struct { + StartTime time.Time + EndTime time.Time + ActiveUsersCount uint32 +} diff --git a/internal/billing/doc.go b/internal/billing/doc.go new file mode 100644 index 000000000..ce68d0cde --- /dev/null +++ b/internal/billing/doc.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package billing provides usage numbers that can be used for +// billing purposes. The currently supported metric is monthly +// active users. A user is considered active within a month +// if they have at least one issued auth token within the time +// range of the start and end of a given month. +package billing diff --git a/internal/billing/options.go b/internal/billing/options.go new file mode 100644 index 000000000..a1b5a3540 --- /dev/null +++ b/internal/billing/options.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "time" +) + +// getOpts - iterate the inbound Options and return a struct +func getOpts(opt ...Option) options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*options) + +// options = how options are represented +type options struct { + withStartTime *time.Time + withEndTime *time.Time +} + +func getDefaultOptions() options { + return options{ + withStartTime: nil, + withEndTime: nil, + } +} + +// WithStartTime allows setting the start time for the query. +func WithStartTime(startTime *time.Time) Option { + return func(o *options) { + o.withStartTime = startTime + } +} + +// WithEndTime allows setting the end time for the query. +func WithEndTime(endTime *time.Time) Option { + return func(o *options) { + o.withEndTime = endTime + } +} diff --git a/internal/billing/options_test.go b/internal/billing/options_test.go new file mode 100644 index 000000000..a774f1e53 --- /dev/null +++ b/internal/billing/options_test.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + + oneMonthAgo := time.Now().AddDate(0, -1, 0) + + t.Run("WithStartTime", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithStartTime(&oneMonthAgo)) + testOpts := getDefaultOptions() + testOpts.withStartTime = &oneMonthAgo + assert.Equal(opts, testOpts) + }) + + t.Run("WithEndTime", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithEndTime(&oneMonthAgo)) + testOpts := getDefaultOptions() + testOpts.withEndTime = &oneMonthAgo + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/billing/query.go b/internal/billing/query.go new file mode 100644 index 000000000..d74b18cb1 --- /dev/null +++ b/internal/billing/query.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +const ( + activeUsersLastTwoMonthsQuery = ` +select * + from hcp_billing_monthly_active_users_last_2_months(); +` + activeUsersWithStartTimeQuery = ` +select * + from hcp_billing_monthly_active_users_all(@start_time); +` + activeUsersWithStartTimeAndEndTimeQuery = ` +select * + from hcp_billing_monthly_active_users_all(@start_time, @end_time); +` +) diff --git a/internal/billing/repository.go b/internal/billing/repository.go new file mode 100644 index 000000000..c77dfea26 --- /dev/null +++ b/internal/billing/repository.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "database/sql" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/errors" +) + +// A Repository retrieves the persistent type in the billing +// package. It is not safe to use a repository concurrently. +// It provides a method for requesting pre-aggregated user counts +// per month. Depending on whether a start time and/or end time are given, +// an ActiveUsers object will be returned: +// - for every month from the provided start date until the present date, +// with the present date being a cumulative count up to the present date. +// - for every month from the provided start date until the provided end date. +// - for the previous month and current month, with the current month being a +// cumulative count up to the present date. +type Repository struct { + reader db.Reader +} + +// NewRepository creates a new Repository. The returned repository is not safe for concurrent go +// routines to access it. +func NewRepository(ctx context.Context, r db.Reader) (*Repository, error) { + const op = "billing.NewRepository" + switch { + case r == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db reader") + } + + return &Repository{ + reader: r, + }, nil +} + +// MonthlyActiveUsers returns the active users for a range of months, from most recent to least. +// If no start or end time is provided, it will return the active users for the last two months. +// If a start time is provided, it will return the active users for that month until the current month. +// If both a start and end time are provided, it will return the active users for that time range, +// starting time inclusive and ending time exclusive. +// The times provided must be the start of the month at midnight UTC. +func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]ActiveUsers, error) { + const op = "billing.Repository.MonthlyActiveUsers" + + opts := getOpts(opt...) + + switch { + case opts.withEndTime != nil && opts.withStartTime == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "end time set without start time") + case opts.withEndTime != nil && !opts.withEndTime.After(*opts.withStartTime): + return nil, errors.New(ctx, errors.InvalidParameter, op, "start time is not before end time") + } + query := activeUsersLastTwoMonthsQuery + var args []any + if opts.withStartTime != nil { + if *opts.withStartTime != time.Date(opts.withStartTime.Year(), opts.withStartTime.Month(), 1, 0, 0, 0, 0, time.UTC) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "start time must be the first day of the month at midnight UTC") + } + query = activeUsersWithStartTimeQuery + args = append(args, + sql.Named("start_time", timestamp.New(*opts.withStartTime))) + } + if opts.withEndTime != nil { + if *opts.withEndTime != time.Date(opts.withEndTime.Year(), opts.withEndTime.Month(), 1, 0, 0, 0, 0, time.UTC) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "end time must be the first day of the month at midnight UTC") + } + query = activeUsersWithStartTimeAndEndTimeQuery + args = append(args, + sql.Named("end_time", timestamp.New(*opts.withEndTime))) + } + + var activeUsers []ActiveUsers + rows, err := r.reader.Query(ctx, query, args) + if err != nil { + return nil, err + } + defer rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + for rows.Next() { + var startTime time.Time + var endTime time.Time + var count uint32 + if err := rows.Scan(&startTime, &endTime, &count); err != nil { + return nil, err + } + + // set start and end times to be in UTC + auUTC := ActiveUsers{ + ActiveUsersCount: count, + StartTime: startTime.UTC(), + EndTime: endTime.UTC(), + } + activeUsers = append(activeUsers, auUTC) + } + + return activeUsers, nil +} diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go new file mode 100644 index 000000000..a9519543c --- /dev/null +++ b/internal/billing/repository_test.go @@ -0,0 +1,173 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" +) + +func TestRepository_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + + type args struct { + r db.Reader + w db.Writer + } + + tests := []struct { + name string + args args + want *Repository + wantIsErr errors.Code + wantErrMsg string + }{ + { + name: "valid", + args: args{ + r: rw, + }, + want: &Repository{ + reader: rw, + }, + }, + { + name: "nil-reader", + args: args{ + r: nil, + }, + want: nil, + wantIsErr: errors.InvalidParameter, + wantErrMsg: "billing.NewRepository: nil db reader: parameter violation: error #100", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got, err := NewRepository(context.Background(), tt.args.r) + if tt.wantIsErr != 0 { + assert.Truef(errors.Match(errors.T(tt.wantIsErr), err), "Unexpected error %s", err) + assert.Equal(tt.wantErrMsg, err.Error()) + return + } + assert.NoError(err) + assert.NotNil(got) + assert.Equal(tt.want, got) + }) + } +} + +func TestRepository_MonthlyActiveUsers(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + + TestGenerateActiveUsers(t, conn) + + today := time.Now().UTC() + threeMonthsAgo := time.Date(today.AddDate(0, -3, 0).Year(), today.AddDate(0, -3, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + oneMonthAgo := time.Date(today.AddDate(0, -1, 0).Year(), today.AddDate(0, -1, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + midMonth := time.Date(today.Year(), today.Month(), 15, 0, 0, 0, 0, time.UTC) + + t.Run("valid-no-options", func(t *testing.T) { + repo := TestRepo(t, conn) + activeUsers, err := repo.MonthlyActiveUsers(ctx) + assert.NoError(t, err) + require.Len(t, activeUsers, 2) + // check counts for the last two months + require.Equal(t, uint32(0), activeUsers[0].ActiveUsersCount) + require.Equal(t, uint32(6), activeUsers[1].ActiveUsersCount) + // assert start and end times are correct + // the current month (contains the hour) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[0].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC), activeUsers[0].EndTime) + // the previous month + assert.Equal(t, time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC), activeUsers[1].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[1].EndTime) + }) + + t.Run("valid-with-start-time", func(t *testing.T) { + repo := TestRepo(t, conn) + activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo)) + assert.NoError(t, err) + require.Len(t, activeUsers, 4) + for i := 0; i < 4; i++ { + // check counts for the last four months + if i == 0 { + assert.Equal(t, uint32(0), activeUsers[i].ActiveUsersCount) + // the current month (contains the hour) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[i].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC), activeUsers[i].EndTime) + } else { + // create a sliding window of dates to assert start and end times are correct + expectedStartTime := time.Date(today.AddDate(0, -i, 0).Year(), today.AddDate(0, -i, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + expectedEndTime := time.Date(today.AddDate(0, -i+1, 0).Year(), today.AddDate(0, -i+1, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, uint32(6), activeUsers[i].ActiveUsersCount) + assert.Equal(t, expectedStartTime, activeUsers[i].StartTime) + assert.Equal(t, expectedEndTime, activeUsers[i].EndTime) + } + } + }) + + t.Run("valid-with-start-and-end-time", func(t *testing.T) { + repo := TestRepo(t, conn) + activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo), WithEndTime(&oneMonthAgo)) + assert.NoError(t, err) + require.Len(t, activeUsers, 2) + expectedStartTime := time.Date(today.AddDate(0, -2, 0).Year(), today.AddDate(0, -2, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + expectedEndTime := time.Date(today.AddDate(0, -1, 0).Year(), today.AddDate(0, -1, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + require.Equal(t, uint32(6), activeUsers[0].ActiveUsersCount) + assert.Equal(t, expectedStartTime, activeUsers[0].StartTime) + assert.Equal(t, expectedEndTime, activeUsers[0].EndTime) + + expectedStartTime = time.Date(today.AddDate(0, -3, 0).Year(), today.AddDate(0, -3, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + expectedEndTime = time.Date(today.AddDate(0, -2, 0).Year(), today.AddDate(0, -2, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + require.Equal(t, uint32(6), activeUsers[1].ActiveUsersCount) + assert.Equal(t, expectedStartTime, activeUsers[1].StartTime) + assert.Equal(t, expectedEndTime, activeUsers[1].EndTime) + }) + + t.Run("invalid-end-time-without-start-time", func(t *testing.T) { + repo := TestRepo(t, conn) + _, err := repo.MonthlyActiveUsers(ctx, WithEndTime(&oneMonthAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: end time set without start time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-end-time-before-start-time", func(t *testing.T) { + repo := TestRepo(t, conn) + _, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&threeMonthsAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time is not before end time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-start-time-equals-end-time", func(t *testing.T) { + repo := TestRepo(t, conn) + _, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&oneMonthAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time is not before end time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-start-time-not-first-day-of-month", func(t *testing.T) { + repo := TestRepo(t, conn) + _, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&midMonth)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time must be the first day of the month at midnight UTC: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-end-time-not-first-day-of-month", func(t *testing.T) { + repo := TestRepo(t, conn) + _, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&midMonth)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: end time must be the first day of the month at midnight UTC: parameter violation: error #100", err.Error()) + }) +} diff --git a/internal/billing/testing.go b/internal/billing/testing.go new file mode 100644 index 000000000..92b765a5e --- /dev/null +++ b/internal/billing/testing.go @@ -0,0 +1,104 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/require" +) + +const insertQuery = ` +with + month_range (date_key, time_key, month) as ( + select wh_date_key(s), wh_time_key(s), s + from generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + users (user_id, u) as ( + select 'u_____user'||u, u + from generate_series(1, 6, 1) as u + ), + user_key (key, user_id) as ( + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email) + select users.user_id, 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + from users + returning key, user_id + ), + tokens (date_key, time_key, user_id, token_id) as ( + select wh_date_key(s), wh_time_key(s), users.user_id, 't_____u'||users.u||'tok'||s as token_id + from users, + generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + tokens_user_keys (date_key, time_key, user_id, token_id, user_key) as ( + select tokens.date_key, tokens.time_key, tokens.user_id, tokens.token_id, user_key.key + from tokens + join user_key + on user_key.user_id = tokens.user_id + ), + auth_tokens (user_key, user_id, token_id, valid_range) as ( + select tokens_user_keys.user_key, tokens_user_keys.user_id, tokens_user_keys.token_id, tstzrange(month_range.month, month_range.month + interval '5 minutes', '[)') + from tokens_user_keys + join month_range + on month_range.date_key = tokens_user_keys.date_key + and month_range.time_key = tokens_user_keys.time_key + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select auth_tokens.token_id, auth_tokens.user_key, + wh_date_key(lower(auth_tokens.valid_range)), wh_time_key(lower(auth_tokens.valid_range)), lower(auth_tokens.valid_range), + coalesce(wh_date_key(upper(auth_tokens.valid_range)), -1), coalesce(wh_time_key(upper(auth_tokens.valid_range)), -1), upper(auth_tokens.valid_range), + wh_date_key(upper(auth_tokens.valid_range)), wh_time_key(upper(auth_tokens.valid_range)), upper(auth_tokens.valid_range), + auth_tokens.valid_range, + auth_tokens.valid_range, + 1 + from auth_tokens; + ` + +// TestRepo creates a repo that can be used for various testing purposes. +func TestRepo(t testing.TB, conn *db.DB) *Repository { + t.Helper() + ctx := context.Background() + require := require.New(t) + rw := db.New(conn) + + repo, err := NewRepository(ctx, rw) + require.NoError(err) + return repo +} + +// TestGenerateActiveUsers is a test helper that populates the data warehouse +// with active users for the last twelve months. +func TestGenerateActiveUsers(t testing.TB, conn *db.DB) { + t.Helper() + db, err := conn.SqlDB(context.Background()) + require.NoError(t, err) + _, err = db.Exec(insertQuery) + require.NoError(t, err) +} diff --git a/internal/census/census_job.go b/internal/census/census_job.go index a29efdf9c..dcca07118 100644 --- a/internal/census/census_job.go +++ b/internal/census/census_job.go @@ -18,11 +18,13 @@ var ( ) type censusJob struct { - r db.Reader - w db.Writer - lurEnabled bool - agent any - eventCtx context.Context + r db.Reader + w db.Writer + lurEnabled bool + emitEvents bool + sessionsAgent any + activeUsersAgent any + eventCtx context.Context } func newCensusJob(ctx context.Context, lurEnabled bool, r db.Reader, w db.Writer) (*censusJob, error) { @@ -35,11 +37,13 @@ func newCensusJob(ctx context.Context, lurEnabled bool, r db.Reader, w db.Writer } return &censusJob{ - r: r, - w: w, - lurEnabled: lurEnabled, - agent: nil, - eventCtx: ctx, + r: r, + w: w, + lurEnabled: lurEnabled, + emitEvents: false, + sessionsAgent: nil, + activeUsersAgent: nil, + eventCtx: ctx, }, nil } diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 734b3a24b..04c7c9b93 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/boundary/internal/cmd/commands/authenticate" "github.com/hashicorp/boundary/internal/cmd/commands/authmethodscmd" "github.com/hashicorp/boundary/internal/cmd/commands/authtokenscmd" + "github.com/hashicorp/boundary/internal/cmd/commands/billingcmd" "github.com/hashicorp/boundary/internal/cmd/commands/config" "github.com/hashicorp/boundary/internal/cmd/commands/connect" "github.com/hashicorp/boundary/internal/cmd/commands/credentiallibrariescmd" @@ -243,6 +244,17 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "list", }), + "billing": func() (cli.Command, error) { + return &billingcmd.Command{ + Command: base.NewCommand(ui, opts...), + }, nil + }, + "billing monthly-active-users": clientCacheWrapper( + &billingcmd.Command{ + Command: base.NewCommand(ui), + Func: "monthly-active-users", + }), + "config": func() (cli.Command, error) { return &config.Command{ Command: base.NewCommand(ui, opts...), diff --git a/internal/cmd/commands/billingcmd/billing.gen.go b/internal/cmd/commands/billingcmd/billing.gen.go new file mode 100644 index 000000000..075189d41 --- /dev/null +++ b/internal/cmd/commands/billingcmd/billing.gen.go @@ -0,0 +1,201 @@ +// Code generated by "make cli"; DO NOT EDIT. +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billingcmd + +import ( + "fmt" + "sync" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/billing" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/common" + "github.com/hashicorp/go-secure-stdlib/strutil" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +func initFlags() { + flagsOnce.Do(func() { + extraFlags := extraActionsFlagsMapFunc() + for k, v := range extraFlags { + flagsMap[k] = append(flagsMap[k], v...) + } + }) +} + +var ( + _ cli.Command = (*Command)(nil) + _ cli.CommandAutocomplete = (*Command)(nil) +) + +type Command struct { + *base.Command + + Func string + + plural string + + extraCmdVars +} + +func (c *Command) AutocompleteArgs() complete.Predictor { + initFlags() + return complete.PredictAnything +} + +func (c *Command) AutocompleteFlags() complete.Flags { + initFlags() + return c.Flags().Completions() +} + +func (c *Command) Synopsis() string { + if extra := extraSynopsisFunc(c); extra != "" { + return extra + } + + synopsisStr := "billing" + + return common.SynopsisFunc(c.Func, synopsisStr) +} + +func (c *Command) Help() string { + initFlags() + + var helpStr string + helpMap := common.HelpMap("billing") + + switch c.Func { + + default: + + helpStr = c.extraHelpFunc(helpMap) + + } + + // Keep linter from complaining if we don't actually generate code using it + _ = helpMap + return helpStr +} + +var flagsMap = map[string][]string{} + +func (c *Command) Flags() *base.FlagSets { + if len(flagsMap[c.Func]) == 0 { + return c.FlagSet(base.FlagSetNone) + } + + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient | base.FlagSetOutputFormat) + f := set.NewFlagSet("Command Options") + common.PopulateCommonFlags(c.Command, f, "billing", flagsMap, c.Func) + + extraFlagsFunc(c, set, f) + + return set +} + +func (c *Command) Run(args []string) int { + initFlags() + + switch c.Func { + case "": + return cli.RunResultHelp + + case "create": + return cli.RunResultHelp + + case "update": + return cli.RunResultHelp + + } + + c.plural = "billing" + switch c.Func { + case "list": + c.plural = "billing" + } + + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + var opts []billing.Option + + if strutil.StrListContains(flagsMap[c.Func], "-id") { + switch c.Func { + + } + } + + client, err := c.Client() + if c.WrapperCleanupFunc != nil { + defer func() { + if err := c.WrapperCleanupFunc(); err != nil { + c.PrintCliError(fmt.Errorf("Error cleaning kms wrapper: %w", err)) + } + }() + } + if err != nil { + c.PrintCliError(fmt.Errorf("Error creating API client: %w", err)) + return base.CommandCliError + } + billingClient := billing.NewClient(client) + + if c.FlagFilter != "" { + opts = append(opts, billing.WithFilter(c.FlagFilter)) + } + + var version uint32 + + if ok := extraFlagsHandlingFunc(c, f, &opts); !ok { + return base.CommandUserError + } + + var resp *api.Response + + resp, err = executeExtraActions(c, resp, err, billingClient, version, opts) + if exitCode := c.checkFuncError(err); exitCode > 0 { + return exitCode + } + + output, err := printCustomActionOutput(c) + if err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + if output { + return base.CommandSuccess + } + + return base.CommandSuccess +} + +func (c *Command) checkFuncError(err error) int { + if err == nil { + return 0 + } + if apiErr := api.AsServerError(err); apiErr != nil { + c.PrintApiError(apiErr, fmt.Sprintf("Error from controller when performing %s on %s", c.Func, c.plural)) + return base.CommandApiError + } + c.PrintCliError(fmt.Errorf("Error trying to %s %s: %s", c.Func, c.plural, err.Error())) + return base.CommandCliError +} + +var ( + flagsOnce = new(sync.Once) + + extraActionsFlagsMapFunc = func() map[string][]string { return nil } + extraSynopsisFunc = func(*Command) string { return "" } + extraFlagsFunc = func(*Command, *base.FlagSets, *base.FlagSet) {} + extraFlagsHandlingFunc = func(*Command, *base.FlagSets, *[]billing.Option) bool { return true } + executeExtraActions = func(_ *Command, inResp *api.Response, inErr error, _ *billing.Client, _ uint32, _ []billing.Option) (*api.Response, error) { + return inResp, inErr + } + printCustomActionOutput = func(*Command) (bool, error) { return false, nil } +) diff --git a/internal/cmd/commands/billingcmd/funcs.go b/internal/cmd/commands/billingcmd/funcs.go new file mode 100644 index 000000000..23df91c6a --- /dev/null +++ b/internal/cmd/commands/billingcmd/funcs.go @@ -0,0 +1,156 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billingcmd + +import ( + "fmt" + "strings" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/billing" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/go-wordwrap" +) + +func init() { + extraActionsFlagsMapFunc = extraActionsFlagsMapFuncImpl + extraSynopsisFunc = extraSynopsisFuncImpl + extraFlagsFunc = extraFlagsFuncImpl + extraFlagsHandlingFunc = extraFlagsHandlingFuncImpl + executeExtraActions = executeExtraActionsImpl + printCustomActionOutput = printCustomActionOutputImpl +} + +type extraCmdVars struct { + flagStartTime string + flagEndTime string + monthlyActiveUsers *billing.MonthlyActiveUsersResult +} + +func extraActionsFlagsMapFuncImpl() map[string][]string { + return map[string][]string{ + "monthly-active-users": {"start-time", "end-time"}, + } +} + +func extraSynopsisFuncImpl(c *Command) string { + switch c.Func { + case "monthly-active-users": + var in string + switch { + case strings.HasPrefix(c.Func, "start-time"): + in = "Get monthly active users, starting from this time (YYYY-MM format)." + case strings.HasPrefix(c.Func, "end-time"): + in = "Get monthly active users, ending at this time (YYYY-MM format)." + } + return wordwrap.WrapString(in, base.TermWidth) + + default: + return "" + } +} + +func extraFlagsFuncImpl(c *Command, _ *base.FlagSets, f *base.FlagSet) { + flagsMap[c.Func] = append(flagsMap[c.Func], "start-time", "end-time") + f.StringVar(&base.StringVar{ + Name: "start-time", + Target: &c.flagStartTime, + Usage: "Get monthly active users, starting from this time (YYYY-MM format).", + }) + f.StringVar(&base.StringVar{ + Name: "end-time", + Target: &c.flagEndTime, + Usage: "Get monthly active users, ending at this time (YYYY-MM format).", + }) +} + +func extraFlagsHandlingFuncImpl(c *Command, _ *base.FlagSets, opts *[]billing.Option) bool { + switch c.Func { + case "monthly-active-users": + if len(c.flagStartTime) != 0 { + *opts = append(*opts, billing.WithStartTime(c.flagStartTime)) + } + if len(c.flagEndTime) != 0 { + *opts = append(*opts, billing.WithEndTime(c.flagEndTime)) + } + } + + return true +} + +func executeExtraActionsImpl(c *Command, origResp *api.Response, origError error, billingClient *billing.Client, _ uint32, opts []billing.Option) (*api.Response, error) { + switch c.Func { + case "monthly-active-users": + var err error + c.monthlyActiveUsers, err = billingClient.MonthlyActiveUsers(c.Context, opts...) + if err != nil { + return nil, err + } + } + return origResp, origError +} + +func printCustomActionOutputImpl(c *Command) (bool, error) { + switch c.Func { + case "monthly-active-users": + switch base.Format(c.UI) { + case "table": + items := c.monthlyActiveUsers.GetItems().([]*billing.ActiveUsers) + var ret []string + + ret = append(ret, "Billing information:") + ret = append(ret, "") + for i := range items { + ret = append(ret, + fmt.Sprintf(" Count: %d", items[i].Count), + fmt.Sprintf(" Start Time: %s", items[i].StartTime), + fmt.Sprintf(" End Time: %s", items[i].EndTime), + "", + ) + } + + c.UI.Output(base.WrapForHelpText(ret)) + return true, nil + + case "json": + if ok := c.PrintJsonItem(c.monthlyActiveUsers.GetResponse()); !ok { + return false, fmt.Errorf("error formatting as JSON") + } + return true, nil + } + } + + return false, nil +} + +func (c *Command) extraHelpFunc(helpMap map[string]func() string) string { + var helpStr string + switch c.Func { + case "": + helpStr = base.WrapForHelpText([]string{ + "Usage: boundary billing [sub command] [options] [args]", + "", + " This command allows for collecting Boundary billing reports. Example:", + "", + " Monthly active users:", + "", + ` $ boundary billing monthly-active-users`, + "", + " Please see the billing subcommand help for detailed usage information.", + }) + case "monthly-active-users": + helpStr = base.WrapForHelpText([]string{ + "Usage: boundary billing monthly-active-users [options]", + "", + " This command allows for collecting active Boundary user reports, by month. Example:", + "", + " Monthly active users between September 2023 and February 2024:", + "", + ` $ boundary billing monthly-active-users -start-time="2023-09" -end-time="2024-02"`, + "", + " Please see the billing subcommand help for detailed usage information.", + }) + } + return helpStr + c.Flags().Help() +} diff --git a/internal/cmd/common/help.go b/internal/cmd/common/help.go index 57fec9579..33e5b33f0 100644 --- a/internal/cmd/common/help.go +++ b/internal/cmd/common/help.go @@ -34,6 +34,7 @@ func HelpMap(resType string) map[string]func() string { resource.AuthToken.String(): "at", resource.AuthMethod.String(): "am", resource.Account.String(): "a", + resource.Billing.String(): "b", resource.Role.String(): "r", resource.Group.String(): "g", resource.User.String(): "u", diff --git a/internal/cmd/gencli/input.go b/internal/cmd/gencli/input.go index 925aea3ca..2c9f5913c 100644 --- a/internal/cmd/gencli/input.go +++ b/internal/cmd/gencli/input.go @@ -17,6 +17,9 @@ type cmdInfo struct { // Standard actions (with standard parameters) used by this resource StdActions []string + // HasCustomList indicates if there is a custom list action + HasCustomList bool + // HasExtraCommandVars controls whether to generate an embedded struct with // extra command variables HasExtraCommandVars bool @@ -211,6 +214,15 @@ var inputStructs = map[string][]*cmdInfo{ Container: "Scope", }, }, + "billing": { + { + ResourceType: resource.Billing.String(), + Pkg: "billing", + HasCustomList: true, + HasExtraCommandVars: true, + HasExtraHelpFunc: true, + }, + }, "credentialstores": { { ResourceType: resource.CredentialStore.String(), diff --git a/internal/cmd/gencli/templates.go b/internal/cmd/gencli/templates.go index 1cecc6d56..2880dc1c2 100644 --- a/internal/cmd/gencli/templates.go +++ b/internal/cmd/gencli/templates.go @@ -478,16 +478,19 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { } var resp *api.Response + {{ if $input.StdActions -}} var item *{{ $input.Pkg }}.{{ camelCase $input.ResourceType }} {{ if hasAction .StdActions "list" }} var items []*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }} {{ end }} + {{ end }} {{ range $i, $action := $input.StdActions }} {{ if ( not ( hasAction $input.SkipClientCallActions $action) ) }} var {{ $action }}Result *{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}{{ camelCase $action }}Result {{ end }} {{ end }} + {{ if $input.StdActions }} switch c.Func { {{ range $i, $action := $input.StdActions }} {{ if eq $action "create" }} @@ -538,8 +541,9 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { {{ end }} {{ end }} } + {{ end }} - resp, item, {{ if hasAction .StdActions "list" }}items, {{ end }}err = executeExtra{{ camelCase .SubActionPrefix }}Actions(c, resp, item, {{ if hasAction .StdActions "list" }}items, {{ end }}err, {{ .Pkg }}Client, version, opts) + resp, {{ if $input.StdActions }}item, {{ if hasAction .StdActions "list" }}items, {{ end }}{{ end }}err = executeExtra{{ camelCase .SubActionPrefix }}Actions(c, resp, {{ if $input.StdActions }}item, {{ if hasAction .StdActions "list" }}items, {{ end }}{{ end }}err, {{ .Pkg }}Client, version, opts) if exitCode := c.checkFuncError(err); exitCode > 0 { return exitCode } @@ -553,6 +557,7 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { return base.CommandSuccess } + {{ if $input.StdActions }} switch c.Func { {{ range $i, $action := .StdActions }} {{ if eq $action "delete" }} @@ -595,6 +600,7 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { return base.CommandCliError } } + {{ end }} return base.CommandSuccess } @@ -619,8 +625,8 @@ var ( extra{{ camelCase .SubActionPrefix }}SynopsisFunc = func(*{{ camelCase .SubActionPrefix }}Command) string { return "" } extra{{ camelCase .SubActionPrefix }}FlagsFunc = func(*{{ camelCase .SubActionPrefix }}Command, *base.FlagSets, *base.FlagSet) {} extra{{ camelCase .SubActionPrefix }}FlagsHandlingFunc = func(*{{ camelCase .SubActionPrefix }}Command, *base.FlagSets, *[]{{ .Pkg }}.Option) bool { return true } - executeExtra{{ camelCase .SubActionPrefix }}Actions = func(_ *{{ camelCase .SubActionPrefix }}Command, inResp *api.Response, inItem *{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ if hasAction .StdActions "list" }}inItems []*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ end }}inErr error, _ *{{ .Pkg }}.Client, _ uint32, _ []{{ .Pkg }}.Option) (*api.Response, *{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ if hasAction .StdActions "list" }}[]*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ end }}error) { - return inResp, inItem, {{ if hasAction .StdActions "list" }}inItems, {{ end }}inErr + executeExtra{{ camelCase .SubActionPrefix }}Actions = func(_ *{{ camelCase .SubActionPrefix }}Command, inResp *api.Response, {{ if $input.StdActions }}inItem *{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ if hasAction .StdActions "list" }}inItems []*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ end }}{{ end }}inErr error, _ *{{ .Pkg }}.Client, _ uint32, _ []{{ .Pkg }}.Option) (*api.Response, {{ if $input.StdActions }}*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ if hasAction .StdActions "list" }}[]*{{ $input.Pkg }}.{{ camelCase $input.ResourceType }}, {{ end }}{{ end }}error) { + return inResp, {{ if $input.StdActions }}inItem, {{ if hasAction .StdActions "list" }}inItems, {{ end }}{{ end }}inErr } printCustom{{ camelCase .SubActionPrefix }}ActionOutput = func(*{{ camelCase .SubActionPrefix }}Command) (bool, error) { return false, nil } ) diff --git a/internal/daemon/controller/common/common.go b/internal/daemon/controller/common/common.go index 9f4eb0a9b..d73342764 100644 --- a/internal/daemon/controller/common/common.go +++ b/internal/daemon/controller/common/common.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/boundary/internal/auth/ldap" "github.com/hashicorp/boundary/internal/auth/oidc" "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/billing" "github.com/hashicorp/boundary/internal/credential" credstatic "github.com/hashicorp/boundary/internal/credential/static" "github.com/hashicorp/boundary/internal/credential/vault" @@ -39,6 +40,7 @@ type ( ConnectionRepoFactory func() (*session.ConnectionRepository, error) WorkerAuthRepoStorageFactory func() (*server.WorkerAuthRepositoryStorage, error) PluginStorageBucketRepoFactory func() (*pluginstorage.Repository, error) + BillingRepoFactory func() (*billing.Repository, error) ) // Downstreamers provides at least a minimum interface that must be met by a diff --git a/internal/daemon/controller/controller.go b/internal/daemon/controller/controller.go index cd096c36a..5e0f90b58 100644 --- a/internal/daemon/controller/controller.go +++ b/internal/daemon/controller/controller.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/boundary/internal/auth/oidc" "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/authtoken" + "github.com/hashicorp/boundary/internal/billing" "github.com/hashicorp/boundary/internal/census" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/hashicorp/boundary/internal/cmd/config" @@ -148,6 +149,7 @@ type Controller struct { PluginRepoFn common.PluginRepoFactory TargetRepoFn target.RepositoryFactory WorkerAuthRepoStorageFn common.WorkerAuthRepoStorageFactory + BillingRepoFn common.BillingRepoFactory scheduler *scheduler.Scheduler @@ -448,6 +450,9 @@ func New(ctx context.Context, conf *Config) (*Controller, error) { c.WorkerAuthRepoStorageFn = func() (*server.WorkerAuthRepositoryStorage, error) { return server.NewRepositoryStorage(ctx, dbase, dbase, c.kms) } + c.BillingRepoFn = func() (*billing.Repository, error) { + return billing.NewRepository(ctx, dbase) + } // Check that credentials are available at startup, to avoid some harmless // but nasty-looking errors diff --git a/internal/daemon/controller/handler.go b/internal/daemon/controller/handler.go index 299198a02..88348c6c3 100644 --- a/internal/daemon/controller/handler.go +++ b/internal/daemon/controller/handler.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp/boundary/internal/daemon/controller/handlers/accounts" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authmethods" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authtokens" + "github.com/hashicorp/boundary/internal/daemon/controller/handlers/billing" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentiallibraries" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentials" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentialstores" @@ -349,6 +350,13 @@ func (c *Controller) registerGrpcServices(s *grpc.Server) error { opsservices.RegisterHealthServiceServer(s, hs) c.HealthService = hs } + if _, ok := currentServices[services.BillingService_ServiceDesc.ServiceName]; !ok { + bs, err := billing.NewService(c.baseContext, c.BillingRepoFn) + if err != nil { + return fmt.Errorf("failed to create billing handler service: %w", err) + } + services.RegisterBillingServiceServer(s, bs) + } return nil } @@ -419,6 +427,9 @@ func registerGrpcGatewayEndpoints(ctx context.Context, gwMux *runtime.ServeMux, if err := services.RegisterPolicyServiceHandlerFromEndpoint(ctx, gwMux, gatewayTarget, dialOptions); err != nil { return fmt.Errorf("failed to register policy handler: %w", err) } + if err := services.RegisterBillingServiceHandlerFromEndpoint(ctx, gwMux, gatewayTarget, dialOptions); err != nil { + return fmt.Errorf("failed to register billing service handler: %w", err) + } return nil } diff --git a/internal/daemon/controller/handler_test.go b/internal/daemon/controller/handler_test.go index f1a5262ae..4b386ff7f 100644 --- a/internal/daemon/controller/handler_test.go +++ b/internal/daemon/controller/handler_test.go @@ -137,6 +137,7 @@ func TestHandleImplementedPaths(t *testing.T) { "v1/targets/someid", "v1/users", "v1/users/someid", + "v1/billing:monthly-active-users", }, "POST": { // Creation end points diff --git a/internal/daemon/controller/handlers/billing/billing_service.go b/internal/daemon/controller/handlers/billing/billing_service.go new file mode 100644 index 000000000..303d49cbc --- /dev/null +++ b/internal/daemon/controller/handlers/billing/billing_service.go @@ -0,0 +1,125 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/billing" + "github.com/hashicorp/boundary/internal/daemon/controller/auth" + "github.com/hashicorp/boundary/internal/daemon/controller/common" + "github.com/hashicorp/boundary/internal/daemon/controller/handlers" + "github.com/hashicorp/boundary/internal/errors" + pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "github.com/hashicorp/boundary/internal/types/action" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/hashicorp/boundary/internal/types/scope" + pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + // IdActions contains the set of actions that can be performed on + // individual resources + IdActions = action.NewActionSet() + + // CollectionActions contains the set of actions that can be performed on + // this collection + CollectionActions = action.NewActionSet( + action.MonthlyActiveUsers, + ) +) + +func init() { + // TODO: refactor to remove IdActions and CollectionActions package variables + action.RegisterResource(resource.Billing, IdActions, CollectionActions) +} + +type Service struct { + pbs.UnsafeBillingServiceServer + + repoFn common.BillingRepoFactory +} + +var _ pbs.BillingServiceServer = (*Service)(nil) + +// NewService returns a billing service which handles billing related requests to boundary. +func NewService( + ctx context.Context, + repoFn common.BillingRepoFactory, +) (Service, error) { + const op = "billing.NewService" + if repoFn == nil { + return Service{}, errors.New(ctx, errors.InvalidParameter, op, "missing billing repository") + } + return Service{ + repoFn: repoFn, + }, nil +} + +func (s Service) MonthlyActiveUsers(ctx context.Context, req *pbs.MonthlyActiveUsersRequest) (*pbs.MonthlyActiveUsersResponse, error) { + const op = "billing.(Service).MonthlyActiveUsers" + + authResults := s.authResult(ctx, action.MonthlyActiveUsers) + if authResults.Error != nil { + return nil, errors.Wrap(ctx, authResults.Error, op) + } + + // Get the billing information + repo, err := s.repoFn() + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + var startTime, endTime *time.Time + if req.GetStartTime() != "" { + st, err := time.Parse("2006-01", req.GetStartTime()) + if err != nil { + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "start time is in an invalid format") + } + startTime = &st + } + if req.GetEndTime() != "" { + et, err := time.Parse("2006-01", req.GetEndTime()) + if err != nil { + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "end time is in an invalid format") + } + endTime = &et + } + if startTime != nil && endTime != nil && !endTime.After(*startTime) { + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "start time is not before end time") + } + + months, err := repo.MonthlyActiveUsers( + ctx, + billing.WithStartTime(startTime), + billing.WithEndTime(endTime), + ) + if err != nil { + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, err.Error()) + } + + var activeUsers []*pb.ActiveUsers + for _, month := range months { + maud := &pb.ActiveUsers{ + StartTime: timestamppb.New(month.StartTime), + EndTime: timestamppb.New(month.EndTime), + Count: month.ActiveUsersCount, + } + activeUsers = append(activeUsers, maud) + } + + return &pbs.MonthlyActiveUsersResponse{Items: activeUsers}, nil +} + +func (s Service) authResult(ctx context.Context, a action.Type) auth.VerifyResults { + opts := []auth.Option{ + auth.WithType(resource.Billing), + auth.WithAction(a), + auth.WithScopeId(scope.Global.String()), + } + return auth.Verify(ctx, opts...) +} diff --git a/internal/daemon/controller/handlers/billing/billing_service_test.go b/internal/daemon/controller/handlers/billing/billing_service_test.go new file mode 100644 index 000000000..07ec26b74 --- /dev/null +++ b/internal/daemon/controller/handlers/billing/billing_service_test.go @@ -0,0 +1,169 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing_test + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/billing" + "github.com/hashicorp/boundary/internal/daemon/controller/auth" + billingservice "github.com/hashicorp/boundary/internal/daemon/controller/handlers/billing" + "github.com/hashicorp/boundary/internal/db" + pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/types/scope" + pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MonthlyActiveUsers(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + + repoFn := func() (*billing.Repository, error) { + return billing.TestRepo(t, conn), nil + } + billing.TestGenerateActiveUsers(t, conn) + + wrap := db.TestWrapper(t) + iamRepoFn := func() (*iam.Repository, error) { + return iam.TestRepo(t, conn, wrap), nil + } + + today := time.Now().UTC() + threeMonthsAgo := time.Date(today.AddDate(0, -3, 0).Year(), today.AddDate(0, -3, 0).Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01") + oneMonthAgo := time.Date(today.AddDate(0, -1, 0).Year(), today.AddDate(0, -1, 0).Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01") + badFormat := time.Date(today.Year(), today.Month(), 15, 0, 0, 0, 0, time.UTC).String() + + cases := []struct { + name string + req *pbs.MonthlyActiveUsersRequest + res *pbs.MonthlyActiveUsersResponse + errContains string + }{ + { + name: "Valid no options, current and previous months", + req: &pbs.MonthlyActiveUsersRequest{}, + res: &pbs.MonthlyActiveUsersResponse{ + Items: []*pb.ActiveUsers{ + { + Count: 0, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC)), + }, + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + name: "Valid start time", + req: &pbs.MonthlyActiveUsersRequest{StartTime: threeMonthsAgo}, + res: &pbs.MonthlyActiveUsersResponse{ + Items: []*pb.ActiveUsers{ + { + Count: 0, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC)), + }, + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)), + }, + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-2, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-3, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month()-2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + name: "Valid start and end time", + req: &pbs.MonthlyActiveUsersRequest{StartTime: threeMonthsAgo, EndTime: oneMonthAgo}, + res: &pbs.MonthlyActiveUsersResponse{ + Items: []*pb.ActiveUsers{ + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-2, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + Count: 6, + StartTime: timestamppb.New(time.Date(today.Year(), today.Month()-3, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(today.Year(), today.Month()-2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + name: "Invalid end time without start time", + req: &pbs.MonthlyActiveUsersRequest{EndTime: oneMonthAgo}, + errContains: "end time set without start time", + }, + { + name: "Invalid end time before start time", + req: &pbs.MonthlyActiveUsersRequest{StartTime: oneMonthAgo, EndTime: threeMonthsAgo}, + errContains: "start time is not before end time", + }, + { + name: "Invalid start time equals end time", + req: &pbs.MonthlyActiveUsersRequest{StartTime: threeMonthsAgo, EndTime: threeMonthsAgo}, + errContains: "start time is not before end time", + }, + { + name: "Invalid start time format", + req: &pbs.MonthlyActiveUsersRequest{StartTime: badFormat}, + errContains: "start time is in an invalid format", + }, + { + name: "Invalid end time format", + req: &pbs.MonthlyActiveUsersRequest{StartTime: threeMonthsAgo, EndTime: badFormat}, + errContains: "end time is in an invalid format", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b, err := billingservice.NewService(ctx, repoFn) + require.NoError(t, err, "Couldn't create new billing service.") + + got, gErr := b.MonthlyActiveUsers(auth.DisabledAuthTestContext(iamRepoFn, scope.Global.String(), auth.WithUserId(globals.AnyAuthenticatedUserId)), tc.req) + if tc.errContains != "" { + require.ErrorContains(t, gErr, tc.errContains) + require.Nil(t, got) + return + } else { + require.NoError(t, gErr) + } + assert.Empty(t, + cmp.Diff( + got, + tc.res, + protocmp.Transform(), + protocmp.SortRepeatedFields(got), + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + )) + }) + } +} diff --git a/internal/daemon/controller/rate_limiter_test.go b/internal/daemon/controller/rate_limiter_test.go index 24bfac23e..f42d52b50 100644 --- a/internal/daemon/controller/rate_limiter_test.go +++ b/internal/daemon/controller/rate_limiter_test.go @@ -63,7 +63,7 @@ func Test_newRateLimiterConfig(t *testing.T) { ratelimit.DefaultLimiterMaxQuotas(), false, &rateLimiterConfig{ - maxSize: 322161, + maxSize: 324162, configs: nil, disabled: false, limits: defaultLimits, diff --git a/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/defaults.json b/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/defaults.json index b1fa65f07..13520cab0 100644 --- a/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/defaults.json +++ b/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/defaults.json @@ -4075,7 +4075,7 @@ ] } }, - "max_size": 322161, + "max_size": 324162, "msg": "controller api rate limiter" }, "op": "controller.(rateLimiterConfig).writeSysEvent", diff --git a/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/override.json b/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/override.json index 4c928c051..8253c8609 100644 --- a/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/override.json +++ b/internal/daemon/controller/testdata/Test_rateLimiterConfig_writeSysEvent/override.json @@ -4075,7 +4075,7 @@ ] } }, - "max_size": 322161, + "max_size": 324162, "msg": "controller api rate limiter" }, "op": "controller.(rateLimiterConfig).writeSysEvent", diff --git a/internal/db/schema/migrations/oss/postgres/15/01_wh_rename_key_columns.up.sql b/internal/db/schema/migrations/oss/postgres/15/01_wh_rename_key_columns.up.sql index 62491ab53..9d6a797e1 100644 --- a/internal/db/schema/migrations/oss/postgres/15/01_wh_rename_key_columns.up.sql +++ b/internal/db/schema/migrations/oss/postgres/15/01_wh_rename_key_columns.up.sql @@ -97,6 +97,7 @@ begin; ; -- replaces function from 14/01_wh_user_dimension_oidc.up.sql + -- replaced in 82/02_wh_upsert_user_refact.up.sql drop function wh_upsert_user; create function wh_upsert_user(p_user_id wt_user_id, p_auth_token_id wt_public_id) returns wh_dim_key as $$ diff --git a/internal/db/schema/migrations/oss/postgres/60/03_wh_sessions.up.sql b/internal/db/schema/migrations/oss/postgres/60/03_wh_sessions.up.sql index d3728d0d9..3ddd6f6bf 100644 --- a/internal/db/schema/migrations/oss/postgres/60/03_wh_sessions.up.sql +++ b/internal/db/schema/migrations/oss/postgres/60/03_wh_sessions.up.sql @@ -216,7 +216,8 @@ begin; create trigger wh_update_session_connection_accumulating_fact after insert on session_host_set_host for each row execute procedure wh_upsert_host(); - -- replaced function in 16/04_wh_credential_dimension.up.sql + -- Replaces function from 16/04_wh_credential_dimension.up.sql + -- Replaced in 82/02_wh_upsert_user_refact.up.sql create or replace function wh_insert_session() returns trigger as $$ declare diff --git a/internal/db/schema/migrations/oss/postgres/63/01_credential_vault_ssh_cert_library.up.sql b/internal/db/schema/migrations/oss/postgres/63/01_credential_vault_ssh_cert_library.up.sql index 7d82ee378..d41f9a11f 100644 --- a/internal/db/schema/migrations/oss/postgres/63/01_credential_vault_ssh_cert_library.up.sql +++ b/internal/db/schema/migrations/oss/postgres/63/01_credential_vault_ssh_cert_library.up.sql @@ -136,6 +136,7 @@ begin; comment on table credential_vault_ssh_cert_library is 'credential_vault_ssh_cert_library a credential library that issues credentials from a vault ssh secret backend.'; + -- Replaced in 82/07_vault_ssh_cert_default.up.sql create function default_ssh_certificate_credential_type() returns trigger as $$ begin diff --git a/internal/db/schema/migrations/oss/postgres/72/01_census.up.sql b/internal/db/schema/migrations/oss/postgres/72/01_census.up.sql index 71b14d661..5ca5710c8 100644 --- a/internal/db/schema/migrations/oss/postgres/72/01_census.up.sql +++ b/internal/db/schema/migrations/oss/postgres/72/01_census.up.sql @@ -12,6 +12,7 @@ begin; -- This index ensures that there will only ever be one row in the table. -- See: https://www.postgresql.org/docs/current/indexes-expressional.html + -- Dropped in 82/09_census_add_metric.up.sql create unique index census_last_uploaded_one_row on census_last_uploaded((last_uploaded_at is not null)); diff --git a/internal/db/schema/migrations/oss/postgres/84/01_wh_auth_token.up.sql b/internal/db/schema/migrations/oss/postgres/84/01_wh_auth_token.up.sql new file mode 100644 index 000000000..bdd2a2a14 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/01_wh_auth_token.up.sql @@ -0,0 +1,101 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + -- wh_user_id is similar to wt_user_id but for the data warehouse. + -- Unlike wt_user_id, this allows for nulls. + create domain wh_user_id as text + check( + length(trim(value)) > 10 or value in ('u_anon', 'u_auth', 'u_recovery') + ); + comment on domain wh_user_id is + '"u_anon", "u_auth", "u_recovery" or random ID generated with github.com/hashicorp/go-secure-stdlib/base62'; + + create table wh_auth_token_accumulating_fact ( + auth_token_id wt_public_id primary key, + user_id wh_user_id not null, + user_key wh_dim_key not null + references wh_user_dimension (key) + on delete restrict + on update cascade, + + -- date and time foreign keys + auth_token_issued_date_key integer not null + references wh_date_dimension (key) + on delete restrict + on update cascade, + auth_token_issued_time_key integer not null + references wh_time_of_day_dimension (key) + on delete restrict + on update cascade, + auth_token_issued_time wh_timestamp, + + auth_token_deleted_date_key integer default -1 not null + references wh_date_dimension (key) + on delete restrict + on update cascade, + auth_token_deleted_time_key integer default -1 not null + references wh_time_of_day_dimension (key) + on delete restrict + on update cascade, + auth_token_deleted_time wh_timestamp default 'infinity'::timestamptz, + + auth_token_approximate_last_access_date_key integer default -1 not null + references wh_date_dimension (key) + on delete restrict + on update cascade, + auth_token_approximate_last_access_time_key integer default -1 not null + references wh_time_of_day_dimension (key) + on delete restrict + on update cascade, + auth_token_approximate_last_access_time wh_timestamp, + + auth_token_approximate_active_time_range tstzrange not null default tstzrange(current_timestamp, current_timestamp, '[]'), + auth_token_valid_time_range tstzrange not null default tstzrange(current_timestamp, 'infinity'::timestamptz, '[]'), + + -- the auth_token_count must always be 1 + -- this is a common pattern in data warehouse models + -- See The Data Warehouse Toolkit, Third Edition + -- by Ralph Kimball and Margy Ross for more information + auth_token_count smallint default 1 not null + constraint auth_token_count_must_be_1 + check(auth_token_count = 1), + + constraint last_accessed_time_lte_deleted_time + check(auth_token_approximate_last_access_time <= auth_token_deleted_time), + + constraint active_time_lower_eq_issued_time + check(lower(auth_token_approximate_active_time_range) = auth_token_issued_time), + constraint active_time_upper_eq_last_accessed_time + check(upper(auth_token_approximate_active_time_range) = auth_token_approximate_last_access_time), + constraint valid_time_lower_eq_issued + check(lower(auth_token_valid_time_range) = auth_token_issued_time), + constraint valid_time_upper_eq_deleted + check(upper(auth_token_valid_time_range) = auth_token_deleted_time), + constraint active_time_contained_by_valid_time + check(auth_token_approximate_active_time_range <@ auth_token_valid_time_range) + ); + comment on table wh_auth_token_accumulating_fact is + 'The Wh Auth Token Accumulating Fact table is an accumulating fact table. ' + 'The grain of the fact table is one row per auth token.'; + + create function wh_insert_auth_token() returns trigger + as $$ + begin + select user_id + into new.user_id + from wh_user_dimension + where key = new.user_key; + + return new; + end; + $$ language plpgsql; + + create trigger wh_insert_auth_token before insert on wh_auth_token_accumulating_fact + for each row execute function wh_insert_auth_token(); + + create trigger immutable_columns before update on wh_auth_token_accumulating_fact + for each row execute procedure immutable_columns('user_id', 'user_key', 'auth_token_issued_time', 'auth_token_issued_time_key', 'auth_token_issued_date_key'); + +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/02_wh_upsert_user_refact.up.sql b/internal/db/schema/migrations/oss/postgres/84/02_wh_upsert_user_refact.up.sql new file mode 100644 index 000000000..4f52d710b --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/02_wh_upsert_user_refact.up.sql @@ -0,0 +1,111 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + drop trigger wh_insert_session on session; + drop function wh_insert_session; + drop function wh_upsert_user; + + -- Replaces function from 15/01_wh_rename_key_columns.up.sql + create function wh_upsert_user(p_auth_token_id wt_public_id) returns wh_dim_key + as $$ + declare + src whx_user_dimension_target%rowtype; + target whx_user_dimension_target%rowtype; + new_row wh_user_dimension%rowtype; + acct_id wt_public_id; + begin + select auth_account_id into strict acct_id + from auth_token + where public_id = p_auth_token_id; + + select * into target + from whx_user_dimension_target as t + where t.auth_account_id = acct_id; + + select target.key, t.* into src + from whx_user_dimension_source as t + where t.auth_account_id = acct_id; + + if src is distinct from target then + + -- expire the current row + update wh_user_dimension + set current_row_indicator = 'Expired', + row_expiration_time = current_timestamp + where auth_account_id = acct_id + and current_row_indicator = 'Current'; + + -- insert a new row + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_account_external_id, auth_account_full_name, auth_account_email, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + auth_method_external_id, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, row_effective_time, row_expiration_time + ) + select user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_account_external_id, auth_account_full_name, auth_account_email, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + auth_method_external_id, + user_organization_id, user_organization_name, user_organization_description, + 'Current', current_timestamp, 'infinity'::timestamptz + from whx_user_dimension_source + where auth_account_id = acct_id + returning * into new_row; + + return new_row.key; + end if; + return target.key; + + end; + $$ language plpgsql; + comment on function wh_upsert_user is + 'wh_upsert_user a function insert or updates the wh_user_dimension table' + 'for the user that corresponds to the provided auth_token_id.'; + + -- Replaces function from 60/03_wh_sessions.up.sql + create function wh_insert_session() returns trigger + as $$ + declare + new_row wh_session_accumulating_fact%rowtype; + begin + with + pending_timestamp (date_dim_key, time_dim_key, ts) as ( + select wh_date_key(start_time), wh_time_key(start_time), start_time + from session_state + where session_id = new.public_id + and state = 'pending' + ) + insert into wh_session_accumulating_fact ( + session_id, + auth_token_id, + host_key, + user_key, + credential_group_key, + session_pending_date_key, + session_pending_time_key, + session_pending_time + ) + select new.public_id, + new.auth_token_id, + 'no host source', -- will be updated by wh_upsert_host + wh_upsert_user(new.auth_token_id), + 'no credentials', -- will be updated by wh_upsert_credential_group + pending_timestamp.date_dim_key, + pending_timestamp.time_dim_key, + pending_timestamp.ts + from pending_timestamp + returning * into strict new_row; + return null; + end; + $$ language plpgsql; + + create trigger wh_insert_session after insert on session + for each row execute procedure wh_insert_session(); + +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/03_auth_token_issued.up.sql b/internal/db/schema/migrations/oss/postgres/84/03_auth_token_issued.up.sql new file mode 100644 index 000000000..6707d34ea --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/03_auth_token_issued.up.sql @@ -0,0 +1,19 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + create function auth_token_before_issued() returns trigger + as $$ + begin + if new.status = 'token issued' then + new.approximate_last_access_time = now(); + end if; + return new; + end; + $$ language plpgsql; + + create trigger auth_token_before_inserted before insert on auth_token + for each row execute procedure auth_token_before_issued(); + create trigger auth_token_before_updated before update on auth_token + for each row execute procedure auth_token_before_issued(); +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/04_wh_auth_token_insert.up.sql b/internal/db/schema/migrations/oss/postgres/84/04_wh_auth_token_insert.up.sql new file mode 100644 index 000000000..776da6f2a --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/04_wh_auth_token_insert.up.sql @@ -0,0 +1,51 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + create function wh_auth_token_issued(p_auth_token_id wt_public_id, issued_ts wt_timestamp, last_accessed_ts wt_timestamp) returns void + as $$ + declare + new_row wh_auth_token_accumulating_fact%rowtype; + begin + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range + ) + select p_auth_token_id, + wh_upsert_user(p_auth_token_id), + wh_date_key(issued_ts), + wh_time_key(issued_ts), + issued_ts, + wh_date_key(last_accessed_ts), + wh_time_key(last_accessed_ts), + last_accessed_ts, + tstzrange(issued_ts, last_accessed_ts, '[]'), + tstzrange(issued_ts, 'infinity'::wt_timestamp, '[]') + returning * into strict new_row; + return; + end; + $$ language plpgsql; + comment on function wh_auth_token_issued is + 'wh_auth_token_issued is a function called when an auth token is issued to insert a fact into the auth token accumulating fact table.'; + + create function auth_token_inserted() returns trigger + as $$ + begin + if new.status = 'token issued' then + perform wh_auth_token_issued(new.public_id, new.update_time, new.approximate_last_access_time); + end if; + return null; + end; + $$ language plpgsql; + + create trigger auth_token_inserted after insert on auth_token + for each row execute procedure auth_token_inserted(); +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/05_wh_auth_token_update.up.sql b/internal/db/schema/migrations/oss/postgres/84/05_wh_auth_token_update.up.sql new file mode 100644 index 000000000..3186cca4f --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/05_wh_auth_token_update.up.sql @@ -0,0 +1,38 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + create function wh_auth_token_accessed(p_auth_token_id wt_public_id, last_accessed_ts wt_timestamp) returns void + as $$ + begin + update wh_auth_token_accumulating_fact + set auth_token_approximate_last_access_date_key = wh_date_key(last_accessed_ts), + auth_token_approximate_last_access_time_key = wh_time_key(last_accessed_ts), + auth_token_approximate_last_access_time = last_accessed_ts, + auth_token_approximate_active_time_range = tstzrange(lower(auth_token_approximate_active_time_range), last_accessed_ts, '[]') + where auth_token_id = p_auth_token_id; + return; + end; + $$ language plpgsql; + comment on function wh_auth_token_accessed is + 'wh_auth_token_accessed is a function that updates the wh_auth_token_accumulating_fact' + 'when an auth_token is accessed.'; + + create function auth_token_updated() returns trigger + as $$ + begin + case + when new.status = 'token issued' and new.status is distinct from old.status then + perform wh_auth_token_issued(new.public_id, new.update_time, new.approximate_last_access_time); + when new.approximate_last_access_time is distinct from old.approximate_last_access_time then + perform wh_auth_token_accessed(new.public_id, new.approximate_last_access_time); + else + return null; + end case; + return null; + end; + $$ language plpgsql; + + create trigger auth_token_updated after update on auth_token + for each row execute procedure auth_token_updated(); +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/06_wh_auth_token_delete.up.sql b/internal/db/schema/migrations/oss/postgres/84/06_wh_auth_token_delete.up.sql new file mode 100644 index 000000000..8edd19b98 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/06_wh_auth_token_delete.up.sql @@ -0,0 +1,33 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + create function wh_auth_token_deleted(p_auth_token_id wt_public_id) returns void + as $$ + begin + update wh_auth_token_accumulating_fact + set auth_token_deleted_date_key = wh_date_key(now()), + auth_token_deleted_time_key = wh_time_key(now()), + auth_token_deleted_time = now(), + auth_token_valid_time_range = tstzrange(lower(auth_token_valid_time_range), now(), '[]') + where auth_token_id = p_auth_token_id; + return; + end; + $$ language plpgsql; + comment on function wh_auth_token_deleted is + 'wh_auth_token_deleted is a function that updates the wh_auth_token_accumulating_fact' + 'when a previously issued auth_token is deleted.'; + + create function auth_token_deleted() returns trigger + as $$ + begin + if old.status = 'token issued' then + perform wh_auth_token_deleted(old.public_id); + end if; + return null; + end; + $$ language plpgsql; + + create trigger auth_token_deleted after delete on auth_token + for each row execute procedure auth_token_deleted(); +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/07_vault_ssh_cert_default.up.sql b/internal/db/schema/migrations/oss/postgres/84/07_vault_ssh_cert_default.up.sql new file mode 100644 index 000000000..21695b7a9 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/07_vault_ssh_cert_default.up.sql @@ -0,0 +1,23 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + drop trigger default_ssh_certificate_credential_type on credential_vault_ssh_cert_library; + drop function default_ssh_certificate_credential_type; + + -- Replaces trigger in 63/01_credential_vault_ssh_cert_library.up.sql + create function default_ssh_certificate_credential_type() returns trigger + as $$ + begin + if new.credential_type is distinct from 'ssh_certificate' then + new.credential_type = 'ssh_certificate'; + end if; + return new; + end; + $$ language plpgsql; + comment on function default_ssh_certificate_credential_type is + 'default_ssh_certificate_credential_type ensures the credential_type is set to ssh_certificate'; + + create trigger default_ssh_certificate_credential_type before insert on credential_vault_ssh_cert_library + for each row execute procedure default_ssh_certificate_credential_type(); +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/08_hcp_user_billing_view.up.sql b/internal/db/schema/migrations/oss/postgres/84/08_hcp_user_billing_view.up.sql new file mode 100644 index 000000000..656677aed --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/08_hcp_user_billing_view.up.sql @@ -0,0 +1,121 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + create view hcp_billing_monthly_active_users_last_2_months as + with + monthly_range (month) as ( + select date_trunc('month', time) as month + from generate_series( + date_trunc('month', now() - interval '1 month'), + now(), + '1 month'::interval + ) as time + ), + final (start_time, end_time, active_users_count) as ( + select monthly_range.month, -- start + case when monthly_range.month = date_trunc('month', now()) + then date_trunc('hour', now()) + else monthly_range.month + interval '1 month' + end, + count(distinct(user_id)) + from monthly_range + left join wh_auth_token_accumulating_fact as wh_ataf + on wh_ataf.auth_token_approximate_active_time_range && tstzrange(monthly_range.month, monthly_range.month + interval '1 month', '[)') + group by monthly_range.month + ) + select start_time, end_time, active_users_count + from final + order by start_time desc; + comment on view hcp_billing_monthly_active_users_last_2_months is + 'hcp_billing_monthly_active_users_last_2_months is a view that contains ' + 'the count of active users for the current month and the previous month. ' + 'The current month is a sum from the beginning of the current month ' + 'until the start of the current hour (exclusive).'; + + create view hcp_billing_monthly_active_users_all as + with + monthly_range (month) as ( + select date_trunc('month', time) as month + from generate_series( + date_trunc('month', (select min(auth_token_issued_time) + from wh_auth_token_accumulating_fact)), + now(), + '1 month'::interval + ) as time + ), + final (start_time, end_time, active_users_count) as ( + select monthly_range.month, -- start + case when monthly_range.month = date_trunc('month', now()) + then date_trunc('hour', now()) + else monthly_range.month + interval '1 month' + end, + count(distinct(user_id)) + from monthly_range + left join wh_auth_token_accumulating_fact as wh_ataf + on wh_ataf.auth_token_approximate_active_time_range && tstzrange(monthly_range.month, monthly_range.month + interval '1 month', '[)') + group by monthly_range.month + ) + select start_time, end_time, active_users_count + from final + order by start_time desc; + comment on view hcp_billing_monthly_active_users_all is + 'hcp_billing_monthly_active_users_all is a view that contains ' + 'the count of active users for the all months.' + 'The current month is a sum from the beginning of the current month ' + 'until the start of the current hour (exclusive).'; + + create function hcp_billing_monthly_active_users_last_2_months() + returns table(start_time timestamp with time zone, end_time timestamp with time zone, active_user_count bigint) + as $$ + select * + from hcp_billing_monthly_active_users_last_2_months; + $$ language sql + immutable + parallel safe -- all of the functions called are parallel safe + strict -- means the function returns null on null input + set timezone to 'utc'; + comment on function hcp_billing_monthly_active_users_last_2_months is + 'hcp_billing_monthly_active_users_last_2_months is a function that contains ' + 'the count of active users for the current month and the previous month. ' + 'The current month is a sum from the beginning of the current month ' + 'until the start of the current hour (exclusive).' + 'All timestamps returned are in UTC.'; + + create function hcp_billing_monthly_active_users_all(p_start_time timestamptz default null, + p_end_time timestamptz default null) + returns setof hcp_billing_monthly_active_users_all + as $$ + begin + case + when p_start_time is not null and p_end_time is not null then + return query select * + from hcp_billing_monthly_active_users_all + where start_time >= p_start_time + and end_time <= p_end_time; + when p_start_time is not null then + return query select * + from hcp_billing_monthly_active_users_all + where start_time >= p_start_time; + when p_end_time is not null then + return query select * + from hcp_billing_monthly_active_users_all + where end_time <= p_end_time; + else + return query select * + from hcp_billing_monthly_active_users_all; + end case; + return; + end; + $$ language plpgsql + immutable + parallel safe -- all of the functions called are parallel safe + set timezone to 'utc'; + comment on function hcp_billing_monthly_active_users_all is + 'hcp_billing_monthly_active_users_all is a function that contains ' + 'the count of active users for the all months.' + 'The current month is a sum from the beginning of the current month ' + 'until the start of the current hour (exclusive).' + 'It can be provided with a start time and end time to restrict the results.' + 'All timestamps returned are in UTC.'; +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/09_cenus_add_metric.up.sql b/internal/db/schema/migrations/oss/postgres/84/09_cenus_add_metric.up.sql new file mode 100644 index 000000000..5b1ce7b77 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/09_cenus_add_metric.up.sql @@ -0,0 +1,47 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + -- Create enum table for valid cenus metrics. + create table census_metric_enm ( + name text primary key + constraint only_predefined_census_metrics_allowed + check ( + name in ( + 'sessions', + 'active_users' + ) + ) + ); + comment on table census_metric_enm is + 'census_metric_enm is an enumeration table for census metric types.'; + + insert into census_metric_enm + (name) + values ('sessions'), + ('active_users'); + + -- Drop constraint and index from 72/01_census.up.sql + drop index census_last_uploaded_one_row; + alter table census_last_uploaded drop constraint census_last_uploaded_pkey; + + -- Add new column and populate the default value. + alter table census_last_uploaded + add column metric text + default 'sessions' + not null + primary key + constraint census_metric_enm_fkey + references census_metric_enm (name) + on delete restrict + on update restrict; + alter table census_last_uploaded alter column metric drop default; + comment on table census_last_uploaded is + 'census_last_uploaded is a table which contains the timestamp ' + 'of the last time census data was uploaded, per census metric.'; + + -- Add new row for active_users. + insert into census_last_uploaded + (last_uploaded_at, metric) + values ('-infinity'::timestamptz, 'active_users'); +commit; diff --git a/internal/db/sqltest/Makefile b/internal/db/sqltest/Makefile index a5bdba6a2..01779bdcb 100644 --- a/internal/db/sqltest/Makefile +++ b/internal/db/sqltest/Makefile @@ -12,7 +12,6 @@ ifneq ($(strip $(SQL_TEST_DB_PORT)),) DOCKER_ARGS += -p $(SQL_TEST_DB_PORT):5432 endif - # Pass through options to pg_prove # See: https://pgtap.org/pg_prove.html PROVE_OPTS ?= @@ -47,6 +46,11 @@ PG_TAP_DOCKER_IMAGE := $(PG_TAP_DOCKER_IMAGE_BASE):$(PG_TAP_DOCKER_TAG) SQL_TEST_CONTAINER_NAME ?= boundary-sql-tests +log_cmd = true +DB_LOGS ?= 0 +ifeq ($(DB_LOGS), 1) + log_cmd = docker logs $(SQL_TEST_CONTAINER_NAME) +endif # re-write paths for docker dockerized_tests = $(patsubst tests/%,/test/%,$(TESTS)) @@ -74,7 +78,7 @@ test: -e TESTS="$(PROVE_OPTS) $(dockerized_tests)" \ -v "$(CWD)/tests":/test \ $(PG_TAP_DOCKER_IMAGE); \ - (ret=$$?; docker stop $(SQL_TEST_CONTAINER_NAME) &>/dev/null && docker logs $(SQL_TEST_CONTAINER_NAME) && docker rm -v $(SQL_TEST_CONTAINER_NAME) &>/dev/null && exit $$ret) + (ret=$$?; docker stop $(SQL_TEST_CONTAINER_NAME) > /dev/null 2>&1 && $(log_cmd) && docker rm -f -v $(SQL_TEST_CONTAINER_NAME) > /dev/null 2>&1 && exit $$ret) database-up: @echo Using $(POSTGRES_DOCKER_IMAGE) diff --git a/internal/db/sqltest/initdb.d/01_colors_persona.sql b/internal/db/sqltest/initdb.d/01_colors_persona.sql index 1c5484aee..af32a7c83 100644 --- a/internal/db/sqltest/initdb.d/01_colors_persona.sql +++ b/internal/db/sqltest/initdb.d/01_colors_persona.sql @@ -199,13 +199,13 @@ begin; ('p____gcolors', 'hc__st_____g', 'Green Color Static Catalog'); insert into auth_token - (key_id, auth_account_id, public_id, token) + (key_id, auth_account_id, public_id, token, expiration_time, status) values - ('kdkv__colors', 'apa____clare', 'tok____clare', 'tok____clare'::bytea), - ('kdkv__colors', 'apa____cindy', 'tok____cindy', 'tok____cindy'::bytea), - ('kdkv__colors', 'apa____ciara', 'tok____ciara', 'tok____ciara'::bytea), - ('kdkv__colors', 'apa____carly', 'tok____carly', 'tok____carly'::bytea), - ('kdkv__colors', 'apa_____cora', 'tok_____cora', 'tok_____cora'::bytea); + ('kdkv__colors', 'apa____clare', 'tok____clare', 'tok____clare'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv__colors', 'apa____cindy', 'tok____cindy', 'tok____cindy'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv__colors', 'apa____ciara', 'tok____ciara', 'tok____ciara'::bytea, now() + interval '15 days', 'auth token pending'), + ('kdkv__colors', 'apa____carly', 'tok____carly', 'tok____carly'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv__colors', 'apa_____cora', 'tok_____cora', 'tok_____cora'::bytea, now() + interval '15 days', 'auth token pending'); insert into static_host (catalog_id, public_id, address) diff --git a/internal/db/sqltest/initdb.d/03_widgets_persona.sql b/internal/db/sqltest/initdb.d/03_widgets_persona.sql index f151a962b..a4c4c14c2 100644 --- a/internal/db/sqltest/initdb.d/03_widgets_persona.sql +++ b/internal/db/sqltest/initdb.d/03_widgets_persona.sql @@ -133,7 +133,7 @@ begin; (key_id, table_name, total_count) values ('kdkv___widget', 'auth_token', 100); - + end; $$ language plpgsql; @@ -172,13 +172,13 @@ begin; update auth_account set iam_user_id = 'u_____wilson' where public_id = 'apa___wilson'; insert into auth_token - (key_id, auth_account_id, public_id, token) + (key_id, auth_account_id, public_id, token, expiration_time, status) values - ('kdkv___widget', 'apa___walter', 'tok___walter', 'tok___walter'::bytea), - ('kdkv___widget', 'apa1__walter', 'tok1__walter', 'tok1__walter'::bytea), - ('kdkv___widget', 'apa___warren', 'tok___warren', 'tok___warren'::bytea), - ('kdkv___widget', 'apa___waylon', 'tok___waylon', 'tok___waylon'::bytea), - ('kdkv___widget', 'apa___wilson', 'tok___wilson', 'tok___wilson'::bytea); + ('kdkv___widget', 'apa___walter', 'tok___walter', 'tok___walter'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv___widget', 'apa1__walter', 'tok1__walter', 'tok1__walter'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv___widget', 'apa___warren', 'tok___warren', 'tok___warren'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv___widget', 'apa___waylon', 'tok___waylon', 'tok___waylon'::bytea, now() + interval '15 days', 'token issued'), + ('kdkv___widget', 'apa___wilson', 'tok___wilson', 'tok___wilson'::bytea, now() + interval '15 days', 'auth token pending'); insert into auth_oidc_method (scope_id, public_id, client_id, name, state, key_id, issuer) @@ -206,7 +206,7 @@ begin; ('o_____widget', 'alm___widget', 'Widget LDAP', 'active-private'); insert into auth_ldap_url (ldap_method_id, url, connection_priority) - values + values ('alm___widget', 'ldaps://ldap1', 1); insert into auth_ldap_account diff --git a/internal/db/sqltest/tests/census/last_uploaded.sql b/internal/db/sqltest/tests/census/last_uploaded.sql index 79a8c4a02..7f7eaff3b 100644 --- a/internal/db/sqltest/tests/census/last_uploaded.sql +++ b/internal/db/sqltest/tests/census/last_uploaded.sql @@ -3,28 +3,55 @@ begin; - select plan(6); + select plan(7); select has_table('census_last_uploaded'); - select is(count(*), 1::bigint, 'census_last_uploaded should have only 1 row') from census_last_uploaded; - select ok(not isfinite(last_uploaded_at)) from census_last_uploaded; + select is(count(*), 2::bigint, 'census_last_uploaded should have only 2 row') from census_last_uploaded; + select results_eq( + $$ + select last_uploaded_at::timestamptz, metric + from census_last_uploaded + $$, + $$ + values ('-infinity'::timestamptz, 'sessions'), + ('-infinity'::timestamptz, 'active_users') + $$); + prepare insert_row as insert into census_last_uploaded - (last_uploaded_at) - values - (now()); + (last_uploaded_at, metric) + values (now(), 'sessions'); - select throws_ok('insert_row', '23505', - 'duplicate key value violates unique constraint "census_last_uploaded_one_row"', - 'insert into census_last_uploaded should fail'); + select throws_ok('insert_row', + '23505', + 'duplicate key value violates unique constraint "census_last_uploaded_pkey"', + 'insert into census_last_uploaded should fail'); + + prepare insert_row_invalid_metric as + insert into census_last_uploaded + (last_uploaded_at, metric) + values (now(), 'foo'); + + select throws_ok('insert_row_invalid_metric', + '23503', + 'insert or update on table "census_last_uploaded" violates foreign key constraint "census_metric_enm_fkey"', + 'insert into census_last_uploaded should fail'); prepare update_census_last_uploaded as update census_last_uploaded set last_uploaded_at = now(); select lives_ok('update_census_last_uploaded'); - select ok(isfinite(last_uploaded_at)) from census_last_uploaded; + select results_eq( + $$ + select last_uploaded_at::timestamptz, metric + from census_last_uploaded + $$, + $$ + values (now(), 'sessions'), + (now(), 'active_users') + $$); select * from finish(); diff --git a/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql b/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql index 42aea4642..011c7c153 100644 --- a/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql +++ b/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql @@ -42,22 +42,22 @@ begin; select is(count(*), 0::bigint) from credential_vault_library_ssh_private_key_mapping_override where library_id = 'vl______wvl8'; select is(count(*), 0::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl8'; - prepare insert_credential_vault_library_ssh_private_key_mapping_override as + prepare insert_cvl_ssh_private_key_mapping_override as insert into credential_vault_library_ssh_private_key_mapping_override (library_id, username_attribute, private_key_attribute) values ('vl______wvl8', 'my_username', 'my_private_key'); - select lives_ok('insert_credential_vault_library_ssh_private_key_mapping_override'); + select lives_ok('insert_cvl_ssh_private_key_mapping_override'); select is(count(*), 1::bigint) from credential_vault_library_ssh_private_key_mapping_override where library_id = 'vl______wvl8'; select is(count(*), 1::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl8'; -- validate the delete triggers - prepare delete_credential_vault_library_ssh_private_key_mapping_override as + prepare delete_cvl_ssh_private_key_mapping_override as delete from credential_vault_library_ssh_private_key_mapping_override where library_id = 'vl______wvl8'; - select lives_ok('delete_credential_vault_library_ssh_private_key_mapping_override'); + select lives_ok('delete_cvl_ssh_private_key_mapping_override'); select is(count(*), 0::bigint) from credential_vault_library_ssh_private_key_mapping_override where library_id = 'vl______wvl8'; select is(count(*), 0::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl8'; diff --git a/internal/db/sqltest/tests/credential/vault/credential_vault_library_username_password_mapping_override.sql b/internal/db/sqltest/tests/credential/vault/credential_vault_library_username_password_mapping_override.sql index e7389d1e3..92ccd468c 100644 --- a/internal/db/sqltest/tests/credential/vault/credential_vault_library_username_password_mapping_override.sql +++ b/internal/db/sqltest/tests/credential/vault/credential_vault_library_username_password_mapping_override.sql @@ -42,22 +42,22 @@ begin; select is(count(*), 0::bigint) from credential_vault_library_username_password_mapping_override where library_id = 'vl______wvl3'; select is(count(*), 0::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl3'; - prepare insert_credential_vault_library_username_password_mapping_override as + prepare insert_cvl_username_password_mapping_override as insert into credential_vault_library_username_password_mapping_override (library_id, username_attribute, password_attribute) values ('vl______wvl3', 'my_username', 'my_password'); - select lives_ok('insert_credential_vault_library_username_password_mapping_override'); + select lives_ok('insert_cvl_username_password_mapping_override'); select is(count(*), 1::bigint) from credential_vault_library_username_password_mapping_override where library_id = 'vl______wvl3'; select is(count(*), 1::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl3'; -- validate the delete triggers - prepare delete_credential_vault_library_username_password_mapping_override as + prepare delete_cvl_username_password_mapping_override as delete from credential_vault_library_username_password_mapping_override where library_id = 'vl______wvl3'; - select lives_ok('delete_credential_vault_library_username_password_mapping_override'); + select lives_ok('delete_cvl_username_password_mapping_override'); select is(count(*), 0::bigint) from credential_vault_library_username_password_mapping_override where library_id = 'vl______wvl3'; select is(count(*), 0::bigint) from credential_vault_library_mapping_override where library_id = 'vl______wvl3'; diff --git a/internal/db/sqltest/tests/domain/wh_user_id.sql b/internal/db/sqltest/tests/domain/wh_user_id.sql new file mode 100644 index 000000000..144c91854 --- /dev/null +++ b/internal/db/sqltest/tests/domain/wh_user_id.sql @@ -0,0 +1,37 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(3); + + create table test_wh_user_id ( + id wh_user_id + ); + + prepare too_short as + insert into test_wh_user_id (id) + values ('short'); + select throws_ok( + 'too_short', + '23514', + 'value for domain wh_user_id violates check constraint "wh_user_id_check"' + ); + prepare too_short_trim as + insert into test_wh_user_id (id) + values (' short '); + select throws_ok( + 'too_short_trim', + '23514', + 'value for domain wh_user_id violates check constraint "wh_user_id_check"' + ); + + prepare valid as + insert into test_wh_user_id (id) + values ('u_123456789'), + ('u_anon'), + ('u_auth'), + ('u_recovery'); + select lives_ok('valid'); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/hcp/billing/monthly_active_users.sql b/internal/db/sqltest/tests/hcp/billing/monthly_active_users.sql new file mode 100644 index 000000000..761369235 --- /dev/null +++ b/internal/db/sqltest/tests/hcp/billing/monthly_active_users.sql @@ -0,0 +1,470 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(39); + + select lives_ok('truncate wh_auth_token_accumulating_fact, + wh_user_dimension, + wh_session_accumulating_fact, + wh_session_connection_accumulating_fact;', + 'Truncate tables in preparation for testing'); + + -- validate the warehouse fact tables are empty + select is(count(*), 0::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 0::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + + -- Create a user dimension for corresponding auth token's user. + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email + ) values ( + 'u_____user1', 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + ); + + -- validate view returns 0 active users for last two months + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 0::bigint) + $$); + + + -- Now insert some edge cases to make sure we count them correctly + + -- active_time_range spans start of a month + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + select tstzrange(lower(previous) - interval '5 minutes', lower(previous) + interval '5 minutes', '[]'), + tstzrange(lower(previous) - interval '5 minutes', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range within start/end of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + select tstzrange(lower(previous) + interval '5 minutes', lower(previous) + interval '10 minutes', '[]'), + tstzrange(lower(previous) + interval '5 minutes', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range spances end of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + select tstzrange(upper(previous) - interval '5 minutes', upper(previous) + interval '5 minutes', '[]'), + tstzrange(upper(previous) - interval '5 minutes', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 1::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range starts on start of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + select tstzrange(lower(previous), lower(previous) + interval '5 minutes', '[]'), + tstzrange(lower(previous), 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range ends on end of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + -- the resolution on a timestamptz is 1 microsecond, so subtracting one microsecond from the upper bound of + -- the month range puts us as close to the "end of month" as possible, wthout going over. + -- https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME + select tstzrange(lower(previous) + interval '5 minutes', upper(previous) - interval '1 microsecond', '[]'), + tstzrange(lower(previous) + interval '5 minutes', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range starts on end of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + -- the resolution on a timestamptz is 1 microsecond, so subtracting one microsecond from the upper bound of + -- the month range puts us as close to the "end of month" as possible, wthout going over. + -- https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME + select tstzrange(upper(previous) - interval '1 microsecond', upper(previous) + '5 minutes', '[]'), + tstzrange(upper(previous) - interval '1 microsecond', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 1::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- active_time_range ends on start of month + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with month_range (previous) as ( + select tstzrange(date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), '[)') + ), + range (active, valid) as ( + select tstzrange(upper(previous) - interval '5 minutes', upper(previous), '[]'), + tstzrange(upper(previous) - interval '5 minutes', 'infinity'::timestamptz) + from month_range + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'at____atok1', user_key.key, + wh_date_key(lower(range.valid)), wh_time_key(lower(range.valid)), lower(range.valid), + coalesce(wh_date_key(upper(range.valid)), -1), coalesce(wh_time_key(upper(range.valid)), -1), upper(range.valid), + wh_date_key(upper(range.active)), wh_time_key(upper(range.active)), upper(range.active), + range.active, + range.valid, + 1 + from range, user_key; + + select is(count(*), 1::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 1::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- multiple tokens for a user within month count as single user + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with token (public_id, active) as ( + select * + from (values + ('at____atok1', tstzrange(date_trunc('month', now() - interval '1 month'), + date_trunc('month', now() - interval '1 month') + interval '5 minutes')), + ('at____atok2', tstzrange(date_trunc('month', now() - interval '1 month') + interval '10 minutes', + date_trunc('month', now() - interval '1 month') + interval '15 minutes')) + ) as t (public_id, active) + ), + user_key (key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select token.public_id, user_key.key, + wh_date_key(lower(token.active)), wh_time_key(lower(token.active)), lower(token.active), + coalesce(wh_date_key(upper(token.active)), -1), coalesce(wh_time_key(upper(token.active)), -1), upper(token.active), + wh_date_key(upper(token.active)), wh_time_key(upper(token.active)), upper(token.active), + token.active, + token.active, + 1 + from token, user_key; + + select is(count(*), 2::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 1::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 1::bigint) + $$); + + -- multiple tokens for multiple users should get counted correctly + -- Create a user dimension for corresponding auth token's user. + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email + ) values ( + 'u_____user2', 'None', 'None', + 'a______acc2', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + ),( + 'u_____user3', 'None', 'None', + 'a______acc3', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + ); + select lives_ok('truncate wh_auth_token_accumulating_fact'); + with token (user_id, public_id, active) as ( + select * + from (values + ('u_____user1', 'at___u1tok1', tstzrange(date_trunc('month', now() - interval '1 month'), + date_trunc('month', now() - interval '1 month') + interval '5 minutes')), + ('u_____user1', 'at___u1tok2', tstzrange(date_trunc('month', now() - interval '1 month') + interval '10 minutes', + date_trunc('month', now() - interval '1 month') + interval '15 minutes')), + ('u_____user2', 'at___u2tok1', tstzrange(date_trunc('month', now() - interval '1 month'), + date_trunc('month', now() - interval '1 month') + interval '5 minutes')), + ('u_____user2', 'at___u2tok2', tstzrange(date_trunc('month', now() - interval '1 month') + interval '10 minutes', + date_trunc('month', now() - interval '1 month') + interval '15 minutes')), + ('u_____user3', 'at___u3tok1', tstzrange(date_trunc('month', now() - interval '1 month'), + date_trunc('month', now() - interval '1 month') + interval '5 minutes')), + ('u_____user3', 'at___u3tok2', tstzrange(date_trunc('month', now() - interval '1 month') + interval '10 minutes', + date_trunc('month', now() - interval '1 month') + interval '15 minutes')), + ('u_____user3', 'at___u3tok3', tstzrange(date_trunc('month', now() - interval '1 month') + interval '10 minutes', + date_trunc('month', now() - interval '1 month') + interval '15 minutes')) + ) as t (user_id, public_id, active) + ), + token_key (user_key, public_id, active) as ( + select whud.key, token.public_id, token.active + from wh_user_dimension as whud + join token + on whud.user_id = token.user_id + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select token_key.public_id, token_key.user_key, + wh_date_key(lower(token_key.active)), wh_time_key(lower(token_key.active)), lower(token_key.active), + coalesce(wh_date_key(upper(token_key.active)), -1), coalesce(wh_time_key(upper(token_key.active)), -1), upper(token_key.active), + wh_date_key(upper(token_key.active)), wh_time_key(upper(token_key.active)), upper(token_key.active), + token_key.active, + token_key.active, + 1 + from token_key; + + select is(count(*), 7::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 3::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_last_2_months', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 3::bigint) + $$); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all.sql b/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all.sql new file mode 100644 index 000000000..87fdbe1bf --- /dev/null +++ b/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all.sql @@ -0,0 +1,100 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(4); + select lives_ok('truncate wh_auth_token_accumulating_fact, + wh_user_dimension, + wh_session_accumulating_fact, + wh_session_connection_accumulating_fact;', + 'Truncate tables in preparation for testing'); + + with + month_range (date_key, time_key, month) as ( + select wh_date_key(s), wh_time_key(s), s + from generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + users (user_id, u) as ( + select 'u_____user'||u, u + from generate_series(1, 6, 1) as u + ), + user_key (key, user_id) as ( + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email) + select users.user_id, 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + from users + returning key, user_id + ), + tokens (date_key, time_key, user_id, token_id) as ( + select wh_date_key(s), wh_time_key(s), users.user_id, 't_____u'||users.u||'tok'||s as token_id + from users, + generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + tokens_user_keys (date_key, time_key, user_id, token_id, user_key) as ( + select tokens.date_key, tokens.time_key, tokens.user_id, tokens.token_id, user_key.key + from tokens + join user_key + on user_key.user_id = tokens.user_id + ), + auth_tokens (user_key, user_id, token_id, valid_range) as ( + select tokens_user_keys.user_key, tokens_user_keys.user_id, tokens_user_keys.token_id, tstzrange(month_range.month, month_range.month + interval '5 minutes', '[)') + from tokens_user_keys + join month_range + on month_range.date_key = tokens_user_keys.date_key + and month_range.time_key = tokens_user_keys.time_key + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select auth_tokens.token_id, auth_tokens.user_key, + wh_date_key(lower(auth_tokens.valid_range)), wh_time_key(lower(auth_tokens.valid_range)), lower(auth_tokens.valid_range), + coalesce(wh_date_key(upper(auth_tokens.valid_range)), -1), coalesce(wh_time_key(upper(auth_tokens.valid_range)), -1), upper(auth_tokens.valid_range), + wh_date_key(upper(auth_tokens.valid_range)), wh_time_key(upper(auth_tokens.valid_range)), upper(auth_tokens.valid_range), + auth_tokens.valid_range, + auth_tokens.valid_range, + 1 + from auth_tokens; + select is(count(*), 72::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 6::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + select results_eq( + 'select * from hcp_billing_monthly_active_users_all', + $$ + values (date_trunc('month', now()), date_trunc('hour', now()), 0::bigint), + (date_trunc('month', now() - interval '1 month'), date_trunc('month', now()), 6::bigint), + (date_trunc('month', now() - interval '2 month'), date_trunc('month', now() - interval '1 month'), 6::bigint), + (date_trunc('month', now() - interval '3 month'), date_trunc('month', now() - interval '2 month'), 6::bigint), + (date_trunc('month', now() - interval '4 month'), date_trunc('month', now() - interval '3 month'), 6::bigint), + (date_trunc('month', now() - interval '5 month'), date_trunc('month', now() - interval '4 month'), 6::bigint), + (date_trunc('month', now() - interval '6 month'), date_trunc('month', now() - interval '5 month'), 6::bigint), + (date_trunc('month', now() - interval '7 month'), date_trunc('month', now() - interval '6 month'), 6::bigint), + (date_trunc('month', now() - interval '8 month'), date_trunc('month', now() - interval '7 month'), 6::bigint), + (date_trunc('month', now() - interval '9 month'), date_trunc('month', now() - interval '8 month'), 6::bigint), + (date_trunc('month', now() - interval '10 month'), date_trunc('month', now() - interval '9 month'), 6::bigint), + (date_trunc('month', now() - interval '11 month'), date_trunc('month', now() - interval '10 month'), 6::bigint), + (date_trunc('month', now() - interval '12 month'), date_trunc('month', now() - interval '11 month'), 6::bigint) + $$); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all_timezone.sql b/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all_timezone.sql new file mode 100644 index 000000000..c6828a98a --- /dev/null +++ b/internal/db/sqltest/tests/hcp/billing/monthly_active_users_all_timezone.sql @@ -0,0 +1,179 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(11); + select lives_ok('truncate wh_auth_token_accumulating_fact, + wh_user_dimension, + wh_session_accumulating_fact, + wh_session_connection_accumulating_fact;', + 'Truncate tables in preparation for testing'); + + -- generate auth tokens for users simulating the server being configured to use a different time zone. + set time zone 'NZ'; + with + month_range (date_key, time_key, month) as ( + select wh_date_key(s), wh_time_key(s), s + from generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + users (user_id, u) as ( + select 'u_____user'||u, u + from generate_series(1, 6, 1) as u + ), + user_key (key, user_id) as ( + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email) + select users.user_id, 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + from users + returning key, user_id + ), + tokens (date_key, time_key, user_id, token_id) as ( + select wh_date_key(s), wh_time_key(s), users.user_id, 't_____u'||users.u||'tok'||s as token_id + from users, + generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + tokens_user_keys (date_key, time_key, user_id, token_id, user_key) as ( + select tokens.date_key, tokens.time_key, tokens.user_id, tokens.token_id, user_key.key + from tokens + join user_key + on user_key.user_id = tokens.user_id + ), + auth_tokens (user_key, user_id, token_id, valid_range) as ( + select tokens_user_keys.user_key, tokens_user_keys.user_id, tokens_user_keys.token_id, tstzrange(month_range.month, month_range.month + interval '5 minutes', '[)') + from tokens_user_keys + join month_range + on month_range.date_key = tokens_user_keys.date_key + and month_range.time_key = tokens_user_keys.time_key + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select auth_tokens.token_id, auth_tokens.user_key, + wh_date_key(lower(auth_tokens.valid_range)), wh_time_key(lower(auth_tokens.valid_range)), lower(auth_tokens.valid_range), + coalesce(wh_date_key(upper(auth_tokens.valid_range)), -1), coalesce(wh_time_key(upper(auth_tokens.valid_range)), -1), upper(auth_tokens.valid_range), + wh_date_key(upper(auth_tokens.valid_range)), wh_time_key(upper(auth_tokens.valid_range)), upper(auth_tokens.valid_range), + auth_tokens.valid_range, + auth_tokens.valid_range, + 1 + from auth_tokens; + select is(count(*), 72::bigint, 'wh_auth_token_accumulating_fact is not empty') from wh_auth_token_accumulating_fact; + select is(count(*), 6::bigint, 'wh_user_dimension is not empty') from wh_user_dimension; + + -- calling the view directly yields results at NZ month boundaries. + select results_eq( + 'select * from hcp_billing_monthly_active_users_all', + $$ + values (date_trunc('month', now(), 'nz'), date_trunc('hour', now(), 'nz'), 0::bigint), + (date_trunc('month', now() - interval '1 month', 'nz'), date_trunc('month', now(), 'nz'), 6::bigint), + (date_trunc('month', now() - interval '2 month', 'nz'), date_trunc('month', now() - interval '1 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '3 month', 'nz'), date_trunc('month', now() - interval '2 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '4 month', 'nz'), date_trunc('month', now() - interval '3 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '5 month', 'nz'), date_trunc('month', now() - interval '4 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '6 month', 'nz'), date_trunc('month', now() - interval '5 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '7 month', 'nz'), date_trunc('month', now() - interval '6 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '8 month', 'nz'), date_trunc('month', now() - interval '7 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '9 month', 'nz'), date_trunc('month', now() - interval '8 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '10 month', 'nz'), date_trunc('month', now() - interval '9 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '11 month', 'nz'), date_trunc('month', now() - interval '10 month', 'nz'), 6::bigint), + (date_trunc('month', now() - interval '12 month', 'nz'), date_trunc('month', now() - interval '11 month', 'nz'), 6::bigint) + $$); + + -- calling the function yields results at UTC month boundaries. + select results_eq( + 'select count(*) from hcp_billing_monthly_active_users_all()', + $$ + values (14::bigint); + $$); + select results_eq( + 'select * from hcp_billing_monthly_active_users_all()', + $$ + values (date_trunc('month', now(), 'utc'), date_trunc('hour', now(), 'utc'), 0::bigint), + (date_trunc('month', now() - interval '1 month', 'utc'), date_trunc('month', now(), 'utc'), 0::bigint), + (date_trunc('month', now() - interval '2 month', 'utc'), date_trunc('month', now() - interval '1 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '3 month', 'utc'), date_trunc('month', now() - interval '2 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '4 month', 'utc'), date_trunc('month', now() - interval '3 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '5 month', 'utc'), date_trunc('month', now() - interval '4 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '6 month', 'utc'), date_trunc('month', now() - interval '5 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '7 month', 'utc'), date_trunc('month', now() - interval '6 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '8 month', 'utc'), date_trunc('month', now() - interval '7 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '9 month', 'utc'), date_trunc('month', now() - interval '8 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '10 month', 'utc'), date_trunc('month', now() - interval '9 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '11 month', 'utc'), date_trunc('month', now() - interval '10 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '12 month', 'utc'), date_trunc('month', now() - interval '11 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '13 month', 'utc'), date_trunc('month', now() - interval '12 month', 'utc'), 6::bigint) + $$); + select results_ne( + 'select * from hcp_billing_monthly_active_users_all()', + 'select * from hcp_billing_monthly_active_users_all'); + -- can provide a start time (inclusive) to limit the results. + select results_eq( + $$ + select * from hcp_billing_monthly_active_users_all(date_trunc('month', now() - interval '5 month', 'utc')); + $$, + $$ + values (date_trunc('month', now(), 'utc'), date_trunc('hour', now(), 'utc'), 0::bigint), + (date_trunc('month', now() - interval '1 month', 'utc'), date_trunc('month', now(), 'utc'), 0::bigint), + (date_trunc('month', now() - interval '2 month', 'utc'), date_trunc('month', now() - interval '1 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '3 month', 'utc'), date_trunc('month', now() - interval '2 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '4 month', 'utc'), date_trunc('month', now() - interval '3 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '5 month', 'utc'), date_trunc('month', now() - interval '4 month', 'utc'), 6::bigint) + $$); + -- can provide a start time (inclusive) and end time (exclusive) to limit results. + select results_eq( + $$ + select * from hcp_billing_monthly_active_users_all(date_trunc('month', now() - interval '5 month', 'utc'), + date_trunc('month', now() - interval '3 month', 'utc')); + $$, + $$ + values (date_trunc('month', now() - interval '4 month', 'utc'), date_trunc('month', now() - interval '3 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '5 month', 'utc'), date_trunc('month', now() - interval '4 month', 'utc'), 6::bigint) + $$); + -- can provide an end time (exclusive) to limit results. + select results_eq( + $$ + select * from hcp_billing_monthly_active_users_all(null, + date_trunc('month', now() - interval '3 month', 'utc')); + $$, + $$ + values (date_trunc('month', now() - interval '4 month', 'utc'), date_trunc('month', now() - interval '3 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '5 month', 'utc'), date_trunc('month', now() - interval '4 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '6 month', 'utc'), date_trunc('month', now() - interval '5 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '7 month', 'utc'), date_trunc('month', now() - interval '6 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '8 month', 'utc'), date_trunc('month', now() - interval '7 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '9 month', 'utc'), date_trunc('month', now() - interval '8 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '10 month', 'utc'), date_trunc('month', now() - interval '9 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '11 month', 'utc'), date_trunc('month', now() - interval '10 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '12 month', 'utc'), date_trunc('month', now() - interval '11 month', 'utc'), 6::bigint), + (date_trunc('month', now() - interval '13 month', 'utc'), date_trunc('month', now() - interval '12 month', 'utc'), 6::bigint) + $$); + -- an end time that is before the start time will yield no results. + select is_empty( + $$ + select * from hcp_billing_monthly_active_users_all(date_trunc('month', now() - interval '2 month', 'utc'), + date_trunc('month', now() - interval '5 month', 'utc')); + $$); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/purge/trigger_insert_on_delete.sql b/internal/db/sqltest/tests/purge/trigger_insert_on_delete.sql index b15d839cf..840f1f5e9 100644 --- a/internal/db/sqltest/tests/purge/trigger_insert_on_delete.sql +++ b/internal/db/sqltest/tests/purge/trigger_insert_on_delete.sql @@ -3,7 +3,7 @@ begin; - select plan(13); + select plan(10); -- Verify the trigger functions exist and are declared properly select has_function('insert_deleted_id'); @@ -25,17 +25,6 @@ begin; select is(count(*), 0::bigint) from auth_token where public_id = 'tok____clare'; select is(count(*), 1::bigint) from auth_token_deleted; - -- Add new auth token with duplicate public_id, delete and check that there's no error - insert into auth_token - (key_id, auth_account_id, public_id, token) - values - ('kdkv__colors', 'apa____clare', 'tok____clare', 'tok____clare'::bytea); - prepare delete_again as - delete from auth_token where public_id = 'tok____clare'; - select lives_ok('delete_again'); - select is(count(*), 0::bigint) from auth_token where public_id = 'tok____clare'; - select is(count(*), 1::bigint) from auth_token_deleted; - select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/auth_token/constraints.sql b/internal/db/sqltest/tests/wh/auth_token/constraints.sql new file mode 100644 index 000000000..b61e366cd --- /dev/null +++ b/internal/db/sqltest/tests/wh/auth_token/constraints.sql @@ -0,0 +1,507 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(13); + + -- Create a user dimension for corresponding auth token's user. + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email + ) values ( + 'u_____user1', 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + ); + + + prepare insert_access_time_after_delete as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 11:00:00'::timestamptz), + wh_time_key('2023-12-13 11:00:00'::timestamptz), + '2023-12-13 11:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token1', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_access_time_after_delete', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "last_accessed_time_lte_deleted_time"' + ); + + prepare insert_active_time_after_issued as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 13:00:00'::timestamptz), + wh_time_key('2023-12-13 13:00:00'::timestamptz), + '2023-12-13 13:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token2', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts + interval '10 minutes', accessed_timestamp.ts), + tstzrange(issued_timestamp.ts, deleted_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_active_time_after_issued', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "active_time_lower_eq_issued_time"' + ); + + prepare insert_active_time_before_access as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 13:00:00'::timestamptz), + wh_time_key('2023-12-13 13:00:00'::timestamptz), + '2023-12-13 13:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token2', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts - interval '10 minutes'), + tstzrange(issued_timestamp.ts, deleted_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_active_time_before_access', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "active_time_upper_eq_last_accessed_time"' + ); + + prepare insert_active_not_contained_by_valid as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 11:00:00'::timestamptz), + wh_time_key('2023-12-13 11:00:00'::timestamptz), + '2023-12-13 11:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token1', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + tstzrange(issued_timestamp.ts, deleted_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_active_not_contained_by_valid', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "active_time_contained_by_valid_time"' + ); + + prepare insert_valid_time_before_issued as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 13:00:00'::timestamptz), + wh_time_key('2023-12-13 13:00:00'::timestamptz), + '2023-12-13 13:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token2', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + tstzrange(issued_timestamp.ts - interval '10 minutes', deleted_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_valid_time_before_issued', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "valid_time_lower_eq_issued"' + ); + + prepare insert_valid_time_before_deleted as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 13:00:00'::timestamptz), + wh_time_key('2023-12-13 13:00:00'::timestamptz), + '2023-12-13 13:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token2', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + tstzrange(issued_timestamp.ts, deleted_timestamp.ts - interval '10 minutes'), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select throws_ok( + 'insert_valid_time_before_deleted', + '23514', + 'new row for relation "wh_auth_token_accumulating_fact" violates check constraint "valid_time_upper_eq_deleted"' + ); + + prepare insert_success as + with + issued_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 10:00:00'::timestamptz), + wh_time_key('2023-12-13 10:00:00'::timestamptz), + '2023-12-13 10:00:00'::timestamptz + ), + accessed_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 12:00:00'::timestamptz), + wh_time_key('2023-12-13 12:00:00'::timestamptz), + '2023-12-13 12:00:00'::timestamptz + ), + deleted_timestamp(date_dim_key, time_dim_key, ts) as ( + select wh_date_key('2023-12-13 13:00:00'::timestamptz), + wh_time_key('2023-12-13 13:00:00'::timestamptz), + '2023-12-13 13:00:00'::timestamptz + ), + user_dim(key) as ( + select key + from wh_user_dimension + where user_id = 'u_____user1' + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, + user_key, + auth_token_issued_date_key, + auth_token_issued_time_key, + auth_token_issued_time, + auth_token_deleted_date_key, + auth_token_deleted_time_key, + auth_token_deleted_time, + auth_token_approximate_last_access_date_key, + auth_token_approximate_last_access_time_key, + auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select 'tok___token2', + user_dim.key, + issued_timestamp.date_dim_key, + issued_timestamp.time_dim_key, + issued_timestamp.ts, + deleted_timestamp.date_dim_key, + deleted_timestamp.time_dim_key, + deleted_timestamp.ts, + accessed_timestamp.date_dim_key, + accessed_timestamp.time_dim_key, + accessed_timestamp.ts, + tstzrange(issued_timestamp.ts, accessed_timestamp.ts), + tstzrange(issued_timestamp.ts, deleted_timestamp.ts), + 1 + from user_dim, + issued_timestamp, + deleted_timestamp, + accessed_timestamp; + select lives_ok('insert_success'); + -- ensure user_id was properly set + select is(wh_auth_token_accumulating_fact.user_id, 'u_____user1') + from wh_auth_token_accumulating_fact + where auth_token_id = 'tok___token2'; + + prepare update_user_id as + update wh_auth_token_accumulating_fact set user_id = 'u_____user2' + where auth_token_id = 'tok___token2'; + select throws_ok( + 'update_user_id', + '23601', + 'immutable column: wh_auth_token_accumulating_fact.user_id' + ); + + prepare update_user_key as + update wh_auth_token_accumulating_fact set user_key = 'key_2' + where auth_token_id = 'tok___token2'; + select throws_ok( + 'update_user_key', + '23601', + 'immutable column: wh_auth_token_accumulating_fact.user_key' + ); + + prepare update_auth_token_issued_time as + update wh_auth_token_accumulating_fact set auth_token_issued_time = '2023-12-13 11:01:00'::timestamptz + where auth_token_id = 'tok___token2'; + select throws_ok( + 'update_auth_token_issued_time', + '23601', + 'immutable column: wh_auth_token_accumulating_fact.auth_token_issued_time' + ); + + prepare update_auth_token_issued_time_key as + update wh_auth_token_accumulating_fact set auth_token_issued_time_key = wh_time_key('2023-12-13 11:01:00'::timestamptz) + where auth_token_id = 'tok___token2'; + select throws_ok( + 'update_auth_token_issued_time_key', + '23601', + 'immutable column: wh_auth_token_accumulating_fact.auth_token_issued_time_key' + ); + + prepare update_auth_token_issued_date_key as + update wh_auth_token_accumulating_fact set auth_token_issued_date_key = wh_date_key('2023-12-14 11:00:00'::timestamptz) + where auth_token_id = 'tok___token2'; + select throws_ok( + 'update_auth_token_issued_date_key', + '23601', + 'immutable column: wh_auth_token_accumulating_fact.auth_token_issued_date_key' + ); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/wh/auth_token/delete.sql b/internal/db/sqltest/tests/wh/auth_token/delete.sql new file mode 100644 index 000000000..f289b1a2f --- /dev/null +++ b/internal/db/sqltest/tests/wh/auth_token/delete.sql @@ -0,0 +1,75 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(14); + + -- should have an auth token for carly + select is(count(*), 1::bigint) + from auth_token + where public_id = 'tok____carly'; + + -- should have one fact for carly. + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + -- the fact should not have a deleted time. + select is(auth_token_deleted_date_key, -1) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_deleted_time_key, -1) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_deleted_time, 'infinity'::wh_timestamp) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(upper(auth_token_valid_time_range), 'infinity'::timestamptz) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + + -- Now delete the auth token and confirm the fact was updated properly. + delete + from auth_token + where public_id = 'tok____carly'; + + -- should still have one fact + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + -- times should be updated to have a deleted time. + select is(auth_token_deleted_date_key, wh_date_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_deleted_time_key, wh_time_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_deleted_time, now()::wh_timestamp) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(upper(auth_token_valid_time_range), now()) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + + -- should have an auth token for cora that is in pending status + select is(count(*), 1::bigint) + from auth_token + where public_id = 'tok_____cora' + and status = 'auth token pending'; + + -- since the auth token is pending, there should be no fact for cora. + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_______cora'; + + -- Now delete the pending auth token, this should not result in a fact. + delete + from auth_token + where public_id = 'tok_____cora'; + + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_______cora'; + + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/wh/auth_token/insert.sql b/internal/db/sqltest/tests/wh/auth_token/insert.sql new file mode 100644 index 000000000..2e1959cd2 --- /dev/null +++ b/internal/db/sqltest/tests/wh/auth_token/insert.sql @@ -0,0 +1,81 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(4); + + select wtt_load('widgets', 'iam', 'kms'); + + -- Create a user dimension for corresponding auth token's user. + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email + ) values ( + 'u_____user1', 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + ); + + -- Create auth method and account to use for making auth tokens. + insert into auth_password_conf + (password_method_id, private_id) + values + ('apm___widget', 'apmc__widget'); + insert into auth_password_method + (scope_id, public_id, password_conf_id, name) + values + ('o_____widget', 'apm___widget', 'apmc__widget', 'Widget Auth Password'); + insert into auth_password_account + (auth_method_id, public_id, login_name) + values + ('apm___widget', 'apa___walter', 'walter'); + update auth_account set iam_user_id = 'u_____walter' where public_id = 'apa___walter'; + + -- should start with no facts for this user. + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_____walter'; + + -- Insert with status 'auth token pending' should not create a auth token fact. + insert into auth_token + (key_id, auth_account_id, public_id, token, status) + values + ('kdkv___widget', 'apa___walter', 'tok___walter1', 'tok___walter1'::bytea, 'auth token pending'); + select is(count(*), 0::bigint) from wh_auth_token_accumulating_fact where user_id = 'u_____walter'; + + -- Insert an auth token wuth the 'token issued' status. + insert into auth_token + (key_id, auth_account_id, public_id, token, status) + values + ('kdkv___widget', 'apa___walter', 'tok___walter2', 'tok___walter2'::bytea, 'token issued'); + + -- should have one fact for this user. + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_____walter'; + select is( + wh_auth_token_accumulating_fact.*, + row('tok___walter2', + 'u_____walter', (select key from wh_user_dimension where user_id = 'u_____walter'), + wh_date_key(now()), wh_time_key(now()), now(), + -1, -1, 'infinity'::timestamptz, + wh_date_key(now()), wh_time_key(now()), now(), + tstzrange(now(), now(), '[]'), + tstzrange(now(), 'infinity'::timestamptz, '[]'), + 1 + )::wh_auth_token_accumulating_fact + ) + from wh_auth_token_accumulating_fact + where user_id = 'u_____walter'; + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/wh/auth_token/update.sql b/internal/db/sqltest/tests/wh/auth_token/update.sql new file mode 100644 index 000000000..9741391eb --- /dev/null +++ b/internal/db/sqltest/tests/wh/auth_token/update.sql @@ -0,0 +1,114 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(20); + + -- should have an auth token for carly + select is(count(*), 1::bigint) + from auth_token + where public_id = 'tok____carly'; + + -- should have one fact for carly. + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + -- the fact should have an access time that is less than now. + select cmp_ok(auth_token_approximate_last_access_date_key, '<=', wh_date_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select cmp_ok(auth_token_approximate_last_access_time_key, '<', wh_time_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select cmp_ok(auth_token_approximate_last_access_time, '<', now()::wh_timestamp) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select cmp_ok(lower(auth_token_approximate_active_time_range), '<', now()) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select cmp_ok(upper(auth_token_approximate_active_time_range), '<', now()) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + + -- update access time for carly's auth token. + update auth_token + set approximate_last_access_time = now() + where public_id = 'tok____carly'; + + -- should still only have one fact for carly. + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + -- the fact should have an access time of now + select is(auth_token_approximate_last_access_date_key, wh_date_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_approximate_last_access_time_key, wh_time_key(now())) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(auth_token_approximate_last_access_time, now()::wh_timestamp) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select cmp_ok(lower(auth_token_approximate_active_time_range), '<', now()) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + select is(upper(auth_token_approximate_active_time_range), now()) + from wh_auth_token_accumulating_fact + where user_id = 'u______carly'; + + -- should have an auth token for cora that is in pending status + select is(count(*), 1::bigint) + from auth_token + where public_id = 'tok_____cora' + and status = 'auth token pending'; + + -- since the auth token is pending, there should be no fact for cora. + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_______cora'; + + -- updating a pending token to issued should result in a fact for cora. + update auth_token + set status = 'token issued' + where public_id = 'tok_____cora'; + + select is(count(*), 1::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u_______cora'; + select is( + wh_auth_token_accumulating_fact.*, + row('tok_____cora', + 'u_______cora', (select key from wh_user_dimension where user_id = 'u_______cora'), + wh_date_key(now()), wh_time_key(now()), now(), + -1, -1, 'infinity'::timestamptz, + wh_date_key(now()), wh_time_key(now()), now(), + tstzrange(now(), now(), '[]'), + tstzrange(now(), 'infinity'::timestamptz, '[]'), + 1 + )::wh_auth_token_accumulating_fact + ) + from wh_auth_token_accumulating_fact + where user_id = 'u_______cora'; + + -- should have an auth token for ciara that is in pending status + select is(count(*), 1::bigint) + from auth_token + where public_id = 'tok____ciara' + and status = 'auth token pending'; + + -- since the auth token is pending, there should be no fact for ciara. + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______ciara'; + + -- updating a pending token to something other than issued should not result in a fact. + update auth_token + set status = 'authentication failed' + where public_id = 'tok____ciara'; + + select is(count(*), 0::bigint) + from wh_auth_token_accumulating_fact + where user_id = 'u______ciara'; + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/wh/upsert_user/insert.sql b/internal/db/sqltest/tests/wh/upsert_user/insert.sql index d8149a3b5..6b1459bc6 100644 --- a/internal/db/sqltest/tests/wh/upsert_user/insert.sql +++ b/internal/db/sqltest/tests/wh/upsert_user/insert.sql @@ -9,12 +9,12 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 0::bigint) from wh_user_dimension where user_id = 'u_____wilson'; - select lives_ok($$select wh_upsert_user('u_____walter', 'tok___walter')$$); + select lives_ok($$select wh_upsert_user('tok___wilson')$$); -- upsert should insert a user_dimension - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____wilson'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/upsert_user/upsert.sql b/internal/db/sqltest/tests/wh/upsert_user/upsert.sql index 4c19eee1d..140e99fa7 100644 --- a/internal/db/sqltest/tests/wh/upsert_user/upsert.sql +++ b/internal/db/sqltest/tests/wh/upsert_user/upsert.sql @@ -9,7 +9,7 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 0::bigint) from wh_user_dimension where user_id = 'u_____wilson'; insert into wh_user_dimension ( @@ -25,8 +25,8 @@ begin; values ( 'wud_____1', - 'u_____walter', 'Walter', 'This is Walter', - 'apa___walter', 'password auth account', 'walter', 'Account for Walter', + 'u_____wilson', 'Wilson', 'This is Wilson', + 'apa___wilson', 'password auth account', 'wilson', 'Account for Wilson', 'None', 'None', 'None', 'apm___widget', 'password auth method', 'Widget Auth Password', 'None', 'None', @@ -34,11 +34,11 @@ begin; 'Current', '2021-07-21T12:01'::timestamptz, 'infinity'::timestamptz ); - select lives_ok($$select wh_upsert_user('u_____walter', 'tok___walter')$$); + select lives_ok($$select wh_upsert_user('tok___wilson')$$); -- upsert should insert a user_dimension - select is(count(*), 2::bigint) from wh_user_dimension where user_id = 'u_____walter'; - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; + select is(count(*), 2::bigint) from wh_user_dimension where user_id = 'u_____wilson'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____wilson' and current_row_indicator = 'Current'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/ldap_auth_new_session.sql b/internal/db/sqltest/tests/wh/user_dimension/ldap_auth_new_session.sql index c389b26c3..de5f2ba9f 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/ldap_auth_new_session.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/ldap_auth_new_session.sql @@ -1,15 +1,23 @@ -- Copyright (c) HashiCorp, Inc. -- SPDX-License-Identifier: BUSL-1.1 --- oidc_auth_new_session tests the wh_user_dimesion when --- a new session is created using the oidc auth method. +-- ldap_auth_new_session tests the wh_user_dimesion when +-- a new session is created using the ldap auth method. begin; - select plan(40); + select plan(41); select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimension from auth tokens + select is(count(*), 2::bigint) + from wh_user_dimension + where user_id in ('u_____walter', 'u_____warren') + and auth_method_id = 'alm___widget'; + + -- update walter and warren so the user dimension is changed when the session is inserted + update iam_user + set description = 'LDAP Auth New Session' + where public_id in ('u_____walter', 'u_____warren'); -- insert first session, should result in a new user dimension insert into session @@ -21,30 +29,43 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter'; - select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + -- inserting the session should result in a new user dimension. + select is(count(*), 1::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'alm___widget' + and user_description = 'LDAP Auth New Session'; - select is(auth_account_id, 'ala___walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_type, 'ldap auth account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_name, 'walter account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_description, 'Walter LDAP Account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_external_id, 'walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_full_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_email, 'walter@widget.test') from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_description, 'LDAP Auth New Session') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(auth_method_id, 'alm___widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_type, 'ldap auth method') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_name, 'Widget LDAP') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_account_id, 'ala___walter') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_type, 'ldap auth account') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_name, 'walter account') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_description, 'Walter LDAP Account') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_external_id, 'walter') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_full_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_email, 'walter@widget.test') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_method_id, 'alm___widget') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_type, 'ldap auth method') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_name, 'Widget LDAP') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + + select is(session_fact.user_key, user_dim.key) + from wh_session_connection_accumulating_fact as session_fact, + wh_user_dimension as user_dim + where session_fact.session_id = 's1____walter' + and user_dim.user_id = 'u_____walter'; -- insert session without full name or email insert into session @@ -56,29 +77,29 @@ begin; values ('s1____warren', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____warren'; - select is(user_name, 'Warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_name, 'Warren') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_description, 'LDAP Auth New Session') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(auth_account_id, 'ala___warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_type, 'ldap auth account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_name, 'warren account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_description, 'Warren LDAP Account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_external_id, 'warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_full_name, 'None') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_email, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(auth_account_id, 'ala___warren') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_type, 'ldap auth account') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_name, 'warren account') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_description, 'Warren LDAP Account') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_external_id, 'warren') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_full_name, 'None') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_account_email, 'None') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(auth_method_id, 'alm___widget') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_type, 'ldap auth method') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_name, 'Widget LDAP') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____warren'; + select is(auth_method_id, 'alm___widget') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_type, 'ldap auth method') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_name, 'Widget LDAP') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____warren'; + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____warren' and auth_method_id = 'alm___widget' and user_description = 'LDAP Auth New Session'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/oidc_auth_new_session.sql b/internal/db/sqltest/tests/wh/user_dimension/oidc_auth_new_session.sql index c4cc6b66d..944002321 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/oidc_auth_new_session.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/oidc_auth_new_session.sql @@ -8,8 +8,16 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimension from auth tokens + select is(count(*), 2::bigint) + from wh_user_dimension + where user_id in ('u_____walter', 'u_____warren') + and auth_method_id = 'aom___widget'; + + -- update walter and warren so the user dimension is changed when the session is inserted + update iam_user + set description = 'OIDC Auth New Session' + where public_id in ('u_____walter', 'u_____warren'); -- insert first session, should result in a new user dimension insert into session @@ -21,30 +29,30 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter'; - select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(user_description, 'OIDC Auth New Session') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; - select is(auth_account_id, 'aoa___walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_type, 'oidc auth account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_name, 'walter account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_description, 'Walter OIDC Account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_external_id, 'sub___walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_full_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_email, 'walter@widget.test') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_account_id, 'aoa___walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_type, 'oidc auth account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_name, 'walter account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_description, 'Walter OIDC Account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_external_id, 'sub___walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_full_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_account_email, 'walter@widget.test') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; - select is(auth_method_id, 'aom___widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_type, 'oidc auth method') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_name, 'Widget OIDC') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_external_id, 'https://oidc.widget.test') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_method_id, 'aom___widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_method_type, 'oidc auth method') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_method_name, 'Widget OIDC') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(auth_method_external_id, 'https://oidc.widget.test') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter'; + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'OIDC Auth New Session'; -- insert session without full name or email insert into session @@ -56,29 +64,29 @@ begin; values ('s1____warren', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____warren'; - select is(user_name, 'Warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(user_name, 'Warren') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(user_description, 'OIDC Auth New Session') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; - select is(auth_account_id, 'aoa___warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_type, 'oidc auth account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_name, 'warren account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_description, 'Warren OIDC Account') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_external_id, 'sub___warren') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_full_name, 'None') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_account_email, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(auth_account_id, 'aoa___warren') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_type, 'oidc auth account') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_name, 'warren account') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_description, 'Warren OIDC Account') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_external_id, 'sub___warren') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_full_name, 'None') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_account_email, 'None') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; - select is(auth_method_id, 'aom___widget') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_type, 'oidc auth method') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_name, 'Widget OIDC') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; - select is(auth_method_external_id, 'https://oidc.widget.test') from wh_user_dimension where user_id = 'u_____warren'; + select is(auth_method_id, 'aom___widget') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_method_type, 'oidc auth method') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_method_name, 'Widget OIDC') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(auth_method_external_id, 'https://oidc.widget.test') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____warren'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____warren'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____warren'; + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____warren' and user_description = 'OIDC Auth New Session'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/password_auth_description_change.sql b/internal/db/sqltest/tests/wh/user_dimension/password_auth_description_change.sql index ee55eadc3..41dc960b4 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/password_auth_description_change.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/password_auth_description_change.sql @@ -5,12 +5,20 @@ -- sessions are created using the password auth method -- after the auth_password_account has its description change. begin; - select plan(40); + select plan(41); select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimension from auth tokens + select is(count(*), 1::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'apm___widget'; + + -- update walter so the user dimension is changed when the session is inserted + update iam_user + set description = 'Passwd Change Description' + where public_id = 'u_____walter'; -- insert first session, should result in a new user dimension insert into session @@ -22,36 +30,42 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter'; - select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + -- inserting the session should result in a new user dimension. + select is(count(*), 1::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'apm___widget' + and user_description = 'Passwd Change Description'; - select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(user_description, 'Passwd Change Description') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; - select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; + + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description'; -- change auth description - update auth_password_account set - description = 'Walter Password Account' - where - public_id = 'apa___walter'; + update auth_password_account + set description = 'Walter Password Account' + where public_id = 'apa___walter'; -- another session with: -- * same user @@ -67,29 +81,33 @@ begin; values ('s2____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 2::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 2::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'apm___widget' + and user_description = 'Passwd Change Description'; - select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; + select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(user_description, 'Passwd Change Description') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; - select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_description, 'Walter Password Account') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; + select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_description, 'Walter Password Account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; - select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; + select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and current_row_indicator = 'Current'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Change Description' and current_row_indicator = 'Current'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/password_auth_new_session.sql b/internal/db/sqltest/tests/wh/user_dimension/password_auth_new_session.sql index 0556a362f..7d6ef2290 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/password_auth_new_session.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/password_auth_new_session.sql @@ -4,12 +4,20 @@ -- password_auth_new_session tests the wh_user_dimesion when -- a new session is created using the password auth method. begin; - select plan(21); + select plan(22); select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimensions from auth tokens + select is(count(*), 1::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'apm___widget'; + + -- update walter so the user dimension is changed when the session is inserted + update iam_user + set description = 'Passwd Auth New Session' + where public_id = 'u_____walter'; -- insert first session, should result in a new user dimension insert into session @@ -21,30 +29,37 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter'; - select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + -- inserting the session should result in a new user dimension. + select is(count(*), 1::bigint) + from wh_user_dimension + where user_id = 'u_____walter' + and auth_method_id = 'apm___widget' + and user_description = 'Passwd Auth New Session'; - select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; + select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(user_name, 'Walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(user_description, 'Passwd Auth New Session') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; - select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; - select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_account_id, 'apa___walter') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_type, 'password auth account') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_name, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_full_name, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_account_email, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; - select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter'; - select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter'; + select is(auth_method_id, 'apm___widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_method_type, 'password auth method') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_method_name, 'Widget Auth Password') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_method_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(auth_method_external_id, 'Not Applicable') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; - select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter'; + select is(user_organization_id, 'o_____widget') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(user_organization_name, 'Widget Inc') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + select is(user_organization_description, 'None') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; + + select is(current_row_indicator, 'Current') from wh_user_dimension where user_id = 'u_____walter' and user_description = 'Passwd Auth New Session'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions.sql b/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions.sql index 365ad8f5b..60d3e0c82 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions.sql @@ -8,8 +8,8 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimensions from auth tokens + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; -- insert first session, should result in a new user dimension insert into session @@ -21,7 +21,7 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; -- another session with: -- * same user @@ -37,7 +37,7 @@ begin; values ('s2____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; -- another session with: -- * same user @@ -53,7 +53,7 @@ begin; values ('s3____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; select * from finish(); rollback; diff --git a/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions_different_auth.sql b/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions_different_auth.sql index 5585cb8e0..c77928aac 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions_different_auth.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/session_multiple_sessions_different_auth.sql @@ -9,8 +9,8 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimensions from auth tokens + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; -- insert first session, should result in a new user dimension insert into session @@ -22,7 +22,7 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; -- another session with: -- * same user @@ -38,7 +38,7 @@ begin; values ('s4____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 2::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 8::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; select is(count(*), 1::bigint) from wh_user_dimension where user_id = 'u_____walter' and auth_account_id = 'apa1__walter'; select is(user_id, 'u_____walter') from wh_user_dimension where user_id = 'u_____walter' and auth_account_id = 'apa1__walter'; diff --git a/internal/db/sqltest/tests/wh/user_dimension/session_update.sql b/internal/db/sqltest/tests/wh/user_dimension/session_update.sql index 96d527955..3807f2c43 100644 --- a/internal/db/sqltest/tests/wh/user_dimension/session_update.sql +++ b/internal/db/sqltest/tests/wh/user_dimension/session_update.sql @@ -8,8 +8,10 @@ begin; select wtt_load('widgets', 'iam', 'kms', 'auth', 'hosts', 'targets'); - -- ensure no existing dimensions - select is(count(*), 0::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + -- existing dimension via auth tokens + select is(count(*), 4::bigint) + from wh_user_dimension + where user_id = 'u_____walter'; -- insert first session, should result in a new user dimension insert into session @@ -21,7 +23,9 @@ begin; values ('s1____walter', 's___1wb-sths', 'h_____wb__01'); - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 4::bigint) + from wh_user_dimension + where user_id = 'u_____walter'; -- update session, should not impact wh_user_dimension update session set @@ -29,7 +33,9 @@ begin; where public_id = 's1____walter'; - select is(count(*), 1::bigint) from wh_user_dimension where user_organization_id = 'o_____widget'; + select is(count(*), 4::bigint) + from wh_user_dimension + where user_id = 'u_____walter'; select * from finish(); rollback; diff --git a/internal/db/timestamp/scanners.go b/internal/db/timestamp/scanners.go index cd347212e..8fbe2cefb 100644 --- a/internal/db/timestamp/scanners.go +++ b/internal/db/timestamp/scanners.go @@ -37,10 +37,16 @@ func (ts *Timestamp) Scan(value any) error { // Scan implements driver.Valuer for protobuf Timestamp. func (ts *Timestamp) Value() (driver.Value, error) { - if ts == nil { + switch { + case ts == nil: return nil, nil + case ts.AsTime().Equal(NegativeInfinityTS): + return "-infinity", nil + case ts.AsTime().Equal(PositiveInfinityTS): + return "infinity", nil + default: + return ts.Timestamp.AsTime(), nil } - return ts.Timestamp.AsTime(), nil } // GormDataType gorm common data type (required) diff --git a/internal/db/timestamp/scanners_test.go b/internal/db/timestamp/scanners_test.go index c231c1387..c6725f087 100644 --- a/internal/db/timestamp/scanners_test.go +++ b/internal/db/timestamp/scanners_test.go @@ -33,6 +33,18 @@ func Test_TimestampValue(t *testing.T) { require.NoError(t, err) assert.Equal(t, v, utcDate(10000, 1, 1)) }) + t.Run("negativeInfinity", func(t *testing.T) { + ts := New(NegativeInfinityTS) + v, err := ts.Value() + require.NoError(t, err) + assert.Equal(t, v, "-infinity") + }) + t.Run("positiveInfinity", func(t *testing.T) { + ts := New(PositiveInfinityTS) + v, err := ts.Value() + require.NoError(t, err) + assert.Equal(t, v, "infinity") + }) } func Test_TimestampScan(t *testing.T) { @@ -65,6 +77,20 @@ func Test_TimestampScan(t *testing.T) { err := ts.Scan(v) require.Nil(err) }) + t.Run("negativeInfinity", func(t *testing.T) { + v := "-infinity" + ts := Timestamp{} + err := ts.Scan(v) + require.NoError(err) + assert.Equal(ts.AsTime(), NegativeInfinityTS) + }) + t.Run("positiveInfinity", func(t *testing.T) { + v := "infinity" + ts := Timestamp{} + err := ts.Scan(v) + require.NoError(err) + assert.Equal(ts.AsTime(), PositiveInfinityTS) + }) } const maxValidSeconds = 253402300800 diff --git a/internal/gen/controller.swagger.json b/internal/gen/controller.swagger.json index a2e7702dd..e1dc4cc49 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -17,6 +17,9 @@ { "name": "controller.api.services.v1.AuthTokenService" }, + { + "name": "controller.api.services.v1.BillingService" + }, { "name": "controller.api.services.v1.CredentialLibraryService" }, @@ -635,6 +638,39 @@ ] } }, + "/v1/billing:monthly-active-users": { + "get": { + "summary": "Returns monthly active users.", + "operationId": "BillingService_MonthlyActiveUsers", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/controller.api.services.v1.MonthlyActiveUsersResponse" + } + } + }, + "parameters": [ + { + "name": "start_time", + "description": "An optional start time of the billing period to query, in the format of YYYY-MM.", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "end_time", + "description": "An optional end time of the billing period to query, in the format of YYYY-MM.", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "controller.api.services.v1.BillingService" + ] + } + }, "/v1/credential-libraries": { "get": { "summary": "Lists all Credential Library.", @@ -4754,6 +4790,29 @@ }, "title": "AuthToken contains all fields related to an Auth Token resource" }, + "controller.api.resources.billing.v1.ActiveUsers": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "format": "int64", + "description": "Output only. The number of active users between the start time and end time.", + "readOnly": true + }, + "start_time": { + "type": "string", + "format": "date-time", + "description": "Output only. The start time of the active users count, inclusive.", + "readOnly": true + }, + "end_time": { + "type": "string", + "format": "date-time", + "description": "Output only. The end time of the active users count, exclusive.", + "readOnly": true + } + } + }, "controller.api.resources.credentiallibraries.v1.CredentialLibrary": { "type": "object", "properties": { @@ -8776,6 +8835,18 @@ } } }, + "controller.api.services.v1.MonthlyActiveUsersResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/controller.api.resources.billing.v1.ActiveUsers" + } + } + } + }, "controller.api.services.v1.ReApplyStoragePolicyResponse": { "type": "object", "properties": { diff --git a/internal/gen/controller/api/services/billing_service.pb.go b/internal/gen/controller/api/services/billing_service.pb.go new file mode 100644 index 000000000..c6aa91891 --- /dev/null +++ b/internal/gen/controller/api/services/billing_service.pb.go @@ -0,0 +1,261 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc (unknown) +// source: controller/api/services/v1/billing_service.proto + +package services + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + billing "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type MonthlyActiveUsersRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional start time of the billing period to query, in the format of YYYY-MM. + StartTime string `protobuf:"bytes,1,opt,name=start_time,proto3" json:"start_time,omitempty" class:"public"` // @gotags: class:"public" + // An optional end time of the billing period to query, in the format of YYYY-MM. + EndTime string `protobuf:"bytes,2,opt,name=end_time,proto3" json:"end_time,omitempty" class:"public"` // @gotags: class:"public" +} + +func (x *MonthlyActiveUsersRequest) Reset() { + *x = MonthlyActiveUsersRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_api_services_v1_billing_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MonthlyActiveUsersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MonthlyActiveUsersRequest) ProtoMessage() {} + +func (x *MonthlyActiveUsersRequest) ProtoReflect() protoreflect.Message { + mi := &file_controller_api_services_v1_billing_service_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MonthlyActiveUsersRequest.ProtoReflect.Descriptor instead. +func (*MonthlyActiveUsersRequest) Descriptor() ([]byte, []int) { + return file_controller_api_services_v1_billing_service_proto_rawDescGZIP(), []int{0} +} + +func (x *MonthlyActiveUsersRequest) GetStartTime() string { + if x != nil { + return x.StartTime + } + return "" +} + +func (x *MonthlyActiveUsersRequest) GetEndTime() string { + if x != nil { + return x.EndTime + } + return "" +} + +type MonthlyActiveUsersResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Items []*billing.ActiveUsers `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` +} + +func (x *MonthlyActiveUsersResponse) Reset() { + *x = MonthlyActiveUsersResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_api_services_v1_billing_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MonthlyActiveUsersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MonthlyActiveUsersResponse) ProtoMessage() {} + +func (x *MonthlyActiveUsersResponse) ProtoReflect() protoreflect.Message { + mi := &file_controller_api_services_v1_billing_service_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MonthlyActiveUsersResponse.ProtoReflect.Descriptor instead. +func (*MonthlyActiveUsersResponse) Descriptor() ([]byte, []int) { + return file_controller_api_services_v1_billing_service_proto_rawDescGZIP(), []int{1} +} + +func (x *MonthlyActiveUsersResponse) GetItems() []*billing.ActiveUsers { + if x != nil { + return x.Items + } + return nil +} + +var File_controller_api_services_v1_billing_service_proto protoreflect.FileDescriptor + +var file_controller_api_services_v1_billing_service_proto_rawDesc = []byte{ + 0x0a, 0x30, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x62, 0x69, 0x6c, + 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x1a, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x1a, 0x31, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, + 0x2f, 0x76, 0x31, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x61, 0x6e, + 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65, 0x6e, 0x2d, 0x6f, 0x70, 0x65, 0x6e, + 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2f, 0x61, 0x6e, + 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, + 0x57, 0x0a, 0x19, 0x4d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x0a, + 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, + 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x64, 0x0a, 0x1a, 0x4d, 0x6f, 0x6e, 0x74, + 0x68, 0x6c, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, + 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, + 0x2e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x63, 0x74, 0x69, + 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x32, 0xe2, + 0x01, 0x0a, 0x0e, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0xcf, 0x01, 0x0a, 0x12, 0x4d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x41, 0x63, 0x74, + 0x69, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x35, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, + 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x41, 0x63, 0x74, + 0x69, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x36, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x6f, 0x6e, + 0x74, 0x68, 0x6c, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x4a, 0x92, 0x41, 0x1f, 0x12, 0x1d, 0x52, 0x65, + 0x74, 0x75, 0x72, 0x6e, 0x73, 0x20, 0x6d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x20, 0x61, 0x63, + 0x74, 0x69, 0x76, 0x65, 0x20, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x22, 0x12, 0x20, 0x2f, 0x76, 0x31, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x3a, 0x6d, + 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x2d, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x2d, 0x75, 0x73, + 0x65, 0x72, 0x73, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, + 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x65, + 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_controller_api_services_v1_billing_service_proto_rawDescOnce sync.Once + file_controller_api_services_v1_billing_service_proto_rawDescData = file_controller_api_services_v1_billing_service_proto_rawDesc +) + +func file_controller_api_services_v1_billing_service_proto_rawDescGZIP() []byte { + file_controller_api_services_v1_billing_service_proto_rawDescOnce.Do(func() { + file_controller_api_services_v1_billing_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_controller_api_services_v1_billing_service_proto_rawDescData) + }) + return file_controller_api_services_v1_billing_service_proto_rawDescData +} + +var file_controller_api_services_v1_billing_service_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_controller_api_services_v1_billing_service_proto_goTypes = []interface{}{ + (*MonthlyActiveUsersRequest)(nil), // 0: controller.api.services.v1.MonthlyActiveUsersRequest + (*MonthlyActiveUsersResponse)(nil), // 1: controller.api.services.v1.MonthlyActiveUsersResponse + (*billing.ActiveUsers)(nil), // 2: controller.api.resources.billing.v1.ActiveUsers +} +var file_controller_api_services_v1_billing_service_proto_depIdxs = []int32{ + 2, // 0: controller.api.services.v1.MonthlyActiveUsersResponse.items:type_name -> controller.api.resources.billing.v1.ActiveUsers + 0, // 1: controller.api.services.v1.BillingService.MonthlyActiveUsers:input_type -> controller.api.services.v1.MonthlyActiveUsersRequest + 1, // 2: controller.api.services.v1.BillingService.MonthlyActiveUsers:output_type -> controller.api.services.v1.MonthlyActiveUsersResponse + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_controller_api_services_v1_billing_service_proto_init() } +func file_controller_api_services_v1_billing_service_proto_init() { + if File_controller_api_services_v1_billing_service_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_controller_api_services_v1_billing_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MonthlyActiveUsersRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_controller_api_services_v1_billing_service_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MonthlyActiveUsersResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_controller_api_services_v1_billing_service_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_controller_api_services_v1_billing_service_proto_goTypes, + DependencyIndexes: file_controller_api_services_v1_billing_service_proto_depIdxs, + MessageInfos: file_controller_api_services_v1_billing_service_proto_msgTypes, + }.Build() + File_controller_api_services_v1_billing_service_proto = out.File + file_controller_api_services_v1_billing_service_proto_rawDesc = nil + file_controller_api_services_v1_billing_service_proto_goTypes = nil + file_controller_api_services_v1_billing_service_proto_depIdxs = nil +} diff --git a/internal/gen/controller/api/services/billing_service.pb.gw.go b/internal/gen/controller/api/services/billing_service.pb.gw.go new file mode 100644 index 000000000..71a853af3 --- /dev/null +++ b/internal/gen/controller/api/services/billing_service.pb.gw.go @@ -0,0 +1,173 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: controller/api/services/v1/billing_service.proto + +/* +Package services is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package services + +import ( + "context" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var _ codes.Code +var _ io.Reader +var _ status.Status +var _ = runtime.String +var _ = utilities.NewDoubleArray +var _ = metadata.Join + +var ( + filter_BillingService_MonthlyActiveUsers_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) + +func request_BillingService_MonthlyActiveUsers_0(ctx context.Context, marshaler runtime.Marshaler, client BillingServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq MonthlyActiveUsersRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_BillingService_MonthlyActiveUsers_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.MonthlyActiveUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_BillingService_MonthlyActiveUsers_0(ctx context.Context, marshaler runtime.Marshaler, server BillingServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq MonthlyActiveUsersRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_BillingService_MonthlyActiveUsers_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.MonthlyActiveUsers(ctx, &protoReq) + return msg, metadata, err + +} + +// RegisterBillingServiceHandlerServer registers the http handlers for service BillingService to "mux". +// UnaryRPC :call BillingServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterBillingServiceHandlerFromEndpoint instead. +func RegisterBillingServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server BillingServiceServer) error { + + mux.Handle("GET", pattern_BillingService_MonthlyActiveUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/controller.api.services.v1.BillingService/MonthlyActiveUsers", runtime.WithHTTPPathPattern("/v1/billing:monthly-active-users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_BillingService_MonthlyActiveUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_BillingService_MonthlyActiveUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +// RegisterBillingServiceHandlerFromEndpoint is same as RegisterBillingServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterBillingServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.DialContext(ctx, endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + + return RegisterBillingServiceHandler(ctx, mux, conn) +} + +// RegisterBillingServiceHandler registers the http handlers for service BillingService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterBillingServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterBillingServiceHandlerClient(ctx, mux, NewBillingServiceClient(conn)) +} + +// RegisterBillingServiceHandlerClient registers the http handlers for service BillingService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "BillingServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "BillingServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "BillingServiceClient" to call the correct interceptors. +func RegisterBillingServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client BillingServiceClient) error { + + mux.Handle("GET", pattern_BillingService_MonthlyActiveUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/controller.api.services.v1.BillingService/MonthlyActiveUsers", runtime.WithHTTPPathPattern("/v1/billing:monthly-active-users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_BillingService_MonthlyActiveUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_BillingService_MonthlyActiveUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +var ( + pattern_BillingService_MonthlyActiveUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "billing"}, "monthly-active-users")) +) + +var ( + forward_BillingService_MonthlyActiveUsers_0 = runtime.ForwardResponseMessage +) diff --git a/internal/gen/controller/api/services/billing_service_grpc.pb.go b/internal/gen/controller/api/services/billing_service_grpc.pb.go new file mode 100644 index 000000000..3875864f7 --- /dev/null +++ b/internal/gen/controller/api/services/billing_service_grpc.pb.go @@ -0,0 +1,122 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc (unknown) +// source: controller/api/services/v1/billing_service.proto + +package services + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + BillingService_MonthlyActiveUsers_FullMethodName = "/controller.api.services.v1.BillingService/MonthlyActiveUsers" +) + +// BillingServiceClient is the client API for BillingService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type BillingServiceClient interface { + // MonthlyActiveUsers returns the monthly active users for the given time period. + // If no time period is provided, the current and previous months are returned. + // If the provided request contains a start time but no end time, it will return + // up to the current month. If the provided request contains an end time and no start time, + // or if the end time is prior to the start time, an error will be returned. + MonthlyActiveUsers(ctx context.Context, in *MonthlyActiveUsersRequest, opts ...grpc.CallOption) (*MonthlyActiveUsersResponse, error) +} + +type billingServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewBillingServiceClient(cc grpc.ClientConnInterface) BillingServiceClient { + return &billingServiceClient{cc} +} + +func (c *billingServiceClient) MonthlyActiveUsers(ctx context.Context, in *MonthlyActiveUsersRequest, opts ...grpc.CallOption) (*MonthlyActiveUsersResponse, error) { + out := new(MonthlyActiveUsersResponse) + err := c.cc.Invoke(ctx, BillingService_MonthlyActiveUsers_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// BillingServiceServer is the server API for BillingService service. +// All implementations must embed UnimplementedBillingServiceServer +// for forward compatibility +type BillingServiceServer interface { + // MonthlyActiveUsers returns the monthly active users for the given time period. + // If no time period is provided, the current and previous months are returned. + // If the provided request contains a start time but no end time, it will return + // up to the current month. If the provided request contains an end time and no start time, + // or if the end time is prior to the start time, an error will be returned. + MonthlyActiveUsers(context.Context, *MonthlyActiveUsersRequest) (*MonthlyActiveUsersResponse, error) + mustEmbedUnimplementedBillingServiceServer() +} + +// UnimplementedBillingServiceServer must be embedded to have forward compatible implementations. +type UnimplementedBillingServiceServer struct { +} + +func (UnimplementedBillingServiceServer) MonthlyActiveUsers(context.Context, *MonthlyActiveUsersRequest) (*MonthlyActiveUsersResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method MonthlyActiveUsers not implemented") +} +func (UnimplementedBillingServiceServer) mustEmbedUnimplementedBillingServiceServer() {} + +// UnsafeBillingServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to BillingServiceServer will +// result in compilation errors. +type UnsafeBillingServiceServer interface { + mustEmbedUnimplementedBillingServiceServer() +} + +func RegisterBillingServiceServer(s grpc.ServiceRegistrar, srv BillingServiceServer) { + s.RegisterService(&BillingService_ServiceDesc, srv) +} + +func _BillingService_MonthlyActiveUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MonthlyActiveUsersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BillingServiceServer).MonthlyActiveUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BillingService_MonthlyActiveUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BillingServiceServer).MonthlyActiveUsers(ctx, req.(*MonthlyActiveUsersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// BillingService_ServiceDesc is the grpc.ServiceDesc for BillingService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var BillingService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "controller.api.services.v1.BillingService", + HandlerType: (*BillingServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "MonthlyActiveUsers", + Handler: _BillingService_MonthlyActiveUsers_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "controller/api/services/v1/billing_service.proto", +} diff --git a/internal/perms/acl_test.go b/internal/perms/acl_test.go index 0cde2e6e9..f776d9f87 100644 --- a/internal/perms/acl_test.go +++ b/internal/perms/acl_test.go @@ -932,7 +932,7 @@ func Test_AnonRestrictions(t *testing.T) { if i == resource.Controller || i == resource.Worker { continue } - for j := action.Type(1); j <= action.RemoveGrantScopes; j++ { + for j := action.Type(1); j <= action.MonthlyActiveUsers; j++ { id := "foobar" prefixes := globals.ResourcePrefixesFromType(resource.Type(i)) if len(prefixes) > 0 { diff --git a/internal/proto/controller/api/resources/billing/v1/billing.proto b/internal/proto/controller/api/resources/billing/v1/billing.proto new file mode 100644 index 000000000..e9e391230 --- /dev/null +++ b/internal/proto/controller/api/resources/billing/v1/billing.proto @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +syntax = "proto3"; + +package controller.api.resources.billing.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing;billing"; + +message ActiveUsers { + // Output only. The number of active users between the start time and end time. + uint32 count = 1; // @gotags: `class:"public"` + + // Output only. The start time of the active users count, inclusive. + google.protobuf.Timestamp start_time = 2 [json_name = "start_time"]; // @gotags: class:"public" + + // Output only. The end time of the active users count, exclusive. + google.protobuf.Timestamp end_time = 3 [json_name = "end_time"]; // @gotags: class:"public" +} diff --git a/internal/proto/controller/api/services/v1/billing_service.proto b/internal/proto/controller/api/services/v1/billing_service.proto new file mode 100644 index 000000000..06607d484 --- /dev/null +++ b/internal/proto/controller/api/services/v1/billing_service.proto @@ -0,0 +1,36 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +syntax = "proto3"; + +package controller.api.services.v1; + +import "controller/api/resources/billing/v1/billing.proto"; +import "google/api/annotations.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/hashicorp/boundary/internal/gen/controller/api/services;services"; + +service BillingService { + // MonthlyActiveUsers returns the monthly active users for the given time period. + // If no time period is provided, the current and previous months are returned. + // If the provided request contains a start time but no end time, it will return + // up to the current month. If the provided request contains an end time and no start time, + // or if the end time is prior to the start time, an error will be returned. + rpc MonthlyActiveUsers(MonthlyActiveUsersRequest) returns (MonthlyActiveUsersResponse) { + option (google.api.http) = {get: "/v1/billing:monthly-active-users"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {summary: "Returns monthly active users."}; + } +} + +message MonthlyActiveUsersRequest { + // An optional start time of the billing period to query, in the format of YYYY-MM. + string start_time = 1 [json_name = "start_time"]; // @gotags: class:"public" + + // An optional end time of the billing period to query, in the format of YYYY-MM. + string end_time = 2 [json_name = "end_time"]; // @gotags: class:"public" +} + +message MonthlyActiveUsersResponse { + repeated resources.billing.v1.ActiveUsers items = 1; +} diff --git a/internal/ratelimit/config.go b/internal/ratelimit/config.go index 68e2eaceb..7c7b47633 100644 --- a/internal/ratelimit/config.go +++ b/internal/ratelimit/config.go @@ -19,6 +19,7 @@ import ( _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/accounts" _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authmethods" _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authtokens" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/billing" _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentiallibraries" _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentials" _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentialstores" diff --git a/internal/tests/cli/boundary/_billing.bash b/internal/tests/cli/boundary/_billing.bash new file mode 100644 index 000000000..34efca204 --- /dev/null +++ b/internal/tests/cli/boundary/_billing.bash @@ -0,0 +1,18 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +function active_users_last_two_months() { + boundary billing monthly-active-users -format json +} + +function active_users_start_time() { + boundary billing monthly-active-users -start-time=$1 -format json +} + +function active_users_start_time_and_end_time() { + boundary billing monthly-active-users -start-time=$1 -end-time=$2 -format json +} + +function active_users_end_time() { + boundary billing monthly-active-users -end-time=$1 -format json +} \ No newline at end of file diff --git a/internal/tests/cli/boundary/billing.bats b/internal/tests/cli/boundary/billing.bats new file mode 100644 index 000000000..f78e6f6cf --- /dev/null +++ b/internal/tests/cli/boundary/billing.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +load _auth +load _billing +load _helpers + +@test "boundary/billing: can login as admin user" { + run login $DEFAULT_LOGIN + [ "$status" -eq 0 ] +} + +@test "boundary/billing: admin user can get last two months" { + run active_users_last_two_months + [ "$status" -eq 0 ] + run has_status_code "$output" "200" +} + +@test "boundary/billing: admin user can get report with start time" { + run active_users_start_time "2023-09" + [ "$status" -eq 0 ] + run has_status_code "$output" "200" +} + +@test "boundary/billing: admin user can get report with start and end times" { + run active_users_start_time_and_end_time "2023-09" "2023-12" + [ "$status" -eq 0 ] + run has_status_code "$output" "200" +} + +@test "boundary/billing: cannot get report with end time before start time" { + run active_users_start_time_and_end_time "2023-09" "2023-08" + [ "$status" -eq 1 ] +} + +@test "boundary/billing: cannot get report with only end time" { + run active_users_end_time "2023-09" + [ "$status" -eq 1 ] +} + +# unpriv tests +@test "boundary/billing: can login as unpriv user" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] +} + +@test "boundary/billing: default user cannot get last two months" { + run active_users_last_two_months + [ "$status" -eq 1 ] +} + +@test "boundary/billing: default user cannot get report with start time" { + run active_users_start_time "2023-09" + [ "$status" -eq 1 ] + run has_status_code "$output" "200" +} + +@test "boundary/billing: default user cannot get report with start and end times" { + run active_users_start_time_and_end_time "2023-09" "2023-12" + [ "$status" -eq 1 ] + run has_status_code "$output" "200" +} \ No newline at end of file diff --git a/internal/types/action/action.go b/internal/types/action/action.go index eb99b4c79..da557a0c7 100644 --- a/internal/types/action/action.go +++ b/internal/types/action/action.go @@ -74,6 +74,7 @@ const ( AddGrantScopes Type = 60 SetGrantScopes Type = 61 RemoveGrantScopes Type = 62 + MonthlyActiveUsers Type = 63 // When adding new actions, be sure to update: // @@ -144,6 +145,7 @@ var Map = map[string]Type{ AddGrantScopes.String(): AddGrantScopes, SetGrantScopes.String(): SetGrantScopes, RemoveGrantScopes.String(): RemoveGrantScopes, + MonthlyActiveUsers.String(): MonthlyActiveUsers, } var DeprecatedMap = map[string]Type{ @@ -220,6 +222,7 @@ func (a Type) String() string { "add-grant-scopes", "set-grant-scopes", "remove-grant-scopes", + "monthly-active-users", }[a] } diff --git a/internal/types/resource/resource.go b/internal/types/resource/resource.go index 02f3589ef..f9ec1f3c2 100644 --- a/internal/types/resource/resource.go +++ b/internal/types/resource/resource.go @@ -35,6 +35,7 @@ const ( Credential StorageBucket Policy + Billing // NOTE: When adding a new type, be sure to update: // // * The Grant.validateType function and test @@ -74,6 +75,7 @@ func (r Type) String() string { "credential", "storage-bucket", "policy", + "billing", }[r] } @@ -83,6 +85,8 @@ func (r Type) PluralString() string { return "credential-libraries" case Policy: return "policies" + case Billing: // never pluralized + return "billing" default: return r.String() + "s" } @@ -94,6 +98,8 @@ func FromPlural(s string) (Type, bool) { return CredentialLibrary, true case "policies": return Policy, true + case "billing": + return Billing, true default: t, ok := Map[strings.TrimSuffix(s, "s")] return t, ok @@ -124,6 +130,7 @@ var Map = map[string]Type{ Credential.String(): Credential, StorageBucket.String(): StorageBucket, Policy.String(): Policy, + Billing.String(): Billing, } // Parent returns the parent type for a given type; if there is no parent, it diff --git a/sdk/pbs/controller/api/resources/billing/billing.pb.go b/sdk/pbs/controller/api/resources/billing/billing.pb.go new file mode 100644 index 000000000..b452d526d --- /dev/null +++ b/sdk/pbs/controller/api/resources/billing/billing.pb.go @@ -0,0 +1,185 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc (unknown) +// source: controller/api/resources/billing/v1/billing.proto + +package billing + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ActiveUsers struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Output only. The number of active users between the start time and end time. + Count uint32 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty" class:"public"` // @gotags: `class:"public"` + // Output only. The start time of the active users count, inclusive. + StartTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start_time,proto3" json:"start_time,omitempty" class:"public"` // @gotags: class:"public" + // Output only. The end time of the active users count, exclusive. + EndTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end_time,proto3" json:"end_time,omitempty" class:"public"` // @gotags: class:"public" +} + +func (x *ActiveUsers) Reset() { + *x = ActiveUsers{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_api_resources_billing_v1_billing_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ActiveUsers) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActiveUsers) ProtoMessage() {} + +func (x *ActiveUsers) ProtoReflect() protoreflect.Message { + mi := &file_controller_api_resources_billing_v1_billing_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActiveUsers.ProtoReflect.Descriptor instead. +func (*ActiveUsers) Descriptor() ([]byte, []int) { + return file_controller_api_resources_billing_v1_billing_proto_rawDescGZIP(), []int{0} +} + +func (x *ActiveUsers) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *ActiveUsers) GetStartTime() *timestamppb.Timestamp { + if x != nil { + return x.StartTime + } + return nil +} + +func (x *ActiveUsers) GetEndTime() *timestamppb.Timestamp { + if x != nil { + return x.EndTime + } + return nil +} + +var File_controller_api_resources_billing_v1_billing_proto protoreflect.FileDescriptor + +var file_controller_api_resources_billing_v1_billing_proto_rawDesc = []byte{ + 0x0a, 0x31, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, + 0x6e, 0x67, 0x2f, 0x76, 0x31, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x62, 0x69, + 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x97, 0x01, 0x0a, 0x0b, 0x41, 0x63, + 0x74, 0x69, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, + 0x3a, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x65, + 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x74, + 0x69, 0x6d, 0x65, 0x42, 0x50, 0x5a, 0x4e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, + 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x3b, 0x62, 0x69, + 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_controller_api_resources_billing_v1_billing_proto_rawDescOnce sync.Once + file_controller_api_resources_billing_v1_billing_proto_rawDescData = file_controller_api_resources_billing_v1_billing_proto_rawDesc +) + +func file_controller_api_resources_billing_v1_billing_proto_rawDescGZIP() []byte { + file_controller_api_resources_billing_v1_billing_proto_rawDescOnce.Do(func() { + file_controller_api_resources_billing_v1_billing_proto_rawDescData = protoimpl.X.CompressGZIP(file_controller_api_resources_billing_v1_billing_proto_rawDescData) + }) + return file_controller_api_resources_billing_v1_billing_proto_rawDescData +} + +var file_controller_api_resources_billing_v1_billing_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_controller_api_resources_billing_v1_billing_proto_goTypes = []interface{}{ + (*ActiveUsers)(nil), // 0: controller.api.resources.billing.v1.ActiveUsers + (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp +} +var file_controller_api_resources_billing_v1_billing_proto_depIdxs = []int32{ + 1, // 0: controller.api.resources.billing.v1.ActiveUsers.start_time:type_name -> google.protobuf.Timestamp + 1, // 1: controller.api.resources.billing.v1.ActiveUsers.end_time:type_name -> google.protobuf.Timestamp + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_controller_api_resources_billing_v1_billing_proto_init() } +func file_controller_api_resources_billing_v1_billing_proto_init() { + if File_controller_api_resources_billing_v1_billing_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_controller_api_resources_billing_v1_billing_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ActiveUsers); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_controller_api_resources_billing_v1_billing_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_controller_api_resources_billing_v1_billing_proto_goTypes, + DependencyIndexes: file_controller_api_resources_billing_v1_billing_proto_depIdxs, + MessageInfos: file_controller_api_resources_billing_v1_billing_proto_msgTypes, + }.Build() + File_controller_api_resources_billing_v1_billing_proto = out.File + file_controller_api_resources_billing_v1_billing_proto_rawDesc = nil + file_controller_api_resources_billing_v1_billing_proto_goTypes = nil + file_controller_api_resources_billing_v1_billing_proto_depIdxs = nil +}