From 9d82958c9f7610f76ed994fbaaaa37596a5f50eb Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Thu, 14 Dec 2023 17:53:23 +0000 Subject: [PATCH 01/27] test(sqltest): Shorten prepared statement identifiers Some of the prepared statement identifies used in these tests exceeded the identifier character limit. This results in postgres truncating the name, which is fine for a test, but does result in some noise in the test output. This change shortens the identifies to eliminate noise like the following: psql:/test/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql:49: NOTICE: identifier "insert_credential_vault_library_ssh_private_key_mapping_override" will be truncated to "insert_credential_vault_library_ssh_private_key_mapping_overrid" The tests should no longer create notices like these. --- ...ial_vault_library_ssh_private_key_mapping_override.sql | 8 ++++---- ...l_vault_library_username_password_mapping_override.sql | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) 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'; From e51d0e0b4feb744c67319a8e9aaaa25c835f56fb Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Thu, 14 Dec 2023 18:21:30 +0000 Subject: [PATCH 02/27] feat(vault): Remove database notice when setting credential type This notice just generated noise, particuarlly in some of the sqltests. --- ...1_credential_vault_ssh_cert_library.up.sql | 1 + .../82/07_vault_ssh_cert_default.up.sql | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/07_vault_ssh_cert_default.up.sql 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/82/07_vault_ssh_cert_default.up.sql b/internal/db/schema/migrations/oss/postgres/82/07_vault_ssh_cert_default.up.sql new file mode 100644 index 000000000..21695b7a9 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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; From 005f3d0843d15e3d82bcdefc3d54a8bde10e2297 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Thu, 14 Dec 2023 18:51:05 +0000 Subject: [PATCH 03/27] chore(sqltest): Make it possible to enable/disable db container logs When running the sqltests, sometimes it is helpful to see the full database container logs, especially when there is an error in a migration or database setup. However, it also makes it difficult to see the test results. This adds a new variable to the makefile to easily toggle the output of these logs on or off. To run the tests without printing the dtabase logs use: DB_LOGS=0 make test To enable the logs use: DB_LOGS=1 make test By default the logs are not printed. --- internal/db/sqltest/Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From 03d1cc4572dc8566e7fa27950b3670bdc5031857 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 5 Jan 2024 21:44:57 +0000 Subject: [PATCH 04/27] feat(dw): Add auth token accumulating fact table --- .../oss/postgres/82/01_wh_auth_token.up.sql | 101 ++++ .../db/sqltest/tests/domain/wh_user_id.sql | 37 ++ .../tests/wh/auth_token/constraints.sql | 507 ++++++++++++++++++ 3 files changed, 645 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/01_wh_auth_token.up.sql create mode 100644 internal/db/sqltest/tests/domain/wh_user_id.sql create mode 100644 internal/db/sqltest/tests/wh/auth_token/constraints.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/01_wh_auth_token.up.sql b/internal/db/schema/migrations/oss/postgres/82/01_wh_auth_token.up.sql new file mode 100644 index 000000000..bdd2a2a14 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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/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/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; From eb626bb54c0b8e0ff453fccbcdff57d4b284d4ba Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 5 Jan 2024 21:54:12 +0000 Subject: [PATCH 05/27] refact(wh): Use auth token id for user dimension Previously the function used to upsert into the user dimension required both an auth token id, and a user id. However, the user id can be determined from the auth token, in fact it already was via the whx_user_dimension_source view. This refactor removes the user id as an argument, since it is not needed and will not be directly available when this function is used when populating the new auth token accumulating fact. --- .../15/01_wh_rename_key_columns.up.sql | 1 + .../oss/postgres/60/03_wh_sessions.up.sql | 3 +- .../82/02_wh_upsert_user_refact.up.sql | 111 ++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 internal/db/schema/migrations/oss/postgres/82/02_wh_upsert_user_refact.up.sql 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/82/02_wh_upsert_user_refact.up.sql b/internal/db/schema/migrations/oss/postgres/82/02_wh_upsert_user_refact.up.sql new file mode 100644 index 000000000..4f52d710b --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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; From 07b3a7a19e3a5d4c7aaf29beb06ae4d0d79b97a4 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 18:10:09 +0000 Subject: [PATCH 06/27] feat(dw): Add auth token fact after inserting issued auth token This adds a trigger and function to ensure that an auth token fact is created when a new auth token is created with the 'token issued' status. --- .../82/04_wh_auth_token_insert.up.sql | 51 ++++++++++++ .../db/sqltest/tests/wh/auth_token/insert.sql | 81 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/04_wh_auth_token_insert.up.sql create mode 100644 internal/db/sqltest/tests/wh/auth_token/insert.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/04_wh_auth_token_insert.up.sql b/internal/db/schema/migrations/oss/postgres/82/04_wh_auth_token_insert.up.sql new file mode 100644 index 000000000..776da6f2a --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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/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; From 585b344da6544e4d700f8cf124c2e6b52ddd1e4b Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 16:18:48 +0000 Subject: [PATCH 07/27] feat(auth_token): Set approximate_last_access_time when token is issued --- .../postgres/82/03_auth_token_issued.up.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/03_auth_token_issued.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/03_auth_token_issued.up.sql b/internal/db/schema/migrations/oss/postgres/82/03_auth_token_issued.up.sql new file mode 100644 index 000000000..6707d34ea --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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; From b938dd67065629ac98ea097f6b1d157b298b0e85 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 16:21:55 +0000 Subject: [PATCH 08/27] feat(dw): Insert/update auth token fact when auth token updated This adds a function and update trigger to auth_token to ensure that: 1. If the auth token is updated to the issued state, a auth token fact is added to the warehouse. 2. If the auth token's approximate last access time is updated, the corresponding auth token fact is updated with the new time. --- .../82/05_wh_auth_token_update.up.sql | 38 ++++++ .../db/sqltest/tests/wh/auth_token/update.sql | 114 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/05_wh_auth_token_update.up.sql create mode 100644 internal/db/sqltest/tests/wh/auth_token/update.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/05_wh_auth_token_update.up.sql b/internal/db/schema/migrations/oss/postgres/82/05_wh_auth_token_update.up.sql new file mode 100644 index 000000000..3186cca4f --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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/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; From 9e0d96b8875e6322d3b19638edde007f5105e20d Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 16:25:44 +0000 Subject: [PATCH 09/27] feat(dw): Update auth token fact when auth token is deleted This adds a function and after delete trigger to the auth_token table to ensure that a corresponding auth token fact is updated with the deleted time and sets the auth token valid time range. The data warehouse will only be updated if the token that was deleted is in the 'token issued' status. --- .../82/06_wh_auth_token_delete.up.sql | 33 ++++++++ .../db/sqltest/tests/wh/auth_token/delete.sql | 75 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/06_wh_auth_token_delete.up.sql create mode 100644 internal/db/sqltest/tests/wh/auth_token/delete.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/06_wh_auth_token_delete.up.sql b/internal/db/schema/migrations/oss/postgres/82/06_wh_auth_token_delete.up.sql new file mode 100644 index 000000000..8edd19b98 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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/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; From 9514a76289682e387f78d051ddd681421e247a2f Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 16:28:37 +0000 Subject: [PATCH 10/27] test(sqltest): Fix tests due to new auth_token insert trigger With the addition of an after insert trigger to the auth_token table, entries are added to the wh_user_dimension table when auth token's are inserted with the 'token issued' status. This caused a number of tests to fail that assumed the user dimension table would be empty prior to inserting sessions. This introduces a number of changes to the tests: - In the test setup, auth tokens are not created with an explicit expiration time and status. The expiration time ensures that the tokens are 'valid' for the test. The status allows test to make assertions on tokens in different statuses, and allow the status to change during the test. - Tests make the correct assertions on the number of entries in the user dimension table after test setup. - User dimension tests modify a user after the standard setup to ensure that a new user dimension is added when a session is inserted. --- .../db/sqltest/initdb.d/01_colors_persona.sql | 12 +- .../sqltest/initdb.d/03_widgets_persona.sql | 16 +-- .../sqltest/tests/wh/upsert_user/insert.sql | 6 +- .../sqltest/tests/wh/upsert_user/upsert.sql | 12 +- .../user_dimension/ldap_auth_new_session.sql | 109 ++++++++++------- .../user_dimension/oidc_auth_new_session.sql | 90 +++++++------- .../password_auth_description_change.sql | 110 ++++++++++-------- .../password_auth_new_session.sql | 61 ++++++---- .../session_multiple_sessions.sql | 10 +- ...ssion_multiple_sessions_different_auth.sql | 8 +- .../wh/user_dimension/session_update.sql | 14 ++- 11 files changed, 258 insertions(+), 190 deletions(-) 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/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; From bbb43e20a0cfa789c856ab10aee739b568caf632 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Mon, 8 Jan 2024 18:39:00 +0000 Subject: [PATCH 11/27] test(sqltest): Remove impossible assertion Now that there is an auth token fact table in the data warehouse, it is not possible to insert an auth_token with an old public_id even after that auth_token was deleted, since it now causes a constraint violation on the data warehouse table. --- .../tests/purge/trigger_insert_on_delete.sql | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) 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; From f3caf219792539a608c6c251b3a12a3403fba14e Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Thu, 11 Jan 2024 18:05:15 +0000 Subject: [PATCH 12/27] test(authtoken): Associate test account with a user This test started failing now that the data warehouse records a auth token fact when an auth token is issued and it requires that the auth token has a user associated with it. Since this test is testing the interaction with the database directly, and not via the repository, it by-passes the application checks that would prevent an auth token without an associated user being inserted. This test is updated to meet this same pre-condition prior to attempting to insert into the database. --- internal/authtoken/authtoken_test.go | 5 ++++- internal/authtoken/repository_test.go | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/authtoken/authtoken_test.go b/internal/authtoken/authtoken_test.go index 432c056a5..9a0736b98 100644 --- a/internal/authtoken/authtoken_test.go +++ b/internal/authtoken/authtoken_test.go @@ -130,13 +130,16 @@ 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()) + iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{acct.GetPublicId()}) testAuthTokenId := func() string { id, err := NewAuthTokenId(ctx) diff --git a/internal/authtoken/repository_test.go b/internal/authtoken/repository_test.go index fe08d6eff..bf15ee11d 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) From 324c474f5d9e476bebba667d3c9a9f98f2e5c18f Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Tue, 30 Jan 2024 22:00:16 +0000 Subject: [PATCH 13/27] feat(billing): Add views for monthly active user counts --- .../82/08_hcp_user_billing_view.up.sql | 67 +++ .../hcp/billing/monthly_active_users.sql | 470 ++++++++++++++++++ .../hcp/billing/monthly_active_users_all.sql | 100 ++++ 3 files changed, 637 insertions(+) create mode 100644 internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql create mode 100644 internal/db/sqltest/tests/hcp/billing/monthly_active_users.sql create mode 100644 internal/db/sqltest/tests/hcp/billing/monthly_active_users_all.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql b/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql new file mode 100644 index 000000000..0c363f878 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql @@ -0,0 +1,67 @@ +-- 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).'; +commit; 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; From 7d5c3e48cd5e00070de8ffb8ff924d0d4b6ede0b Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 16 Feb 2024 13:40:37 -0500 Subject: [PATCH 14/27] feat(billing): Add domain level package for monthly active user counts --- internal/billing/active_users.go | 15 ++ internal/billing/doc.go | 9 + internal/billing/options.go | 47 +++++ internal/billing/options_test.go | 34 ++++ internal/billing/query.go | 22 +++ internal/billing/repository.go | 121 +++++++++++++ internal/billing/repository_test.go | 264 ++++++++++++++++++++++++++++ 7 files changed, 512 insertions(+) create mode 100644 internal/billing/active_users.go create mode 100644 internal/billing/doc.go create mode 100644 internal/billing/options.go create mode 100644 internal/billing/options_test.go create mode 100644 internal/billing/query.go create mode 100644 internal/billing/repository.go create mode 100644 internal/billing/repository_test.go diff --git a/internal/billing/active_users.go b/internal/billing/active_users.go new file mode 100644 index 000000000..6549726e4 --- /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 uint64 +} 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..a792313c2 --- /dev/null +++ b/internal/billing/query.go @@ -0,0 +1,22 @@ +// 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 + where start_time >= @start_time +` + activeUsersWithStartTimeAndEndTimeQuery = ` +select * + from hcp_billing_monthly_active_users_all + where start_time >= @start_time + and end_time < @end_time +` +) diff --git a/internal/billing/repository.go b/internal/billing/repository.go new file mode 100644 index 000000000..bdff024c8 --- /dev/null +++ b/internal/billing/repository.go @@ -0,0 +1,121 @@ +// 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/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 + writer db.Writer +} + +// 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, w db.Writer) (*Repository, error) { + const op = "billing.NewRepository" + switch { + case r == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db reader") + case w == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db writer") + } + + return &Repository{ + reader: r, + writer: w, + }, 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", 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", opts.withEndTime)) + } + + var activeUsers []ActiveUsers + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + _, err := w.Exec(ctx, `set timezone to 'utc'`, nil) + if err != nil { + return err + } + + rows, err := r.Query(ctx, query, args) + if err != nil { + return err + } + defer rows.Close() + if err := rows.Err(); err != nil { + return err + } + for rows.Next() { + var start_time time.Time + var end_time time.Time + var count uint64 + if err := rows.Scan(&start_time, &end_time, &count); err != nil { + return err + } + + // set start and end times to be in UTC + auUTC := ActiveUsers{ + ActiveUsersCount: count, + StartTime: start_time.UTC(), + EndTime: end_time.UTC(), + } + activeUsers = append(activeUsers, auUTC) + + } + return nil + }); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return activeUsers, nil +} diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go new file mode 100644 index 000000000..f84bde451 --- /dev/null +++ b/internal/billing/repository_test.go @@ -0,0 +1,264 @@ +// 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" +) + +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; + ` + +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, + w: rw, + }, + want: &Repository{ + reader: rw, + writer: rw, + }, + }, + { + name: "nil-reader", + args: args{ + r: nil, + w: rw, + }, + want: nil, + wantIsErr: errors.InvalidParameter, + wantErrMsg: "billing.NewRepository: nil db reader: parameter violation: error #100", + }, + { + name: "nil-writer", + args: args{ + r: rw, + w: nil, + }, + want: nil, + wantIsErr: errors.InvalidParameter, + wantErrMsg: "billing.NewRepository: nil db writer: 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, tt.args.w) + 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") + rw := db.New(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) + + db, err := conn.SqlDB(ctx) + if err != nil { + t.Errorf("error getting db connection %s", err) + } + _, err = db.Exec(insertQuery) + if err != nil { + t.Errorf("error %s", err) + } + + t.Run("valid-no-options", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + activeUsers, err := repo.MonthlyActiveUsers(ctx) + assert.NoError(t, err) + require.Len(t, activeUsers, 2) + // check counts for the last two months + require.Equal(t, uint64(0), activeUsers[0].ActiveUsersCount) + require.Equal(t, uint64(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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + 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, uint64(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, uint64(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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo), WithEndTime(&oneMonthAgo)) + assert.NoError(t, err) + // since the end time is exclusive, we should only get one record of active users + // for the month of three months ago + require.Len(t, activeUsers, 1) + require.Equal(t, uint64(6), activeUsers[0].ActiveUsersCount) + }) + + t.Run("invalid-end-time-without-start-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, 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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, 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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, 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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, 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, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, 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()) + }) +} From d84eb0151653d587a0f03da477841baf05397e53 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Tue, 20 Feb 2024 16:54:07 +0000 Subject: [PATCH 15/27] feat(billing): Add sql functions for monthly active user counts This adds two sql functions that can be used to access the views to retrieve monthly active user counts. The functions ensure that any time calculates are performed in UTC, so that the month bounds align with the start of the month in UTC. 1. hcp_billing_monthly_active_users_last_2_months() will return monthly active users for the current and previous month. 2. hcp_billing_monthly_active_users_all() will return monthly active users for all months. This function can also be provided a start time and or end time to filter the results to a specific range. --- .../82/08_hcp_user_billing_view.up.sql | 54 ++++++ .../monthly_active_users_all_timezone.sql | 179 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 internal/db/sqltest/tests/hcp/billing/monthly_active_users_all_timezone.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql b/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql index 0c363f878..bf8b04016 100644 --- a/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql +++ b/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql @@ -64,4 +64,58 @@ begin; '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/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..96ca00099 --- /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 '2 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 '2 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; From 280e9abb89dc3b23af5c1a92987895e3afd5a152 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 21 Feb 2024 13:28:44 -0500 Subject: [PATCH 16/27] feat(billing): Add service handlers for monthly active user counts --- Makefile | 2 + internal/billing/repository_test.go | 120 ++------ internal/billing/testing.go | 104 +++++++ internal/daemon/controller/common/common.go | 2 + internal/daemon/controller/controller.go | 5 + internal/daemon/controller/handler.go | 11 + internal/daemon/controller/handler_test.go | 1 + .../handlers/billing/billing_service.go | 115 ++++++++ .../handlers/billing/billing_service_test.go | 164 +++++++++++ internal/gen/controller.swagger.json | 71 +++++ .../api/services/billing_service.pb.go | 264 ++++++++++++++++++ .../api/services/billing_service.pb.gw.go | 173 ++++++++++++ .../api/services/billing_service_grpc.pb.go | 122 ++++++++ internal/perms/acl_test.go | 2 +- .../api/resources/billing/v1/billing.proto | 21 ++ .../api/services/v1/billing_service.proto | 37 +++ internal/ratelimit/config.go | 1 + internal/types/action/action.go | 3 + internal/types/resource/resource.go | 3 + .../api/resources/billing/billing.pb.go | 185 ++++++++++++ 20 files changed, 1304 insertions(+), 102 deletions(-) create mode 100644 internal/billing/testing.go create mode 100644 internal/daemon/controller/handlers/billing/billing_service.go create mode 100644 internal/daemon/controller/handlers/billing/billing_service_test.go create mode 100644 internal/gen/controller/api/services/billing_service.pb.go create mode 100644 internal/gen/controller/api/services/billing_service.pb.gw.go create mode 100644 internal/gen/controller/api/services/billing_service_grpc.pb.go create mode 100644 internal/proto/controller/api/resources/billing/v1/billing.proto create mode 100644 internal/proto/controller/api/services/v1/billing_service.proto create mode 100644 sdk/pbs/controller/api/resources/billing/billing.pb.go 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/internal/billing/repository_test.go b/internal/billing/repository_test.go index f84bde451..6b370324b 100644 --- a/internal/billing/repository_test.go +++ b/internal/billing/repository_test.go @@ -15,76 +15,6 @@ import ( "github.com/hashicorp/boundary/internal/errors" ) -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; - ` - func TestRepository_New(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) @@ -153,25 +83,16 @@ func TestRepository_New(t *testing.T) { func TestRepository_MonthlyActiveUsers(t *testing.T) { ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") - rw := db.New(conn) + + 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) - db, err := conn.SqlDB(ctx) - if err != nil { - t.Errorf("error getting db connection %s", err) - } - _, err = db.Exec(insertQuery) - if err != nil { - t.Errorf("error %s", err) - } - t.Run("valid-no-options", func(t *testing.T) { - repo, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) + repo := TestRepo(t, conn) activeUsers, err := repo.MonthlyActiveUsers(ctx) assert.NoError(t, err) require.Len(t, activeUsers, 2) @@ -188,8 +109,7 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { }) t.Run("valid-with-start-time", func(t *testing.T) { - repo, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) + repo := TestRepo(t, conn) activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo)) assert.NoError(t, err) require.Len(t, activeUsers, 4) @@ -212,52 +132,50 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { }) t.Run("valid-with-start-and-end-time", func(t *testing.T) { - repo, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) + repo := TestRepo(t, conn) activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo), WithEndTime(&oneMonthAgo)) assert.NoError(t, err) // since the end time is exclusive, we should only get one record of active users // for the month of three months ago + 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.Len(t, activeUsers, 1) require.Equal(t, uint64(6), activeUsers[0].ActiveUsersCount) + assert.Equal(t, expectedStartTime, activeUsers[0].StartTime) + assert.Equal(t, expectedEndTime, activeUsers[0].EndTime) }) t.Run("invalid-end-time-without-start-time", func(t *testing.T) { - repo, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) - _, err = repo.MonthlyActiveUsers(ctx, WithEndTime(&oneMonthAgo)) + 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, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) - _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&threeMonthsAgo)) + 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, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) - _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&oneMonthAgo)) + 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, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) - _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&midMonth)) + 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, err := NewRepository(ctx, rw, rw) - assert.NoError(t, err) - _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&midMonth)) + 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..a657f8797 --- /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, 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/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..94e60bf9b 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, 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..8173be129 --- /dev/null +++ b/internal/daemon/controller/handlers/billing/billing_service.go @@ -0,0 +1,115 @@ +// 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/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" + pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/billing" + "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, errors.New(ctx, errors.InvalidTimeStamp, op, "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, errors.New(ctx, errors.InvalidTimeStamp, op, "end time is in an invalid format") + } + endTime = &et + } + + months, err := repo.MonthlyActiveUsers( + ctx, + billing.WithStartTime(startTime), + billing.WithEndTime(endTime), + ) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + 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)} + 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..f9b41b0c8 --- /dev/null +++ b/internal/daemon/controller/handlers/billing/billing_service_test.go @@ -0,0 +1,164 @@ +// 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()-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/gen/controller.swagger.json b/internal/gen/controller.swagger.json index a2e7702dd..7a1757773 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": "string", + "format": "uint64", + "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": { @@ -8785,6 +8844,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.ReadCertificateAuthorityResponse": { "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..90674badc --- /dev/null +++ b/internal/gen/controller/api/services/billing_service.pb.go @@ -0,0 +1,264 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.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" + _ "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 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, + 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, + 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..02648307e --- /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. + uint64 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..49e309f9a --- /dev/null +++ b/internal/proto/controller/api/services/v1/billing_service.proto @@ -0,0 +1,37 @@ +// 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 "google/protobuf/timestamp.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/types/action/action.go b/internal/types/action/action.go index eb99b4c79..5805b4633 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 = 57 // 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..e9cb5609d 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] } @@ -124,6 +126,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..683809d44 --- /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.31.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 uint64 `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() uint64 { + 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, 0x04, 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 +} From 5aafe66c3c855c00733508376295cbd9d1b74852 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 21 Feb 2024 21:14:27 +0000 Subject: [PATCH 17/27] test(ratelimit): Update tests due to new endpoint --- internal/daemon/controller/rate_limiter_test.go | 2 +- .../testdata/Test_rateLimiterConfig_writeSysEvent/defaults.json | 2 +- .../testdata/Test_rateLimiterConfig_writeSysEvent/override.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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", From 5405a275ab5a665d5b57696bb44ca06c9f6d6cff Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 16 Feb 2024 16:19:25 +0000 Subject: [PATCH 18/27] feat(census): Update census tables to support additional metrics --- .../oss/postgres/72/01_census.up.sql | 1 + .../postgres/82/09_cenus_add_metric.up.sql | 47 +++++++++++++++++++ .../db/sqltest/tests/census/last_uploaded.sql | 47 +++++++++++++++---- 3 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 internal/db/schema/migrations/oss/postgres/82/09_cenus_add_metric.up.sql 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/82/09_cenus_add_metric.up.sql b/internal/db/schema/migrations/oss/postgres/82/09_cenus_add_metric.up.sql new file mode 100644 index 000000000..5b1ce7b77 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/82/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/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(); From a4b34f67c8bb9f57c3dcf969296ee5695770d5b8 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 16 Feb 2024 16:40:44 +0000 Subject: [PATCH 19/27] feat(census): Add support for multiple metrics --- internal/census/census_job.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/census/census_job.go b/internal/census/census_job.go index a29efdf9c..f7261feab 100644 --- a/internal/census/census_job.go +++ b/internal/census/census_job.go @@ -18,11 +18,12 @@ 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 + sessionsAgent any + activeUsersAgent any + eventCtx context.Context } func newCensusJob(ctx context.Context, lurEnabled bool, r db.Reader, w db.Writer) (*censusJob, error) { @@ -35,11 +36,12 @@ 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, + sessionsAgent: nil, + activeUsersAgent: nil, + eventCtx: ctx, }, nil } From 7c14e42897d26f6e172dd7fcc354facb63273972 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Tue, 20 Feb 2024 21:28:01 +0000 Subject: [PATCH 20/27] refact(census): Make emitting events stateful for a census job --- internal/census/census_job.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/census/census_job.go b/internal/census/census_job.go index f7261feab..dcca07118 100644 --- a/internal/census/census_job.go +++ b/internal/census/census_job.go @@ -21,6 +21,7 @@ type censusJob struct { r db.Reader w db.Writer lurEnabled bool + emitEvents bool sessionsAgent any activeUsersAgent any eventCtx context.Context @@ -39,6 +40,7 @@ func newCensusJob(ctx context.Context, lurEnabled bool, r db.Reader, w db.Writer r: r, w: w, lurEnabled: lurEnabled, + emitEvents: false, sessionsAgent: nil, activeUsersAgent: nil, eventCtx: ctx, From e09bf09a95db995538291c5accd0c6607ba420a4 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Tue, 20 Feb 2024 21:36:05 +0000 Subject: [PATCH 21/27] refact(billing): Use sql functions instead of views These functions will ensure that UTC time is used for billing queries, eliminating the need for the repository to set the timezone. This also means the repository no longern needs a writer. --- internal/billing/query.go | 9 ++-- internal/billing/repository.go | 56 +++++++++--------------- internal/billing/repository_test.go | 15 +------ internal/billing/testing.go | 2 +- internal/daemon/controller/controller.go | 2 +- 5 files changed, 27 insertions(+), 57 deletions(-) diff --git a/internal/billing/query.go b/internal/billing/query.go index a792313c2..d74b18cb1 100644 --- a/internal/billing/query.go +++ b/internal/billing/query.go @@ -6,17 +6,14 @@ package billing const ( activeUsersLastTwoMonthsQuery = ` select * - from hcp_billing_monthly_active_users_last_2_months + from hcp_billing_monthly_active_users_last_2_months(); ` activeUsersWithStartTimeQuery = ` select * - from hcp_billing_monthly_active_users_all - where start_time >= @start_time + from hcp_billing_monthly_active_users_all(@start_time); ` activeUsersWithStartTimeAndEndTimeQuery = ` select * - from hcp_billing_monthly_active_users_all - where start_time >= @start_time - and end_time < @end_time + from hcp_billing_monthly_active_users_all(@start_time, @end_time); ` ) diff --git a/internal/billing/repository.go b/internal/billing/repository.go index bdff024c8..0c9a9a659 100644 --- a/internal/billing/repository.go +++ b/internal/billing/repository.go @@ -24,23 +24,19 @@ import ( // cumulative count up to the present date. type Repository struct { reader db.Reader - writer db.Writer } // 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, w db.Writer) (*Repository, error) { +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") - case w == nil: - return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db writer") } return &Repository{ reader: r, - writer: w, }, nil } @@ -81,40 +77,30 @@ func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]A } var activeUsers []ActiveUsers - if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { - _, err := w.Exec(ctx, `set timezone to 'utc'`, nil) - if err != nil { - return err + 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 uint64 + if err := rows.Scan(&startTime, &endTime, &count); err != nil { + return nil, err } - rows, err := r.Query(ctx, query, args) - if err != nil { - return err + // set start and end times to be in UTC + auUTC := ActiveUsers{ + ActiveUsersCount: count, + StartTime: startTime.UTC(), + EndTime: endTime.UTC(), } - defer rows.Close() - if err := rows.Err(); err != nil { - return err - } - for rows.Next() { - var start_time time.Time - var end_time time.Time - var count uint64 - if err := rows.Scan(&start_time, &end_time, &count); err != nil { - return err - } + activeUsers = append(activeUsers, auUTC) - // set start and end times to be in UTC - auUTC := ActiveUsers{ - ActiveUsersCount: count, - StartTime: start_time.UTC(), - EndTime: end_time.UTC(), - } - activeUsers = append(activeUsers, auUTC) - - } - return nil - }); err != nil { - return nil, errors.Wrap(ctx, err, op) } return activeUsers, nil diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go index 6b370324b..4ff411446 100644 --- a/internal/billing/repository_test.go +++ b/internal/billing/repository_test.go @@ -35,39 +35,26 @@ func TestRepository_New(t *testing.T) { name: "valid", args: args{ r: rw, - w: rw, }, want: &Repository{ reader: rw, - writer: rw, }, }, { name: "nil-reader", args: args{ r: nil, - w: rw, }, want: nil, wantIsErr: errors.InvalidParameter, wantErrMsg: "billing.NewRepository: nil db reader: parameter violation: error #100", }, - { - name: "nil-writer", - args: args{ - r: rw, - w: nil, - }, - want: nil, - wantIsErr: errors.InvalidParameter, - wantErrMsg: "billing.NewRepository: nil db writer: 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, tt.args.w) + 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()) diff --git a/internal/billing/testing.go b/internal/billing/testing.go index a657f8797..92b765a5e 100644 --- a/internal/billing/testing.go +++ b/internal/billing/testing.go @@ -88,7 +88,7 @@ func TestRepo(t testing.TB, conn *db.DB) *Repository { require := require.New(t) rw := db.New(conn) - repo, err := NewRepository(ctx, rw, rw) + repo, err := NewRepository(ctx, rw) require.NoError(err) return repo } diff --git a/internal/daemon/controller/controller.go b/internal/daemon/controller/controller.go index 94e60bf9b..5e0f90b58 100644 --- a/internal/daemon/controller/controller.go +++ b/internal/daemon/controller/controller.go @@ -451,7 +451,7 @@ func New(ctx context.Context, conf *Config) (*Controller, error) { return server.NewRepositoryStorage(ctx, dbase, dbase, c.kms) } c.BillingRepoFn = func() (*billing.Repository, error) { - return billing.NewRepository(ctx, dbase, dbase) + return billing.NewRepository(ctx, dbase) } // Check that credentials are available at startup, to avoid some harmless From 0f765e7149cc7663369a487f88a71e3e4d8ef987 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 21 Feb 2024 16:13:05 +0000 Subject: [PATCH 22/27] feat(timestamp): Support -/+infinity for Valuer When converting a timestamp.Timestamp for use in SQL, this now correctly converts the NegativeInfinity and PositiveInfinity constants into the corresponding `-infinity` and `infinity` SQL constants. --- internal/db/timestamp/scanners.go | 10 ++++++++-- internal/db/timestamp/scanners_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) 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 From edfd8b5f5474d2c76a018f7f7e19e1c7a5916539 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 21 Feb 2024 16:16:07 +0000 Subject: [PATCH 23/27] feat(billing): Support -/+ infinity This updates the billing repository to use timestmap.Timestamp when constructing the queries to retrieve monthly active users. This allows for specifying NegativeInfinity to easily retrieve all monthly active user counts. --- internal/billing/repository.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/billing/repository.go b/internal/billing/repository.go index 0c9a9a659..80385dc55 100644 --- a/internal/billing/repository.go +++ b/internal/billing/repository.go @@ -9,6 +9,7 @@ import ( "time" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" ) @@ -65,7 +66,7 @@ func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]A } query = activeUsersWithStartTimeQuery args = append(args, - sql.Named("start_time", opts.withStartTime)) + 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) { @@ -73,7 +74,7 @@ func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]A } query = activeUsersWithStartTimeAndEndTimeQuery args = append(args, - sql.Named("end_time", opts.withEndTime)) + sql.Named("end_time", timestamp.New(*opts.withEndTime))) } var activeUsers []ActiveUsers From e6c3795f910b103ff76b4961940bb8e06f46e9cf Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 21 Feb 2024 21:46:21 +0000 Subject: [PATCH 24/27] refact(db): Renumber migrations for billing --- .../migrations/oss/postgres/{82 => 84}/01_wh_auth_token.up.sql | 0 .../oss/postgres/{82 => 84}/02_wh_upsert_user_refact.up.sql | 0 .../oss/postgres/{82 => 84}/03_auth_token_issued.up.sql | 0 .../oss/postgres/{82 => 84}/04_wh_auth_token_insert.up.sql | 0 .../oss/postgres/{82 => 84}/05_wh_auth_token_update.up.sql | 0 .../oss/postgres/{82 => 84}/06_wh_auth_token_delete.up.sql | 0 .../oss/postgres/{82 => 84}/07_vault_ssh_cert_default.up.sql | 0 .../oss/postgres/{82 => 84}/08_hcp_user_billing_view.up.sql | 0 .../migrations/oss/postgres/{82 => 84}/09_cenus_add_metric.up.sql | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/01_wh_auth_token.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/02_wh_upsert_user_refact.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/03_auth_token_issued.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/04_wh_auth_token_insert.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/05_wh_auth_token_update.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/06_wh_auth_token_delete.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/07_vault_ssh_cert_default.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/08_hcp_user_billing_view.up.sql (100%) rename internal/db/schema/migrations/oss/postgres/{82 => 84}/09_cenus_add_metric.up.sql (100%) diff --git a/internal/db/schema/migrations/oss/postgres/82/01_wh_auth_token.up.sql b/internal/db/schema/migrations/oss/postgres/84/01_wh_auth_token.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/01_wh_auth_token.up.sql rename to internal/db/schema/migrations/oss/postgres/84/01_wh_auth_token.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/02_wh_upsert_user_refact.up.sql b/internal/db/schema/migrations/oss/postgres/84/02_wh_upsert_user_refact.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/02_wh_upsert_user_refact.up.sql rename to internal/db/schema/migrations/oss/postgres/84/02_wh_upsert_user_refact.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/03_auth_token_issued.up.sql b/internal/db/schema/migrations/oss/postgres/84/03_auth_token_issued.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/03_auth_token_issued.up.sql rename to internal/db/schema/migrations/oss/postgres/84/03_auth_token_issued.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/04_wh_auth_token_insert.up.sql b/internal/db/schema/migrations/oss/postgres/84/04_wh_auth_token_insert.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/04_wh_auth_token_insert.up.sql rename to internal/db/schema/migrations/oss/postgres/84/04_wh_auth_token_insert.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/05_wh_auth_token_update.up.sql b/internal/db/schema/migrations/oss/postgres/84/05_wh_auth_token_update.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/05_wh_auth_token_update.up.sql rename to internal/db/schema/migrations/oss/postgres/84/05_wh_auth_token_update.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/06_wh_auth_token_delete.up.sql b/internal/db/schema/migrations/oss/postgres/84/06_wh_auth_token_delete.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/06_wh_auth_token_delete.up.sql rename to internal/db/schema/migrations/oss/postgres/84/06_wh_auth_token_delete.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/07_vault_ssh_cert_default.up.sql b/internal/db/schema/migrations/oss/postgres/84/07_vault_ssh_cert_default.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/07_vault_ssh_cert_default.up.sql rename to internal/db/schema/migrations/oss/postgres/84/07_vault_ssh_cert_default.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql b/internal/db/schema/migrations/oss/postgres/84/08_hcp_user_billing_view.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/08_hcp_user_billing_view.up.sql rename to internal/db/schema/migrations/oss/postgres/84/08_hcp_user_billing_view.up.sql diff --git a/internal/db/schema/migrations/oss/postgres/82/09_cenus_add_metric.up.sql b/internal/db/schema/migrations/oss/postgres/84/09_cenus_add_metric.up.sql similarity index 100% rename from internal/db/schema/migrations/oss/postgres/82/09_cenus_add_metric.up.sql rename to internal/db/schema/migrations/oss/postgres/84/09_cenus_add_metric.up.sql From 111dc87eadca5b98864de1765bcb70496ae14ca5 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Thu, 22 Feb 2024 20:34:00 +0000 Subject: [PATCH 25/27] feat(billing): Fix end time comparision for all view --- internal/billing/repository_test.go | 14 +++++++++----- .../handlers/billing/billing_service_test.go | 5 +++++ .../postgres/84/08_hcp_user_billing_view.up.sql | 4 ++-- .../billing/monthly_active_users_all_timezone.sql | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go index 4ff411446..d6a0e1758 100644 --- a/internal/billing/repository_test.go +++ b/internal/billing/repository_test.go @@ -122,14 +122,18 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { repo := TestRepo(t, conn) activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo), WithEndTime(&oneMonthAgo)) assert.NoError(t, err) - // since the end time is exclusive, we should only get one record of active users - // for the month of three months ago - 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.Len(t, activeUsers, 1) + 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, uint64(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, uint64(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) { diff --git a/internal/daemon/controller/handlers/billing/billing_service_test.go b/internal/daemon/controller/handlers/billing/billing_service_test.go index f9b41b0c8..07ec26b74 100644 --- a/internal/daemon/controller/handlers/billing/billing_service_test.go +++ b/internal/daemon/controller/handlers/billing/billing_service_test.go @@ -102,6 +102,11 @@ func Test_MonthlyActiveUsers(t *testing.T) { 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)), 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 index bf8b04016..656677aed 100644 --- 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 @@ -92,7 +92,7 @@ begin; return query select * from hcp_billing_monthly_active_users_all where start_time >= p_start_time - and end_time < p_end_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 @@ -100,7 +100,7 @@ begin; when p_end_time is not null then return query select * from hcp_billing_monthly_active_users_all - where end_time < p_end_time; + where end_time <= p_end_time; else return query select * from hcp_billing_monthly_active_users_all; 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 index 96ca00099..c6828a98a 100644 --- 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 @@ -144,7 +144,7 @@ begin; select results_eq( $$ select * from hcp_billing_monthly_active_users_all(date_trunc('month', now() - interval '5 month', 'utc'), - date_trunc('month', now() - interval '2 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), @@ -154,7 +154,7 @@ begin; select results_eq( $$ select * from hcp_billing_monthly_active_users_all(null, - date_trunc('month', now() - interval '2 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), From 2e69d59dd2c8edd452ddf753b4fbf8a41d963b76 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 23 Feb 2024 09:39:23 -0500 Subject: [PATCH 26/27] feat(billing): Add cli subcommand for monthly active user counts --- api/billing/active_users.gen.go | 35 +++ api/billing/custom.go | 63 ++++++ api/billing/option.gen.go | 104 +++++++++ internal/api/genapi/input.go | 37 ++++ internal/api/genapi/templates.go | 5 +- internal/billing/active_users.go | 2 +- internal/billing/repository.go | 3 +- internal/billing/repository_test.go | 12 +- internal/cmd/commands.go | 12 ++ .../cmd/commands/billingcmd/billing.gen.go | 201 ++++++++++++++++++ internal/cmd/commands/billingcmd/funcs.go | 156 ++++++++++++++ internal/cmd/common/help.go | 1 + internal/cmd/gencli/input.go | 12 ++ internal/cmd/gencli/templates.go | 12 +- .../handlers/billing/billing_service.go | 18 +- internal/gen/controller.swagger.json | 22 +- .../api/services/billing_service.pb.go | 73 +++---- .../api/resources/billing/v1/billing.proto | 2 +- .../api/services/v1/billing_service.proto | 1 - internal/tests/cli/boundary/_billing.bash | 18 ++ internal/tests/cli/boundary/billing.bats | 61 ++++++ internal/types/action/action.go | 2 +- internal/types/resource/resource.go | 4 + .../api/resources/billing/billing.pb.go | 8 +- 24 files changed, 791 insertions(+), 73 deletions(-) create mode 100644 api/billing/active_users.gen.go create mode 100644 api/billing/custom.go create mode 100644 api/billing/option.gen.go create mode 100644 internal/cmd/commands/billingcmd/billing.gen.go create mode 100644 internal/cmd/commands/billingcmd/funcs.go create mode 100644 internal/tests/cli/boundary/_billing.bash create mode 100644 internal/tests/cli/boundary/billing.bats 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/billing/active_users.go b/internal/billing/active_users.go index 6549726e4..dec4c826e 100644 --- a/internal/billing/active_users.go +++ b/internal/billing/active_users.go @@ -11,5 +11,5 @@ import "time" type ActiveUsers struct { StartTime time.Time EndTime time.Time - ActiveUsersCount uint64 + ActiveUsersCount uint32 } diff --git a/internal/billing/repository.go b/internal/billing/repository.go index 80385dc55..c77dfea26 100644 --- a/internal/billing/repository.go +++ b/internal/billing/repository.go @@ -89,7 +89,7 @@ func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]A for rows.Next() { var startTime time.Time var endTime time.Time - var count uint64 + var count uint32 if err := rows.Scan(&startTime, &endTime, &count); err != nil { return nil, err } @@ -101,7 +101,6 @@ func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]A EndTime: endTime.UTC(), } activeUsers = append(activeUsers, auUTC) - } return activeUsers, nil diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go index d6a0e1758..a9519543c 100644 --- a/internal/billing/repository_test.go +++ b/internal/billing/repository_test.go @@ -84,8 +84,8 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { assert.NoError(t, err) require.Len(t, activeUsers, 2) // check counts for the last two months - require.Equal(t, uint64(0), activeUsers[0].ActiveUsersCount) - require.Equal(t, uint64(6), activeUsers[1].ActiveUsersCount) + 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) @@ -103,7 +103,7 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { for i := 0; i < 4; i++ { // check counts for the last four months if i == 0 { - assert.Equal(t, uint64(0), activeUsers[i].ActiveUsersCount) + 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) @@ -111,7 +111,7 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { // 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, uint64(6), activeUsers[i].ActiveUsersCount) + assert.Equal(t, uint32(6), activeUsers[i].ActiveUsersCount) assert.Equal(t, expectedStartTime, activeUsers[i].StartTime) assert.Equal(t, expectedEndTime, activeUsers[i].EndTime) } @@ -125,13 +125,13 @@ func TestRepository_MonthlyActiveUsers(t *testing.T) { 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, uint64(6), activeUsers[0].ActiveUsersCount) + 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, uint64(6), activeUsers[1].ActiveUsersCount) + require.Equal(t, uint32(6), activeUsers[1].ActiveUsersCount) assert.Equal(t, expectedStartTime, activeUsers[1].StartTime) assert.Equal(t, expectedEndTime, activeUsers[1].EndTime) }) 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/handlers/billing/billing_service.go b/internal/daemon/controller/handlers/billing/billing_service.go index 8173be129..303d49cbc 100644 --- a/internal/daemon/controller/handlers/billing/billing_service.go +++ b/internal/daemon/controller/handlers/billing/billing_service.go @@ -10,11 +10,14 @@ import ( "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" ) @@ -75,17 +78,20 @@ func (s Service) MonthlyActiveUsers(ctx context.Context, req *pbs.MonthlyActiveU if req.GetStartTime() != "" { st, err := time.Parse("2006-01", req.GetStartTime()) if err != nil { - return nil, errors.New(ctx, errors.InvalidTimeStamp, op, "start time is in an invalid format") + 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, errors.New(ctx, errors.InvalidTimeStamp, op, "end time is in an invalid format") + 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, @@ -93,7 +99,7 @@ func (s Service) MonthlyActiveUsers(ctx context.Context, req *pbs.MonthlyActiveU billing.WithEndTime(endTime), ) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, err.Error()) } var activeUsers []*pb.ActiveUsers @@ -110,6 +116,10 @@ func (s Service) MonthlyActiveUsers(ctx context.Context, req *pbs.MonthlyActiveU } func (s Service) authResult(ctx context.Context, a action.Type) auth.VerifyResults { - opts := []auth.Option{auth.WithType(resource.Billing), auth.WithAction(a)} + opts := []auth.Option{ + auth.WithType(resource.Billing), + auth.WithAction(a), + auth.WithScopeId(scope.Global.String()), + } return auth.Verify(ctx, opts...) } diff --git a/internal/gen/controller.swagger.json b/internal/gen/controller.swagger.json index 7a1757773..e1dc4cc49 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -4794,8 +4794,8 @@ "type": "object", "properties": { "count": { - "type": "string", - "format": "uint64", + "type": "integer", + "format": "int64", "description": "Output only. The number of active users between the start time and end time.", "readOnly": true }, @@ -8835,15 +8835,6 @@ } } }, - "controller.api.services.v1.ReApplyStoragePolicyResponse": { - "type": "object", - "properties": { - "item": { - "$ref": "#/definitions/controller.api.resources.sessionrecordings.v1.SessionRecording", - "description": "The requested recording." - } - } - }, "controller.api.services.v1.MonthlyActiveUsersResponse": { "type": "object", "properties": { @@ -8856,6 +8847,15 @@ } } }, + "controller.api.services.v1.ReApplyStoragePolicyResponse": { + "type": "object", + "properties": { + "item": { + "$ref": "#/definitions/controller.api.resources.sessionrecordings.v1.SessionRecording", + "description": "The requested recording." + } + } + }, "controller.api.services.v1.ReadCertificateAuthorityResponse": { "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 index 90674badc..c6aa91891 100644 --- a/internal/gen/controller/api/services/billing_service.pb.go +++ b/internal/gen/controller/api/services/billing_service.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 +// protoc-gen-go v1.32.0 // protoc (unknown) // source: controller/api/services/v1/billing_service.proto @@ -15,7 +15,6 @@ import ( _ "google.golang.org/genproto/googleapis/api/annotations" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - _ "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) @@ -144,43 +143,41 @@ var file_controller_api_services_v1_billing_service_proto_rawDesc = []byte{ 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, - 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, - 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, + 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, 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, + 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 ( diff --git a/internal/proto/controller/api/resources/billing/v1/billing.proto b/internal/proto/controller/api/resources/billing/v1/billing.proto index 02648307e..e9e391230 100644 --- a/internal/proto/controller/api/resources/billing/v1/billing.proto +++ b/internal/proto/controller/api/resources/billing/v1/billing.proto @@ -11,7 +11,7 @@ option go_package = "github.com/hashicorp/boundary/sdk/pbs/controller/api/resour message ActiveUsers { // Output only. The number of active users between the start time and end time. - uint64 count = 1; // @gotags: `class:"public"` + 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" diff --git a/internal/proto/controller/api/services/v1/billing_service.proto b/internal/proto/controller/api/services/v1/billing_service.proto index 49e309f9a..06607d484 100644 --- a/internal/proto/controller/api/services/v1/billing_service.proto +++ b/internal/proto/controller/api/services/v1/billing_service.proto @@ -7,7 +7,6 @@ package controller.api.services.v1; import "controller/api/resources/billing/v1/billing.proto"; import "google/api/annotations.proto"; -import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; option go_package = "github.com/hashicorp/boundary/internal/gen/controller/api/services;services"; 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 5805b4633..da557a0c7 100644 --- a/internal/types/action/action.go +++ b/internal/types/action/action.go @@ -74,7 +74,7 @@ const ( AddGrantScopes Type = 60 SetGrantScopes Type = 61 RemoveGrantScopes Type = 62 - MonthlyActiveUsers Type = 57 + MonthlyActiveUsers Type = 63 // When adding new actions, be sure to update: // diff --git a/internal/types/resource/resource.go b/internal/types/resource/resource.go index e9cb5609d..f9ec1f3c2 100644 --- a/internal/types/resource/resource.go +++ b/internal/types/resource/resource.go @@ -85,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" } @@ -96,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 diff --git a/sdk/pbs/controller/api/resources/billing/billing.pb.go b/sdk/pbs/controller/api/resources/billing/billing.pb.go index 683809d44..b452d526d 100644 --- a/sdk/pbs/controller/api/resources/billing/billing.pb.go +++ b/sdk/pbs/controller/api/resources/billing/billing.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 +// protoc-gen-go v1.32.0 // protoc (unknown) // source: controller/api/resources/billing/v1/billing.proto @@ -30,7 +30,7 @@ type ActiveUsers struct { unknownFields protoimpl.UnknownFields // Output only. The number of active users between the start time and end time. - Count uint64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty" class:"public"` // @gotags: `class:"public"` + 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. @@ -69,7 +69,7 @@ func (*ActiveUsers) Descriptor() ([]byte, []int) { return file_controller_api_resources_billing_v1_billing_proto_rawDescGZIP(), []int{0} } -func (x *ActiveUsers) GetCount() uint64 { +func (x *ActiveUsers) GetCount() uint32 { if x != nil { return x.Count } @@ -102,7 +102,7 @@ var file_controller_api_resources_billing_v1_billing_proto_rawDesc = []byte{ 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, 0x04, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, + 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, From 4b0e7ab5a3db805a67e66b7379b0908f7d61e488 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 23 Feb 2024 15:49:49 +0000 Subject: [PATCH 27/27] chore(authtoken): Fix lint errors in tests --- internal/authtoken/authtoken_test.go | 3 ++- internal/authtoken/repository_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/authtoken/authtoken_test.go b/internal/authtoken/authtoken_test.go index 9a0736b98..898193d15 100644 --- a/internal/authtoken/authtoken_test.go +++ b/internal/authtoken/authtoken_test.go @@ -139,7 +139,8 @@ func TestAuthToken_DbCreate(t *testing.T) { am := password.TestAuthMethods(t, conn, org.GetPublicId(), 1)[0] acct := password.TestAccount(t, conn, am.GetPublicId(), "name1") createdAuthToken := TestAuthToken(t, conn, kms, org.GetPublicId()) - iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{acct.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 bf15ee11d..0b34a066e 100644 --- a/internal/authtoken/repository_test.go +++ b/internal/authtoken/repository_test.go @@ -905,7 +905,7 @@ func Test_CloseExpiredPendingTokens(t *testing.T) { user, _, err = iamRepo.LookupUser(ctx, user.GetPublicId()) require.NoError(t, err) - iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{accts[i].GetPublicId()}) + _, _ = iamRepo.AddUserAccounts(ctx, user.GetPublicId(), user.GetVersion(), []string{accts[i].GetPublicId()}) at := allocAuthToken() id, err := NewAuthTokenId(ctx)