diff --git a/CHANGELOG.md b/CHANGELOG.md index 26371016a2..bb5f09851c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. ## Next +### Added dependency + +* postgres citext dependency added to enable aliases to be globally unique + in a case insensitive way. + ## 0.15.1 (2024/02/28) ### Bug Fixes diff --git a/internal/db/schema/migrations/oss/postgres/84/01_citext_extension.up.sql b/internal/db/schema/migrations/oss/postgres/84/01_citext_extension.up.sql new file mode 100644 index 0000000000..5c2140b4b9 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/01_citext_extension.up.sql @@ -0,0 +1,11 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + -- https://www.postgresql.org/docs/14/citext.html allows us to make + -- case-insensitive uniqueness constraints which is useful to us for + -- aliases. + create extension "citext"; + +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/02_alias_domain.up.sql b/internal/db/schema/migrations/oss/postgres/84/02_alias_domain.up.sql new file mode 100644 index 0000000000..0b3922853f --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/02_alias_domain.up.sql @@ -0,0 +1,46 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + -- wt_alias defines a type for alias values + create domain wt_alias as citext + constraint wt_alias_too_short + check (length(trim(value)) > 0) + constraint wt_alias_no_suround_spaces + check (trim(value) = value); + comment on domain wt_alias is + 'standard value column for an alias'; + + -- wt_target_alias defines a type for target alias values + create domain wt_target_alias as wt_alias + constraint wt_target_alias_too_long + check (length(trim(value)) < 254) + -- dns names consists of at least one label joined together by a "." + -- each label can consist of a-z 0-9 and "-" case insensitive + -- a label cannot start or end with a "-" + -- a label can be between 1 and 63 characters long + -- the final label in the dns name cannot be all numeric + -- see https://en.wikipedia.org/wiki/Domain_Name_System#Domain_name_syntax,_internationalization + -- + -- Notes on the regex: + -- "^(?!-)[a-z0-9-]{0,62}[a-z0-9]" ensures that there is at least one label + -- * [a-z0-9-]{0,62} allows for the first 0-62 characters to be a-z 0-9 or "-" + -- * (?!-) is a look ahead to ensure the string does not start with a "-" + -- * [a-z0-9] at the end ensures that the string ends with a-z 0-9 which + -- enforces that the label is at least 1 character long which, when + -- combined with the previous regex, ensures that the label is between + -- 1 and 63 characters long + -- "(\.((?!-)[a-z0-9-]{0,62}[a-z0-9]))*$" is almost identical to the + -- previous section and allows for 0 or more additional labels, all of + -- which must start with a "." + -- The constraint that the final label is not all numeric is enforced by + -- the separate constraint wt_target_alias_tld_not_only_numeric + constraint wt_target_alias_value_shape + check (value ~* '^(?!-)[a-z0-9-]{0,62}[a-z0-9](\.((?!-)[a-z0-9-]{0,62}[a-z0-9]))*$') + constraint wt_target_alias_tld_not_only_numeric + check (substring(value from '[^.]*$') !~ '^[0-9]+$'); + comment on domain wt_target_alias is + 'standard value column for a target alias'; + +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/03_alias_base_table.up.sql b/internal/db/schema/migrations/oss/postgres/84/03_alias_base_table.up.sql new file mode 100644 index 0000000000..cad2f549b2 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/03_alias_base_table.up.sql @@ -0,0 +1,70 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + create table alias ( + public_id wt_public_id primary key, + scope_id wt_scope_id not null + constraint iam_scope_fkey + references iam_scope (public_id) + on delete cascade + on update cascade + constraint alias_must_be_in_global_scope + check( + scope_id = 'global' + ), + value wt_alias not null + constraint alias_value_uq + unique, + constraint alias_scope_id_value_public_id_uq + unique(scope_id, value, public_id) + ); + comment on table alias is + 'alias is a base table for the alias type. ' + 'Each row is owned by a single scope and maps 1-to-1 to a row in one of the alias subtype tables.'; + + create trigger immutable_columns before update on alias + for each row execute procedure immutable_columns('public_id', 'scope_id'); + + -- insert_alias_subtype() is a before insert trigger + -- function for subtypes of alias + create function insert_alias_subtype() returns trigger + as $$ + begin + insert into alias + (public_id, value, scope_id) + values + (new.public_id, new.value, new.scope_id); + return new; + end; + $$ language plpgsql; + comment on function insert_alias_subtype() is + 'insert_alias_subtype() inserts a record into the base alias table when a corresponding record is inserted into the subtype table'; + + + -- delete_alias_subtype() is an after delete trigger + -- function for subtypes of alias + create function delete_alias_subtype() returns trigger + as $$ + begin + delete from alias + where + public_id = old.public_id; + return null; + end; + $$ language plpgsql; + comment on function delete_alias_subtype() is + 'delete_alias_subtype() deletes the base alias record when the corresponding record is deleted from the subtype table'; + + create function update_alias_subtype() returns trigger + as $$ + begin + update alias set value = new.value where public_id = new.public_id and new.value != value; + return new; + end; + $$ language plpgsql; + comment on function update_alias_subtype() is + 'update_alias_subtype() updates the base table value column with the new value from the subtype table'; + +commit; diff --git a/internal/db/schema/migrations/oss/postgres/84/04_alias_target.up.sql b/internal/db/schema/migrations/oss/postgres/84/04_alias_target.up.sql new file mode 100644 index 0000000000..63e15d05bb --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/84/04_alias_target.up.sql @@ -0,0 +1,113 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + create table alias_target ( + public_id wt_public_id primary key, + name wt_name, + description wt_description, + scope_id wt_scope_id not null + constraint iam_scope_fkey + references iam_scope (public_id) + on delete cascade + on update cascade, + value wt_target_alias not null, + -- destination_id is used here instead of target_id because many subtyped + -- aliases may be coming in the future. Since Boundary's method for + -- updating these fields use update masks derived from the API resource and + -- there is a single API resource for each subtype with a field that is + -- generic enough to be used by all subtypes, this column name also needs to + -- be generic enough across subtypes. + destination_id wt_public_id + constraint target_fkey + references target (public_id) + on delete set null + on update cascade, + create_time wt_timestamp, + update_time wt_timestamp, + version wt_version, + host_id wt_public_id + constraint destination_id_set_when_host_id_is_set + check( + destination_id is not null + or + ( + destination_id is null + and + host_id is null + ) + ), + constraint alias_target_scope_id_name_uq + unique(scope_id, name), + constraint alias_fkey + foreign key (scope_id, value, public_id) + references alias (scope_id, value, public_id) + on delete cascade + on update cascade + deferrable initially deferred + ); + comment on table alias_target is + 'alias_target is a subtype of alias. ' + 'Each row is owned by a single scope and maps 1-to-1 to a row in the alias table.'; + + create index alias_target_create_time_public_id_idx + on alias_target (create_time desc, public_id desc); + + create index alias_target_update_time_public_id_idx + on alias_target (update_time desc, public_id desc); + + create function delete_host_id_if_destination_id_is_null() returns trigger + as $$ + begin + if new.destination_id is null then + new.host_id = null; + end if; + return new; + end; + $$ language plpgsql; + + create trigger delete_host_id_if_destination_id_is_null before update on alias_target + for each row execute procedure delete_host_id_if_destination_id_is_null(); + + create trigger insert_alias_subtype before insert on alias_target + for each row execute procedure insert_alias_subtype(); + + create trigger update_alias_subtype after update on alias_target + for each row execute procedure update_alias_subtype(); + + create trigger delete_alias_subtype after delete on alias_target + for each row execute procedure delete_alias_subtype(); + + create trigger update_version_column after update on alias_target + for each row execute procedure update_version_column(); + + create trigger update_time_column before update on alias_target + for each row execute procedure update_time_column(); + + create trigger default_create_time_column before insert on alias_target + for each row execute procedure default_create_time(); + + create trigger immutable_columns before update on alias_target + for each row execute procedure immutable_columns('public_id', 'scope_id', 'create_time'); + + + -- Alias delete tracking tables + create table alias_target_deleted ( + public_id wt_public_id primary key, + delete_time wt_timestamp not null + ); + comment on table alias_target_deleted is + 'alias_target_deleted holds the ID and delete_time of every deleted target alias. ' + 'It is automatically trimmed of records older than 30 days by a job.'; + + create trigger insert_deleted_id after delete on alias_target + for each row execute procedure insert_deleted_id('alias_target_deleted'); + + create index alias_target_deleted_delete_time_idx on alias_target_deleted (delete_time); + + insert into oplog_ticket (name, version) + values + ('alias_target', 1); + +commit; diff --git a/internal/db/sqltest/Makefile b/internal/db/sqltest/Makefile index 01779bdcb2..0ebc984d85 100644 --- a/internal/db/sqltest/Makefile +++ b/internal/db/sqltest/Makefile @@ -32,6 +32,7 @@ TESTS ?= tests/setup/*.sql \ tests/domain/*.sql \ tests/history/*.sql \ tests/recording/*.sql \ + tests/alias/*.sql \ tests/auth/*/*.sql \ tests/purge/*.sql \ tests/pagination/*.sql \ diff --git a/internal/db/sqltest/initdb.d/01_colors_persona.sql b/internal/db/sqltest/initdb.d/01_colors_persona.sql index af32a7c83e..8a49c75daa 100644 --- a/internal/db/sqltest/initdb.d/01_colors_persona.sql +++ b/internal/db/sqltest/initdb.d/01_colors_persona.sql @@ -506,6 +506,16 @@ begin; ('p____gcolors', 'tssh______cg', 'cvl_______g1', 'brokered'), ('p____gcolors', 'tssh______cg', 'cvl__ssh__g1', 'injected_application'); + insert into alias_target + (scope_id, public_id, value, destination_id) + values + ('global', 'alt__t____cb', 'blue.tcp.target', 't_________cb'), + ('global', 'alt__t____cr', 'red.tcp.target', 't_________cr'), + ('global', 'alt__t____cg', 'green.tcp.target', 't_________cg'), + ('global', 'alt__tssh_cb', 'blue.ssh.target', 'tssh______cb'), + ('global', 'alt__tssh_cr', 'red.ssh.target', 'tssh______cr'), + ('global', 'alt__tssh_cg', 'green.ssh.target', 'tssh______cg'); + insert into session (project_id, target_id, public_id, user_id, auth_token_id, certificate, endpoint) values diff --git a/internal/db/sqltest/tests/alias/subtype_triggers.sql b/internal/db/sqltest/tests/alias/subtype_triggers.sql new file mode 100644 index 0000000000..9c5af0056c --- /dev/null +++ b/internal/db/sqltest/tests/alias/subtype_triggers.sql @@ -0,0 +1,76 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +-- alias tests triggers: +-- insert_alias_subtype +-- update_alias_subtype +-- delete_alias_subtype + +begin; +select plan(17); +select wtt_load('widgets', 'iam', 'kms', 'auth'); + +-- validate the setup data +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cb'; +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cr'; +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cg'; + +-- validate the insert triggers +prepare insert_target_alias as + insert into alias_target + (scope_id, public_id, value, destination_id) + values + ('global', 'alt__t___2cb', 'second.blue.tcp.target', 't_________cb'); +select lives_ok('insert_target_alias'); + +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t___2cb'; +select is(count(*), 1::bigint) from alias where public_id = 'alt__t___2cb'; + +-- validate the update triggers +prepare update_target_alias as + update alias_target + set value = 'updated.red.tcp.target.updated' + where public_id = 'alt__t____cr'; +select lives_ok('update_target_alias'); + +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cr' and value = 'updated.red.tcp.target.updated'; +select is(count(*), 1::bigint) from alias where public_id = 'alt__t____cr' and value = 'updated.red.tcp.target.updated'; + +-- validate delete_host_id_if_destination_id_is_null +update alias_target + set host_id = 'hst_1234567890' + where + public_id = 'alt__t____cr' + or public_id = 'alt__tssh_cr'; + +select is(count(*), 2::bigint) from alias_target where host_id = 'hst_1234567890'; + +prepare delete_destination_target as + delete from target_ssh + where public_id = 'tssh______cr'; +select lives_ok('delete_destination_target'); + +select is(count(*), 1::bigint) from alias_target where host_id = 'hst_1234567890'; + +prepare update_remove_destination_id as + update alias_target + set destination_id = null + where public_id = 'alt__t____cr'; + +select lives_ok('update_remove_destination_id'); + +select is(count(*), 0::bigint) from alias_target where host_id = 'hst_1234567890'; + + +-- validate the delete triggers +prepare delete_target_alias as + delete + from alias_target + where public_id = 'alt__t____cg'; +select lives_ok('delete_target_alias'); + +select is(count(*), 0::bigint) from alias_target where public_id = 'alt__t____cg'; +select is(count(*), 0::bigint) from alias where public_id = 'alt__t____cg'; + +select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/alias/targets.sql b/internal/db/sqltest/tests/alias/targets.sql new file mode 100644 index 0000000000..8ffc0fcb4c --- /dev/null +++ b/internal/db/sqltest/tests/alias/targets.sql @@ -0,0 +1,50 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; +select plan(11); +select wtt_load('widgets', 'iam', 'kms', 'auth'); + +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id = 't_________cb'; + +-- validate destination id and host id can be updated +prepare update_target_alias_destination as + update alias_target + set (destination_id, host_id) = ('t_________cg', 'h_________cg') + where public_id = 'alt__t____cb'; +select lives_ok('update_target_alias_destination'); + +select is(count(*), 1::bigint) from alias_target where value = 'blue.tcp.target'; + +prepare insert_case_insensitive_value_duplicate AS + insert into alias_target (public_id, scope_id, value) + values ('new_alias_for_tests', 'global', 'BLUE.TCP.TARGET'); +select throws_like( + 'insert_case_insensitive_value_duplicate', + 'duplicate key value violates unique constraint "alias_value_uq"' +); + +select is(count(*), 0::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id = 't_________cb'; +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id = 't_________cg' and host_id = 'h_________cg'; + +-- validate deleting a target nulls out the destination id and host id +prepare update_target_alias as + delete from target_tcp + where public_id = 't_________cg'; +select lives_ok('update_target_alias'); + +select is(count(*), 0::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id = 't_________cb'; +select is(count(*), 0::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id = 't_________cg'; +select is(count(*), 1::bigint) from alias_target where public_id = 'alt__t____cb' and destination_id is null and host_id is null; + +-- validate a host id cant be set if the destination id is not set +prepare insert_destination_id_not_set_when_host_id_is_set as + insert into alias_target (public_id, scope_id, value, host_id) + values ('unset_destination_id', 'global', 'unset.destination.id', 'h_________cb'); +select throws_like( + 'insert_destination_id_not_set_when_host_id_is_set', + 'new row for relation "alias_target" violates check constraint "destination_id_set_when_host_id_is_set"' +); + +select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/alias/wt_target_alias.sql b/internal/db/sqltest/tests/alias/wt_target_alias.sql new file mode 100644 index 0000000000..9b43baa757 --- /dev/null +++ b/internal/db/sqltest/tests/alias/wt_target_alias.sql @@ -0,0 +1,135 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +-- controller_id tests: +-- validates the wt_controller_id domain + +begin; + select plan(16); + + select has_domain('wt_target_alias'); + + create table target_alias_testing ( + v wt_target_alias + ); + + prepare empty_insert as insert into target_alias_testing (v) values (''); + select throws_like( + 'empty_insert', + '%"wt_alias_too_short"', + 'We should error for empty values' + ); + + prepare valid_inserts as insert into target_alias_testing (v) values + ('a'), + ('A'), + ('192.168.1.9-9'), + ('foo'), + ('a.b.c'), + ('A.B.C'), + ('a-b-c'), + ('a.9-9'), + ('9things'), + ('9-things'), + ('hp--something.test.com'), + ('test-for-long-name-which-is-almost-over-the-limit-of-characters'), + ('TEST-FOR-LONG-NAME-WHICH-IS-ALMOST-OVER-THE-LIMIT-OF-CHARACTERS'), + ('test-for-long-name-which-is-almost-over-the-limit-of-characters.another-label'), + ('test.test-for-long-name-which-is-almost-over-the-limit-of-characters'), + ('test-for-long-name-which-is-almost-over-the-limit-of-characters.test-for-long-name-which-is-almost-over-the-limit-of-characters'), + ('9-things.9-things'); + select lives_ok('valid_inserts'); + + prepare label_too_long as insert into target_alias_testing (v) values + ('test-for-long-name-which-is-almost-over-the-limit-of-charactersX'); + select throws_like( + 'label_too_long', + '%"wt_target_alias_value_shape"' + ); + + prepare label_too_long_2 as insert into target_alias_testing (v) values + ('a.test-for-long-name-which-is-almost-over-the-limit-of-charactersX'); + select throws_like( + 'label_too_long_2', + '%"wt_target_alias_value_shape"' + ); + + prepare label_too_long_3 as insert into target_alias_testing (v) values + ('test-for-long-name-which-is-almost-over-the-limit-of-charactersX.a'); + select throws_like( + 'label_too_long_3', + '%"wt_target_alias_value_shape"' + ); + + prepare starting_with_hyphen as insert into target_alias_testing (v) values + ('-test.com'); + select throws_like( + 'starting_with_hyphen', + '%"wt_target_alias_value_shape"' + ); + + prepare starting_with_hyphen2 as insert into target_alias_testing (v) values + ('a.-test'); + select throws_like( + 'starting_with_hyphen2', + '%"wt_target_alias_value_shape"' + ); + + prepare ending_with_hyphen as insert into target_alias_testing (v) values + ('test-.com'); + select throws_like( + 'ending_with_hyphen', + '%"wt_target_alias_value_shape"' + ); + + prepare ending_with_hyphen2 as insert into target_alias_testing (v) values + ('a.test-'); + select throws_like( + 'ending_with_hyphen2', + '%"wt_target_alias_value_shape"' + ); + + prepare ending_with_hyphen3 as insert into target_alias_testing (v) values + ('a.9-'); + select throws_like( + 'ending_with_hyphen3', + '%"wt_target_alias_value_shape"' + ); + + prepare empty_label as insert into target_alias_testing (v) values + ('a..com'); + select throws_like( + 'empty_label', + '%"wt_target_alias_value_shape"' + ); + + prepare empty_label2 as insert into target_alias_testing (v) values + ('.a.com'); + select throws_like( + 'empty_label2', + '%"wt_target_alias_value_shape"' + ); + + prepare empty_label3 as insert into target_alias_testing (v) values + ('a.com.'); + select throws_like( + 'empty_label3', + '%"wt_target_alias_value_shape"' + ); + + prepare numeric_only_tld as insert into target_alias_testing (v) values + ('a.123'); + select throws_like( + 'numeric_only_tld', + '%"wt_target_alias_tld_not_only_numeric"' + ); + + prepare numeric_only_tld2 as insert into target_alias_testing (v) values + ('a.9'); + select throws_like( + 'numeric_only_tld2', + '%"wt_target_alias_tld_not_only_numeric"' + ); + + select * from finish(); +rollback; diff --git a/internal/db/sqltest/tests/pagination/alias.sql b/internal/db/sqltest/tests/pagination/alias.sql new file mode 100644 index 0000000000..587d4d1b1c --- /dev/null +++ b/internal/db/sqltest/tests/pagination/alias.sql @@ -0,0 +1,12 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(2); + + select has_index('alias_target', 'alias_target_create_time_public_id_idx', array['create_time', 'public_id']); + select has_index('alias_target', 'alias_target_update_time_public_id_idx', array['update_time', 'public_id']); + + select * from finish(); + +rollback; \ No newline at end of file diff --git a/website/content/docs/install-boundary/system-requirements.mdx b/website/content/docs/install-boundary/system-requirements.mdx index a6ee6ece01..4aeae51c57 100644 --- a/website/content/docs/install-boundary/system-requirements.mdx +++ b/website/content/docs/install-boundary/system-requirements.mdx @@ -154,7 +154,7 @@ When you initialize the database with the `boundary database init` command, the ### Required PostgreSQL modules -Boundary has a dependency on the PostgreSQL [pgcrypto](https://www.postgresql.org/docs/11/pgcrypto.html) module, which is one of the standard modules that is supplied with PostgreSQL. +Boundary has a dependency on the PostgreSQL [pgcrypto](https://www.postgresql.org/docs/11/pgcrypto.html) module and [citext](https://www.postgresql.org/docs/11/citext.html), which are part of the standard modules that are supplied with PostgreSQL. Refer to the PostgreSQL documentation for more information. ## Load balancer recommendations