diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index 44d43ce0b..623861fd8 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -598,6 +598,7 @@ create table iam_scope_project ( parent_id wt_public_id not null references iam_scope_org(scope_id) on delete cascade on update cascade, name text, unique(parent_id, name), + unique(scope_id), primary key(scope_id, parent_id) ); @@ -3242,6 +3243,17 @@ begin; drop table session; drop table session_termination_reason_enm; drop function insert_session_state; + drop function insert_new_session_state; + drop function insert_session; + drop function update_session_state_on_termination_reason; + drop function insert_session_state; + + + delete + from oplog_ticket + where name in ( + 'session' + ); commit; @@ -3275,7 +3287,7 @@ begin; │ server_id (fk3) │ ▲fk4 ┼ │ server_type (fk3) │╲ ┼ │ target_id (fk4) │─○─────────────────┬─────────────────────┤ - │ set_id (fk5) │╱ ┼ ┼ + │ host_set_id (fk5) │╱ ┼ ┼ │ auth_token_id (fk6) │ ▼fk5 ┼ ▼fk2 ┼ │ scope_id (fk7) │ ┌─────────────────┐ ┌─────────────────┐ │ termination_reason (fk8) │ │ host_set │ │ host │ @@ -3364,7 +3376,7 @@ begin; on update cascade, -- the host set the host was chosen from and the user was authorized to -- connect to via the target - set_id wt_public_id -- fk5 + host_set_id wt_public_id -- fk5 references host_set (public_id) on delete set null on update cascade, @@ -3373,68 +3385,153 @@ begin; references auth_token (public_id) on delete set null on update cascade, - -- the organization which owns this session + -- the project which owns this session scope_id wt_scope_id -- fk7 - references iam_scope_org (scope_id) + references iam_scope_project (scope_id) on delete set null on update cascade, + -- Certificate to use when connecting (or if using custom certs, to + -- serve as the "login"). Raw DER bytes. + certificate bytea not null, + -- after this time the connection will be expired, e.g. forcefully terminated + expiration_time wt_timestamp, -- maybe null + -- trust of first use token + tofu_token bytea, -- will be null when session is first created -- the reason this session ended (null until terminated) + -- TODO: Make key_id a foreign key once we have DEKs + key_id text, -- will be null on insert + -- references kms_database_key_version(private_id) + -- on delete restrict + -- on update cascade, termination_reason text -- fk8 references session_termination_reason_enm (name) on delete restrict on update cascade, - -- the network address of the host the worker proxied a connection to for - -- the user - address text - not null - check( - length(trim(address)) > 7 - and - length(trim(address)) < 256 - ), - -- the network port at the address of the host the worker proxied a - -- connection to for the user - port integer - not null - check( - port > 0 - and - port <= 65535 - ), - -- the total number of bytes received by the worker from the user and sent - -- to the host for this session - bytes_up bigint -- can be null - check ( - bytes_up is null - or - bytes_up >= 0 - ), - -- the total number of bytes received by the worker from the host and sent - -- to the user for this session - bytes_down bigint -- can be null - check ( - bytes_down is null - or - bytes_down >= 0 - ) + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp ); + create trigger + immutable_columns + before + update on session + for each row execute procedure immutable_columns('public_id', 'certificate', 'expiration_time', 'create_time'); + + create trigger + update_version_column + after update on session + for each row execute procedure update_version_column(); + + create trigger + update_time_column + before update on session + for each row execute procedure update_time_column(); + + create trigger + default_create_time_column + before + insert on session + for each row execute procedure default_create_time(); + + create or replace function + insert_session() + returns trigger + as $$ + begin + case + when new.user_id is null then + raise exception 'user_id is null'; + when new.host_id is null then + raise exception 'host_id is null'; + when new.target_id is null then + raise exception 'target_id is null'; + when new.host_set_id is null then + raise exception 'host_set_id is null'; + when new.auth_token_id is null then + raise exception 'auth_token_id is null'; + when new.scope_id is null then + raise exception 'scope_id is null'; + else + end case; + return new; + end; + $$ language plpgsql; + + create trigger + insert_session + before insert on session + for each row execute procedure insert_session(); + + create or replace function + insert_new_session_state() + returns trigger + as $$ + begin + insert into session_state (session_id, state) + values + (new.public_id, 'pending'); + return new; + end; + $$ language plpgsql; + + create trigger + insert_new_session_state + after insert on session + for each row execute procedure insert_new_session_state(); + + -- update_connection_state_on_closed_reason() is used in an update insert trigger on the + -- session_connection table. it will valiadate that all the session's + -- connections are closed, and then insert a state of "closed" in + -- session_connection_state for the closed session connection. + create or replace function + update_session_state_on_termination_reason() + returns trigger + as $$ + begin + if new.termination_reason is not null then + perform from + session_connection sc, + session_connection_state scs + where + sc.public_id = scs.connection_id and + scs.state != 'closed' and + sc.session_id = new.public_id; + if found then + raise 'session %s has existing open connections', new.public_id; + end if; + insert into session_state (session_id, state) + values + (new.public_id, 'terminated'); + end if; + return new; + end; + $$ language plpgsql; + + + create trigger + update_session_state_on_termination_reason + after update of termination_reason on session + for each row execute procedure update_session_state_on_termination_reason(); + + create table session_state_enm ( name text primary key check ( - name in ('pending', 'connected', 'canceling', 'closed') + name in ('pending', 'active', 'canceling', 'terminated') ) ); insert into session_state_enm (name) values ('pending'), - ('connected'), + ('active'), ('canceling'), - ('closed'); + ('terminated'); /* + ┌────────────────┐ start │ │ . │ Canceling │ @@ -3445,7 +3542,7 @@ begin; ▼ │ ▼ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ │ │ │ │ - │ Pending │ │ Connected │ │ Closed │ + │ Pending │ │ Active │ │ Terminated │ │ │──────────▶│ │──────────▶│ │ │ │ │ │ │ │ └────────────────┘ └────────────────┘ └────────────────┘ @@ -3486,6 +3583,12 @@ begin; ); + create trigger + immutable_columns + before + update on session_state + for each row execute procedure immutable_columns('session_id', 'start_time', 'previous_end_time'); + create or replace function insert_session_state() returns trigger @@ -3516,6 +3619,300 @@ begin; create trigger insert_session_state before insert on session_state for each row execute procedure insert_session_state(); + +commit; + +`), + }, + "migrations/51_connection.down.sql": { + name: "51_connection.down.sql", + bytes: []byte(` +begin; + + drop table session_connection_state; + drop table session_connection_state_enm; + drop table session_connection; + drop table session_connection_closed_reason_enm; + drop function insert_session_connection_state; + drop function insert_new_connection_state; + drop function update_connection_state_on_closed_reason; +commit; + +`), + }, + "migrations/51_connection.up.sql": { + name: "51_connection.up.sql", + bytes: []byte(` +begin; + +/* + + ┌────────────────┐ + │ session │ + ├────────────────┤ + │ public_id (pk) │ + │ │ + │ │ + └────────────────┘ + ▲fk1 ┼ + ┼ + │ + │ + ┼ + ╱│╲ ┌──────────────────────────────────────┐ + ┌───────────────────────┐ │ session_connection_closed_reason_enm │ + │ session_connection │╲ fk2▶ ├──────────────────────────────────────┤ + ├───────────────────────┤─○───○┼│ name │ + │ public_id (pk) │╱ └──────────────────────────────────────┘ + │ session_id (fk1) │ + │ closed_reason (fk2) │ + └───────────────────────┘ + ▲fk1 ┼ + ┼ + │ + │ + ┼ + ╱│╲ + ┌──────────────────────────────────────────┐ + │ session_connection_state │ + ├──────────────────────────────────────────┤┼○┐ + │ connection_id (pk,fk1,fk2,unq1,unq2) │ │◀fk2 + │ state (fk3) │ │ + │ previous_end_time (fk2,unq1) │┼○┘ + │ start_time (pk) │ + │ end_time (unq2) │ + └──────────────────────────────────────────┘ + ╲│╱ + ○ + │ + │ + ┼ + ▼fk3 ┼ + ┌───────────────────────────────┐ + │ session_connection_state_enm │ + ├───────────────────────────────┤ + │ name │ + └───────────────────────────────┘ + +*/ + + create table session_connection_closed_reason_enm ( + name text primary key + check ( + name in ( + 'unknown', + 'timed out', + 'closed by end-user', + 'cancelled', + 'network error', + 'system error' + ) + ) + ); + + insert into session_connection_closed_reason_enm (name) + values + ('unknown'), + ('timed out'), + ('closed by end-user'), + ('cancelled'), + ('network error'), + ('system error'); + + -- A session connection is one connection proxied by a worker from a client to + -- a backend for a session. The client initiates the connection to the worker + -- and the worker initiates the connection to the backend. + -- A session can have zero or more session connections. + create table session_connection ( + public_id wt_public_id primary key, + session_id wt_public_id not null + references session (public_id) + on delete cascade + on update cascade, + -- the client_tcp_address is the network address of the client which initiated + -- the connection to a worker + client_tcp_address inet not null, + -- the client_tcp_port is the network port at the address of the client the + -- worker proxied a connection for the user + client_tcp_port integer not null + check( + client_tcp_port > 0 + and + client_tcp_port <= 65535 + ), + -- the backend_tcp_address is the network address of the backend which the + -- worker initiated the connection to, for the user + backend_tcp_address inet not null, + -- the backend_tcp_port is the network port at the address of the backend the + -- worker proxied a connection to, for the user + backend_tcp_port integer not null + check( + backend_tcp_port > 0 + and + backend_tcp_port <= 65535 + ), + -- the total number of bytes received by the worker from the client and sent + -- to the backend for this connection + bytes_up bigint -- can be null + check ( + bytes_up is null + or + bytes_up >= 0 + ), + -- the total number of bytes received by the worker from the backend and sent + -- to the client for this connection + bytes_down bigint -- can be null + check ( + bytes_down is null + or + bytes_down >= 0 + ), + closed_reason text + references session_connection_closed_reason_enm (name) + on delete restrict + on update cascade, + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp + ); + + create trigger + immutable_columns + before + update on session_connection + for each row execute procedure immutable_columns('public_id', 'session_id', 'client_tcp_address', 'client_tcp_port', 'backend_tcp_address', 'backend_tcp_port', 'create_time'); + + create trigger + update_version_column + after update on session_connection + for each row execute procedure update_version_column(); + + create trigger + update_time_column + before update on session_connection + for each row execute procedure update_time_column(); + + create trigger + default_create_time_column + before + insert on session_connection + for each row execute procedure default_create_time(); + + -- insert_new_connection_state() is used in an after insert trigger on the + -- session_connection table. it will insert a state of "connected" in + -- session_connection_state for the new session connection. + create or replace function + insert_new_connection_state() + returns trigger + as $$ + begin + insert into session_connection_state (connection_id, state) + values + (new.public_id, 'connected'); + return new; + end; + $$ language plpgsql; + + create trigger + insert_new_connection_state + after insert on session_connection + for each row execute procedure insert_new_connection_state(); + + -- update_connection_state_on_closed_reason() is used in an update insert trigger on the + -- session_connection table. it will insert a state of "closed" in + -- session_connection_state for the closed session connection. + create or replace function + update_connection_state_on_closed_reason() + returns trigger + as $$ + begin + if new.closed_reason is not null then + insert into session_connection_state (connection_id, state) + values + (new.public_id, 'closed'); + end if; + return new; + end; + $$ language plpgsql; + + create trigger + update_connection_state_on_closed_reason + after update of closed_reason on session_connection + for each row execute procedure update_connection_state_on_closed_reason(); + + create table session_connection_state_enm ( + name text primary key + check ( + name in ('connected', 'closed') + ) + ); + + insert into session_connection_state_enm (name) + values + ('connected'), + ('closed'); + + + create table session_connection_state ( + connection_id wt_public_id not null + references session_connection (public_id) + on delete cascade + on update cascade, + state text not null + references session_connection_state_enm(name) + on delete restrict + on update cascade, + previous_end_time timestamp with time zone, -- fk2 -- null means first state + start_time timestamp with time zone default current_timestamp not null, + constraint previous_end_time_and_start_time_in_sequence + check (previous_end_time <= start_time), + end_time timestamp with time zone, -- null means unfinished current state + constraint start_and_end_times_in_sequence + check (start_time <= end_time), + constraint end_times_in_sequence + check (previous_end_time <> end_time), + primary key (connection_id, start_time), + unique (connection_id, previous_end_time), -- null means first state + unique (connection_id, end_time), -- one null current state + foreign key (connection_id, previous_end_time) -- self-reference + references session_connection_state (connection_id, end_time) + ); + + create trigger + immutable_columns + before + update on session_connection_state + for each row execute procedure immutable_columns('connection_id', 'state', 'start_time'); + + create or replace function + insert_session_connection_state() + returns trigger + as $$ + begin + + update session_connection_state + set end_time = now() + where connection_id = new.connection_id + and end_time is null; + + if not found then + new.previous_end_time = null; + new.start_time = now(); + new.end_time = null; + return new; + end if; + + new.previous_end_time = now(); + new.start_time = now(); + new.end_time = null; + return new; + + end; + $$ language plpgsql; + + create trigger insert_session_connection_state before insert on session_connection_state + for each row execute procedure insert_session_connection_state(); + commit; `), diff --git a/internal/db/migrations/postgres/06_iam.up.sql b/internal/db/migrations/postgres/06_iam.up.sql index b51bec976..1d1b62d3f 100644 --- a/internal/db/migrations/postgres/06_iam.up.sql +++ b/internal/db/migrations/postgres/06_iam.up.sql @@ -80,6 +80,7 @@ create table iam_scope_project ( parent_id wt_public_id not null references iam_scope_org(scope_id) on delete cascade on update cascade, name text, unique(parent_id, name), + unique(scope_id), primary key(scope_id, parent_id) ); diff --git a/internal/db/migrations/postgres/50_session.down.sql b/internal/db/migrations/postgres/50_session.down.sql index a25322875..69a43976f 100644 --- a/internal/db/migrations/postgres/50_session.down.sql +++ b/internal/db/migrations/postgres/50_session.down.sql @@ -5,5 +5,16 @@ begin; drop table session; drop table session_termination_reason_enm; drop function insert_session_state; + drop function insert_new_session_state; + drop function insert_session; + drop function update_session_state_on_termination_reason; + drop function insert_session_state; + + + delete + from oplog_ticket + where name in ( + 'session' + ); commit; diff --git a/internal/db/migrations/postgres/50_session.up.sql b/internal/db/migrations/postgres/50_session.up.sql index 0fd0399e9..3225dd27a 100644 --- a/internal/db/migrations/postgres/50_session.up.sql +++ b/internal/db/migrations/postgres/50_session.up.sql @@ -23,7 +23,7 @@ begin; │ server_id (fk3) │ ▲fk4 ┼ │ server_type (fk3) │╲ ┼ │ target_id (fk4) │─○─────────────────┬─────────────────────┤ - │ set_id (fk5) │╱ ┼ ┼ + │ host_set_id (fk5) │╱ ┼ ┼ │ auth_token_id (fk6) │ ▼fk5 ┼ ▼fk2 ┼ │ scope_id (fk7) │ ┌─────────────────┐ ┌─────────────────┐ │ termination_reason (fk8) │ │ host_set │ │ host │ @@ -112,7 +112,7 @@ begin; on update cascade, -- the host set the host was chosen from and the user was authorized to -- connect to via the target - set_id wt_public_id -- fk5 + host_set_id wt_public_id -- fk5 references host_set (public_id) on delete set null on update cascade, @@ -121,68 +121,153 @@ begin; references auth_token (public_id) on delete set null on update cascade, - -- the organization which owns this session + -- the project which owns this session scope_id wt_scope_id -- fk7 - references iam_scope_org (scope_id) + references iam_scope_project (scope_id) on delete set null on update cascade, + -- Certificate to use when connecting (or if using custom certs, to + -- serve as the "login"). Raw DER bytes. + certificate bytea not null, + -- after this time the connection will be expired, e.g. forcefully terminated + expiration_time wt_timestamp, -- maybe null + -- trust of first use token + tofu_token bytea, -- will be null when session is first created -- the reason this session ended (null until terminated) + -- TODO: Make key_id a foreign key once we have DEKs + key_id text, -- will be null on insert + -- references kms_database_key_version(private_id) + -- on delete restrict + -- on update cascade, termination_reason text -- fk8 references session_termination_reason_enm (name) on delete restrict on update cascade, - -- the network address of the host the worker proxied a connection to for - -- the user - address text - not null - check( - length(trim(address)) > 7 - and - length(trim(address)) < 256 - ), - -- the network port at the address of the host the worker proxied a - -- connection to for the user - port integer - not null - check( - port > 0 - and - port <= 65535 - ), - -- the total number of bytes received by the worker from the user and sent - -- to the host for this session - bytes_up bigint -- can be null - check ( - bytes_up is null - or - bytes_up >= 0 - ), - -- the total number of bytes received by the worker from the host and sent - -- to the user for this session - bytes_down bigint -- can be null - check ( - bytes_down is null - or - bytes_down >= 0 - ) + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp ); + create trigger + immutable_columns + before + update on session + for each row execute procedure immutable_columns('public_id', 'certificate', 'expiration_time', 'create_time'); + + create trigger + update_version_column + after update on session + for each row execute procedure update_version_column(); + + create trigger + update_time_column + before update on session + for each row execute procedure update_time_column(); + + create trigger + default_create_time_column + before + insert on session + for each row execute procedure default_create_time(); + + create or replace function + insert_session() + returns trigger + as $$ + begin + case + when new.user_id is null then + raise exception 'user_id is null'; + when new.host_id is null then + raise exception 'host_id is null'; + when new.target_id is null then + raise exception 'target_id is null'; + when new.host_set_id is null then + raise exception 'host_set_id is null'; + when new.auth_token_id is null then + raise exception 'auth_token_id is null'; + when new.scope_id is null then + raise exception 'scope_id is null'; + else + end case; + return new; + end; + $$ language plpgsql; + + create trigger + insert_session + before insert on session + for each row execute procedure insert_session(); + + create or replace function + insert_new_session_state() + returns trigger + as $$ + begin + insert into session_state (session_id, state) + values + (new.public_id, 'pending'); + return new; + end; + $$ language plpgsql; + + create trigger + insert_new_session_state + after insert on session + for each row execute procedure insert_new_session_state(); + + -- update_connection_state_on_closed_reason() is used in an update insert trigger on the + -- session_connection table. it will valiadate that all the session's + -- connections are closed, and then insert a state of "closed" in + -- session_connection_state for the closed session connection. + create or replace function + update_session_state_on_termination_reason() + returns trigger + as $$ + begin + if new.termination_reason is not null then + perform from + session_connection sc, + session_connection_state scs + where + sc.public_id = scs.connection_id and + scs.state != 'closed' and + sc.session_id = new.public_id; + if found then + raise 'session %s has existing open connections', new.public_id; + end if; + insert into session_state (session_id, state) + values + (new.public_id, 'terminated'); + end if; + return new; + end; + $$ language plpgsql; + + + create trigger + update_session_state_on_termination_reason + after update of termination_reason on session + for each row execute procedure update_session_state_on_termination_reason(); + + create table session_state_enm ( name text primary key check ( - name in ('pending', 'connected', 'canceling', 'closed') + name in ('pending', 'active', 'canceling', 'terminated') ) ); insert into session_state_enm (name) values ('pending'), - ('connected'), + ('active'), ('canceling'), - ('closed'); + ('terminated'); /* + ┌────────────────┐ start │ │ . │ Canceling │ @@ -193,7 +278,7 @@ begin; ▼ │ ▼ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ │ │ │ │ - │ Pending │ │ Connected │ │ Closed │ + │ Pending │ │ Active │ │ Terminated │ │ │──────────▶│ │──────────▶│ │ │ │ │ │ │ │ └────────────────┘ └────────────────┘ └────────────────┘ @@ -234,6 +319,12 @@ begin; ); + create trigger + immutable_columns + before + update on session_state + for each row execute procedure immutable_columns('session_id', 'start_time', 'previous_end_time'); + create or replace function insert_session_state() returns trigger @@ -264,4 +355,5 @@ begin; create trigger insert_session_state before insert on session_state for each row execute procedure insert_session_state(); + commit; diff --git a/internal/db/migrations/postgres/51_connection.down.sql b/internal/db/migrations/postgres/51_connection.down.sql new file mode 100644 index 000000000..9356a5674 --- /dev/null +++ b/internal/db/migrations/postgres/51_connection.down.sql @@ -0,0 +1,10 @@ +begin; + + drop table session_connection_state; + drop table session_connection_state_enm; + drop table session_connection; + drop table session_connection_closed_reason_enm; + drop function insert_session_connection_state; + drop function insert_new_connection_state; + drop function update_connection_state_on_closed_reason; +commit; diff --git a/internal/db/migrations/postgres/51_connection.up.sql b/internal/db/migrations/postgres/51_connection.up.sql new file mode 100644 index 000000000..20d27d7d8 --- /dev/null +++ b/internal/db/migrations/postgres/51_connection.up.sql @@ -0,0 +1,271 @@ +begin; + +/* + + ┌────────────────┐ + │ session │ + ├────────────────┤ + │ public_id (pk) │ + │ │ + │ │ + └────────────────┘ + ▲fk1 ┼ + ┼ + │ + │ + ┼ + ╱│╲ ┌──────────────────────────────────────┐ + ┌───────────────────────┐ │ session_connection_closed_reason_enm │ + │ session_connection │╲ fk2▶ ├──────────────────────────────────────┤ + ├───────────────────────┤─○───○┼│ name │ + │ public_id (pk) │╱ └──────────────────────────────────────┘ + │ session_id (fk1) │ + │ closed_reason (fk2) │ + └───────────────────────┘ + ▲fk1 ┼ + ┼ + │ + │ + ┼ + ╱│╲ + ┌──────────────────────────────────────────┐ + │ session_connection_state │ + ├──────────────────────────────────────────┤┼○┐ + │ connection_id (pk,fk1,fk2,unq1,unq2) │ │◀fk2 + │ state (fk3) │ │ + │ previous_end_time (fk2,unq1) │┼○┘ + │ start_time (pk) │ + │ end_time (unq2) │ + └──────────────────────────────────────────┘ + ╲│╱ + ○ + │ + │ + ┼ + ▼fk3 ┼ + ┌───────────────────────────────┐ + │ session_connection_state_enm │ + ├───────────────────────────────┤ + │ name │ + └───────────────────────────────┘ + +*/ + + create table session_connection_closed_reason_enm ( + name text primary key + check ( + name in ( + 'unknown', + 'timed out', + 'closed by end-user', + 'cancelled', + 'network error', + 'system error' + ) + ) + ); + + insert into session_connection_closed_reason_enm (name) + values + ('unknown'), + ('timed out'), + ('closed by end-user'), + ('cancelled'), + ('network error'), + ('system error'); + + -- A session connection is one connection proxied by a worker from a client to + -- a backend for a session. The client initiates the connection to the worker + -- and the worker initiates the connection to the backend. + -- A session can have zero or more session connections. + create table session_connection ( + public_id wt_public_id primary key, + session_id wt_public_id not null + references session (public_id) + on delete cascade + on update cascade, + -- the client_tcp_address is the network address of the client which initiated + -- the connection to a worker + client_tcp_address inet not null, + -- the client_tcp_port is the network port at the address of the client the + -- worker proxied a connection for the user + client_tcp_port integer not null + check( + client_tcp_port > 0 + and + client_tcp_port <= 65535 + ), + -- the backend_tcp_address is the network address of the backend which the + -- worker initiated the connection to, for the user + backend_tcp_address inet not null, + -- the backend_tcp_port is the network port at the address of the backend the + -- worker proxied a connection to, for the user + backend_tcp_port integer not null + check( + backend_tcp_port > 0 + and + backend_tcp_port <= 65535 + ), + -- the total number of bytes received by the worker from the client and sent + -- to the backend for this connection + bytes_up bigint -- can be null + check ( + bytes_up is null + or + bytes_up >= 0 + ), + -- the total number of bytes received by the worker from the backend and sent + -- to the client for this connection + bytes_down bigint -- can be null + check ( + bytes_down is null + or + bytes_down >= 0 + ), + closed_reason text + references session_connection_closed_reason_enm (name) + on delete restrict + on update cascade, + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp + ); + + create trigger + immutable_columns + before + update on session_connection + for each row execute procedure immutable_columns('public_id', 'session_id', 'client_tcp_address', 'client_tcp_port', 'backend_tcp_address', 'backend_tcp_port', 'create_time'); + + create trigger + update_version_column + after update on session_connection + for each row execute procedure update_version_column(); + + create trigger + update_time_column + before update on session_connection + for each row execute procedure update_time_column(); + + create trigger + default_create_time_column + before + insert on session_connection + for each row execute procedure default_create_time(); + + -- insert_new_connection_state() is used in an after insert trigger on the + -- session_connection table. it will insert a state of "connected" in + -- session_connection_state for the new session connection. + create or replace function + insert_new_connection_state() + returns trigger + as $$ + begin + insert into session_connection_state (connection_id, state) + values + (new.public_id, 'connected'); + return new; + end; + $$ language plpgsql; + + create trigger + insert_new_connection_state + after insert on session_connection + for each row execute procedure insert_new_connection_state(); + + -- update_connection_state_on_closed_reason() is used in an update insert trigger on the + -- session_connection table. it will insert a state of "closed" in + -- session_connection_state for the closed session connection. + create or replace function + update_connection_state_on_closed_reason() + returns trigger + as $$ + begin + if new.closed_reason is not null then + insert into session_connection_state (connection_id, state) + values + (new.public_id, 'closed'); + end if; + return new; + end; + $$ language plpgsql; + + create trigger + update_connection_state_on_closed_reason + after update of closed_reason on session_connection + for each row execute procedure update_connection_state_on_closed_reason(); + + create table session_connection_state_enm ( + name text primary key + check ( + name in ('connected', 'closed') + ) + ); + + insert into session_connection_state_enm (name) + values + ('connected'), + ('closed'); + + + create table session_connection_state ( + connection_id wt_public_id not null + references session_connection (public_id) + on delete cascade + on update cascade, + state text not null + references session_connection_state_enm(name) + on delete restrict + on update cascade, + previous_end_time timestamp with time zone, -- fk2 -- null means first state + start_time timestamp with time zone default current_timestamp not null, + constraint previous_end_time_and_start_time_in_sequence + check (previous_end_time <= start_time), + end_time timestamp with time zone, -- null means unfinished current state + constraint start_and_end_times_in_sequence + check (start_time <= end_time), + constraint end_times_in_sequence + check (previous_end_time <> end_time), + primary key (connection_id, start_time), + unique (connection_id, previous_end_time), -- null means first state + unique (connection_id, end_time), -- one null current state + foreign key (connection_id, previous_end_time) -- self-reference + references session_connection_state (connection_id, end_time) + ); + + create trigger + immutable_columns + before + update on session_connection_state + for each row execute procedure immutable_columns('connection_id', 'state', 'start_time'); + + create or replace function + insert_session_connection_state() + returns trigger + as $$ + begin + + update session_connection_state + set end_time = now() + where connection_id = new.connection_id + and end_time is null; + + if not found then + new.previous_end_time = null; + new.start_time = now(); + new.end_time = null; + return new; + end if; + + new.previous_end_time = now(); + new.start_time = now(); + new.end_time = null; + return new; + + end; + $$ language plpgsql; + + create trigger insert_session_connection_state before insert on session_connection_state + for each row execute procedure insert_session_connection_state(); + +commit; diff --git a/internal/db/read_writer.go b/internal/db/read_writer.go index 4be4584d0..748f54053 100644 --- a/internal/db/read_writer.go +++ b/internal/db/read_writer.go @@ -103,6 +103,11 @@ type Writer interface { // DB returns the sql.DB DB() (*sql.DB, error) + // Exec will execute the sql with the values as parameters. The int returned + // is the number of rows affected by the sql. No options are currently + // supported. + Exec(sql string, values []interface{}, opt ...Option) (int, error) + // GetTicket returns an oplog ticket for the aggregate root of "i" which can // be used to WriteOplogEntryWith for that aggregate root. GetTicket(i interface{}) (*store.Ticket, error) @@ -187,6 +192,20 @@ func (rw *Db) DB() (*sql.DB, error) { return rw.underlying.DB(), nil } +// Exec will execute the sql with the values as parameters. The int returned +// is the number of rows affected by the sql. No options are currently +// supported. +func (rw *Db) Exec(sql string, values []interface{}, opt ...Option) (int, error) { + if sql == "" { + return NoRowsAffected, fmt.Errorf("missing sql: %w", ErrInvalidParameter) + } + gormDb := rw.underlying.Exec(sql, values...) + if gormDb.Error != nil { + return NoRowsAffected, fmt.Errorf("exec: failed: %w", gormDb.Error) + } + return int(gormDb.RowsAffected), nil +} + // Scan rows will scan the rows into the interface func (rw *Db) ScanRows(rows *sql.Rows, result interface{}) error { if rw.underlying == nil { diff --git a/internal/db/read_writer_test.go b/internal/db/read_writer_test.go index 669ca2779..e935884c2 100644 --- a/internal/db/read_writer_test.go +++ b/internal/db/read_writer_test.go @@ -1091,6 +1091,24 @@ func TestDb_DB(t *testing.T) { }) } +func TestDb_Exec(t *testing.T) { + t.Parallel() + t.Run("update", func(t *testing.T) { + db, _ := TestSetup(t, "postgres") + require := require.New(t) + w := Db{underlying: db} + id := testId(t) + user, err := db_test.NewTestUser() + require.NoError(err) + user.Name = "foo-" + id + err = w.Create(context.Background(), user) + require.NoError(err) + require.NotEmpty(user.Id) + rowsAffected, err := w.Exec("update db_test_user set name = ? where public_id = ?", []interface{}{"updated-" + id, user.PublicId}) + require.NoError(err) + require.Equal(1, rowsAffected) + }) +} func TestDb_DoTx(t *testing.T) { t.Parallel() db, _ := TestSetup(t, "postgres") diff --git a/internal/servers/controller/handler.go b/internal/servers/controller/handler.go index 9568d97ab..1463cb58e 100644 --- a/internal/servers/controller/handler.go +++ b/internal/servers/controller/handler.go @@ -2,13 +2,9 @@ package controller import ( "context" - "crypto/rand" - "crypto/x509" "encoding/json" "errors" "fmt" - "math/big" - mathrand "math/rand" "net/http" "os" "strings" @@ -28,7 +24,7 @@ import ( "github.com/hashicorp/boundary/internal/servers/controller/handlers/authmethods" "github.com/hashicorp/boundary/internal/servers/controller/handlers/host_sets" "github.com/hashicorp/boundary/internal/servers/controller/handlers/targets" - "github.com/hashicorp/boundary/internal/sessions" + "github.com/hashicorp/boundary/internal/session" "github.com/hashicorp/boundary/internal/types/scope" "github.com/hashicorp/boundary/sdk/strutil" "github.com/hashicorp/shared-secure-libs/configutil" @@ -369,24 +365,9 @@ func jobTestingHandler(c *Controller) http.Handler { errorResp(err) return } - jobId = "s_" + jobId - pubKey, privKey, err := sessions.DeriveED25519Key(wrapper, "u_1234567890", jobId) - - template := &x509.Certificate{ - ExtKeyUsage: []x509.ExtKeyUsage{ - x509.ExtKeyUsageServerAuth, - x509.ExtKeyUsageClientAuth, - }, - DNSNames: []string{jobId}, - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement | x509.KeyUsageCertSign, - SerialNumber: big.NewInt(mathrand.Int63()), - NotBefore: time.Now().Add(-1 * time.Minute), - NotAfter: time.Now().Add(5 * time.Minute), - BasicConstraintsValid: true, - IsCA: true, - } - - certBytes, err := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey) + // TODO (jimlambrt 8/2020): this is quite correct. We need to create a + // new session here (in the session repo) which would have a cert. + privKey, certBytes, err := session.TestCert(wrapper, "u_1234567890", jobId) if err != nil { errorResp(err) return diff --git a/internal/servers/controller/handlers/workers/worker_service.go b/internal/servers/controller/handlers/workers/worker_service.go index 20c405ddb..8ab8cb8d0 100644 --- a/internal/servers/controller/handlers/workers/worker_service.go +++ b/internal/servers/controller/handlers/workers/worker_service.go @@ -9,7 +9,7 @@ import ( pbs "github.com/hashicorp/boundary/internal/gen/controller/servers/services" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/servers/controller/common" - "github.com/hashicorp/boundary/internal/sessions" + "github.com/hashicorp/boundary/internal/session" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/go-hclog" "google.golang.org/grpc/codes" @@ -89,7 +89,7 @@ func (ws *workerServiceServer) GetSession(ctx context.Context, req *pbs.GetSessi // Derive the private key, which should match. Deriving on both ends allows // us to not store it in the DB. - _, privKey, err := sessions.DeriveED25519Key(wrapper, sessionInfo.GetUserId(), req.GetId()) + _, privKey, err := session.DeriveED25519Key(wrapper, sessionInfo.GetUserId(), req.GetId()) if err != nil { return nil, status.Errorf(codes.Internal, "Error deriving session key: %v", err) } diff --git a/internal/session/connection.go b/internal/session/connection.go new file mode 100644 index 000000000..c1feb4a39 --- /dev/null +++ b/internal/session/connection.go @@ -0,0 +1,179 @@ +package session + +import ( + "context" + "fmt" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + defaultConnectionTableName = "session_connection" +) + +// Connection contains information about session's connection to a target +type Connection struct { + // PublicId is used to access the connection via an API + PublicId string `json:"public_id,omitempty" gorm:"primary_key"` + // SessionId of the connection + SessionId string `json:"session_id,omitempty" gorm:"default:null"` + // ClientTcpAddress of the connection + ClientTcpAddress string `json:"client_tcp_address,omitempty" gorm:"default:null"` + // ClientTcpPort of the connection + ClientTcpPort uint32 `json:"client_tcp_port,omitempty" gorm:"default:null"` + // BackendTcpAddress of the connection + BackendTcpAddress string `json:"backend_tcp_address,omitempty" gorm:"default:null"` + // BackendTcpPort of the connection + BackendTcpPort uint32 `json:"backend_tcp_port,omitempty" gorm:"default:null"` + // BytesUp of the connection + BytesUp uint64 `json:"bytes_up,omitempty" gorm:"default:null"` + // BytesDown of the connection + BytesDown uint64 `json:"bytes_down,omitempty" gorm:"default:null"` + // ClosedReason of the conneciont + ClosedReason string `json:"closed_reason,omitempty" gorm:"default:null"` + // CreateTime from the RDBMS + CreateTime *timestamp.Timestamp `json:"create_time,omitempty" gorm:"default:current_timestamp"` + // UpdateTime from the RDBMS + UpdateTime *timestamp.Timestamp `json:"update_time,omitempty" gorm:"default:current_timestamp"` + // Version of the connection + Version uint32 `json:"version,omitempty" gorm:"default:null"` + + tableName string `gorm:"-"` +} + +func (c *Connection) GetPublicId() string { + return c.PublicId +} + +var _ Cloneable = (*Connection)(nil) +var _ db.VetForWriter = (*Connection)(nil) + +// New creates a new in memory session. No options +// are currently supported. +func NewConnection(sessionID, clientTcpAddress string, clientTcpPort uint32, backendTcpAddr string, backendTcpPort uint32, opt ...Option) (*Connection, error) { + c := Connection{ + SessionId: sessionID, + ClientTcpAddress: clientTcpAddress, + ClientTcpPort: clientTcpPort, + BackendTcpAddress: backendTcpAddr, + BackendTcpPort: backendTcpPort, + } + if err := c.validateNewConnection("new connection:"); err != nil { + return nil, err + } + return &c, nil +} + +// AllocConnection will allocate a Session +func AllocConnection() Connection { + return Connection{} +} + +// Clone creates a clone of the Session +func (c *Connection) Clone() interface{} { + clone := &Connection{ + PublicId: c.PublicId, + SessionId: c.SessionId, + ClientTcpAddress: c.ClientTcpAddress, + ClientTcpPort: c.ClientTcpPort, + BackendTcpAddress: c.BackendTcpAddress, + BackendTcpPort: c.BackendTcpPort, + BytesUp: c.BytesUp, + BytesDown: c.BytesDown, + ClosedReason: c.ClosedReason, + Version: c.Version, + } + if c.CreateTime != nil { + clone.CreateTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: c.CreateTime.Timestamp.Seconds, + Nanos: c.CreateTime.Timestamp.Nanos, + }, + } + } + if c.UpdateTime != nil { + clone.UpdateTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: c.UpdateTime.Timestamp.Seconds, + Nanos: c.UpdateTime.Timestamp.Nanos, + }, + } + } + return clone +} + +// VetForWrite implements db.VetForWrite() interface and validates the connection +// before it's written. +func (c *Connection) VetForWrite(ctx context.Context, r db.Reader, opType db.OpType, opt ...db.Option) error { + opts := db.GetOpts(opt...) + if c.PublicId == "" { + return fmt.Errorf("connection vet for write: missing public id: %w", db.ErrInvalidParameter) + } + switch opType { + case db.CreateOp: + if err := c.validateNewConnection("connection vet for write:"); err != nil { + return err + } + case db.UpdateOp: + switch { + case contains(opts.WithFieldMaskPaths, "PublicId"): + return fmt.Errorf("connection vet for write: public id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "SessionId"): + return fmt.Errorf("connection vet for write: session id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "ClientTcpAddress"): + return fmt.Errorf("connection vet for write: client address is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "ClientTcpPort"): + return fmt.Errorf("connection vet for write: client port is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "BackendTcpAddress"): + return fmt.Errorf("connection vet for write: backend address is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "BackendTcpPort"): + return fmt.Errorf("connection vet for write: backend port is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "CreateTime"): + return fmt.Errorf("connection vet for write: create time is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "UpdateTime"): + return fmt.Errorf("connection vet for write: update time is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "ClosedReason"): + if _, err := convertToClosedReason(c.ClosedReason); err != nil { + return fmt.Errorf("connection vet for write: %w", db.ErrInvalidParameter) + } + } + } + return nil +} + +// TableName returns the tablename to override the default gorm table name +func (c *Connection) TableName() string { + if c.tableName != "" { + return c.tableName + } + return defaultConnectionTableName +} + +// SetTableName sets the tablename and satisfies the ReplayableMessage +// interface. If the caller attempts to set the name to "" the name will be +// reset to the default name. +func (c *Connection) SetTableName(n string) { + c.tableName = n +} + +// validateNewConnection checks everything but the connection's PublicId +func (c *Connection) validateNewConnection(errorPrefix string) error { + if c.SessionId == "" { + return fmt.Errorf("%s missing session id: %w", errorPrefix, db.ErrInvalidParameter) + } + if c.ClientTcpAddress == "" { + return fmt.Errorf("%s missing client address: %w", errorPrefix, db.ErrInvalidParameter) + } + if c.ClientTcpPort == 0 { + return fmt.Errorf("%s missing client port: %w", errorPrefix, db.ErrInvalidParameter) + } + if c.BackendTcpAddress == "" { + return fmt.Errorf("%s missing backend address: %w", errorPrefix, db.ErrInvalidParameter) + } + if c.BackendTcpPort == 0 { + return fmt.Errorf("%s missing backend port: %w", errorPrefix, db.ErrInvalidParameter) + } + return nil +} diff --git a/internal/session/connection_closed_reason.go b/internal/session/connection_closed_reason.go new file mode 100644 index 000000000..acd988b12 --- /dev/null +++ b/internal/session/connection_closed_reason.go @@ -0,0 +1,42 @@ +package session + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/db" +) + +// ClosedReason of the connection +type ClosedReason string + +const ( + UnknownReason ClosedReason = "unknown" + ConnectionTimedOut ClosedReason = "timed out" + ConnectionClosedByUser ClosedReason = "closed by end-user" + ConnectionCancelled ClosedReason = "cancelled" + ConnectionNetworkError ClosedReason = "network error" + ConnectionSystemError ClosedReason = "system error" +) + +// String representation of the termination reason +func (r ClosedReason) String() string { + return string(r) +} +func convertToClosedReason(s string) (ClosedReason, error) { + switch s { + case UnknownReason.String(): + return UnknownReason, nil + case ConnectionTimedOut.String(): + return ConnectionTimedOut, nil + case ConnectionClosedByUser.String(): + return ConnectionClosedByUser, nil + case ConnectionCancelled.String(): + return ConnectionCancelled, nil + case ConnectionNetworkError.String(): + return ConnectionNetworkError, nil + case ConnectionSystemError.String(): + return ConnectionSystemError, nil + default: + return "", fmt.Errorf("closed reason: %s is not a valid reason: %w", s, db.ErrInvalidParameter) + } +} diff --git a/internal/session/connection_state.go b/internal/session/connection_state.go new file mode 100644 index 000000000..c34a03a94 --- /dev/null +++ b/internal/session/connection_state.go @@ -0,0 +1,142 @@ +package session + +import ( + "context" + "fmt" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + defaultConnectionStateTableName = "session_connection_state" +) + +// ConnectionStatus of the connection's state +type ConnectionStatus string + +const ( + StatusConnected ConnectionStatus = "connected" + StatusClosed ConnectionStatus = "closed" +) + +// String representation of the state's status +func (s ConnectionStatus) String() string { + return string(s) +} + +// ConnectionState of the state of the connection +type ConnectionState struct { + // ConnectionId is used to access the state via an API + ConnectionId string `json:"public_id,omitempty" gorm:"primary_key"` + // status of the connection + Status string `protobuf:"bytes,20,opt,name=status,proto3" json:"status,omitempty" gorm:"column:state"` + // PreviousEndTime from the RDBMS + PreviousEndTime *timestamp.Timestamp `json:"previous_end_time,omitempty" gorm:"default:current_timestamp"` + // StartTime from the RDBMS + StartTime *timestamp.Timestamp `json:"start_time,omitempty" gorm:"default:current_timestamp;primary_key"` + // EndTime from the RDBMS + EndTime *timestamp.Timestamp `json:"end_time,omitempty" gorm:"default:current_timestamp"` + + tableName string `gorm:"-"` +} + +var _ Cloneable = (*ConnectionState)(nil) +var _ db.VetForWriter = (*ConnectionState)(nil) + +// NewConnectionState creates a new in memory connection state. No options +// are currently supported. +func NewConnectionState(connectionId string, state ConnectionStatus, opt ...Option) (*ConnectionState, error) { + s := ConnectionState{ + ConnectionId: connectionId, + Status: state.String(), + } + if err := s.validate("new connection state:"); err != nil { + return nil, err + } + return &s, nil +} + +// allocConnectionState will allocate a connection State +func allocConnectionState() ConnectionState { + return ConnectionState{} +} + +// Clone creates a clone of the State +func (s *ConnectionState) Clone() interface{} { + clone := &ConnectionState{ + ConnectionId: s.ConnectionId, + Status: s.Status, + } + if s.PreviousEndTime != nil { + clone.PreviousEndTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.PreviousEndTime.Timestamp.Seconds, + Nanos: s.PreviousEndTime.Timestamp.Nanos, + }, + } + } + + if s.StartTime != nil { + clone.StartTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.StartTime.Timestamp.Seconds, + Nanos: s.StartTime.Timestamp.Nanos, + }, + } + } + if s.EndTime != nil { + clone.EndTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.EndTime.Timestamp.Seconds, + Nanos: s.EndTime.Timestamp.Nanos, + }, + } + } + return clone +} + +// VetForWrite implements db.VetForWrite() interface and validates the state +// before it's written. +func (s *ConnectionState) VetForWrite(ctx context.Context, r db.Reader, opType db.OpType, opt ...db.Option) error { + if err := s.validate("connection state vet for write:"); err != nil { + return err + } + return nil +} + +// TableName returns the tablename to override the default gorm table name +func (s *ConnectionState) TableName() string { + if s.tableName != "" { + return s.tableName + } + return defaultConnectionStateTableName +} + +// SetTableName sets the tablename and satisfies the ReplayableMessage +// interface. If the caller attempts to set the name to "" the name will be +// reset to the default name. +func (s *ConnectionState) SetTableName(n string) { + s.tableName = n +} + +// validate checks the session state +func (s *ConnectionState) validate(errorPrefix string) error { + if s.Status == "" { + return fmt.Errorf("%s missing status: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.ConnectionId == "" { + return fmt.Errorf("%s missing connection id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.StartTime != nil { + return fmt.Errorf("%s start time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.EndTime != nil { + return fmt.Errorf("%s end time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.PreviousEndTime != nil { + return fmt.Errorf("%s previous end time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + return nil +} diff --git a/internal/session/connection_state_test.go b/internal/session/connection_state_test.go new file mode 100644 index 000000000..eeabeb871 --- /dev/null +++ b/internal/session/connection_state_test.go @@ -0,0 +1,212 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnectionState_Create(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + connection := TestConnection(t, conn, session.PublicId, "127.0.0.1", 443, "127.0.0.1", 4443) + + type args struct { + connectionId string + status ConnectionStatus + } + tests := []struct { + name string + args args + want *ConnectionState + wantErr bool + wantIsErr error + create bool + wantCreateErr bool + }{ + { + name: "valid", + args: args{ + connectionId: connection.PublicId, + status: StatusClosed, + }, + want: &ConnectionState{ + ConnectionId: connection.PublicId, + Status: StatusClosed.String(), + }, + create: true, + }, + { + name: "empty-connectionId", + args: args{ + status: StatusClosed, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-status", + args: args{ + connectionId: connection.PublicId, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewConnectionState(tt.args.connectionId, tt.args.status) + if tt.wantErr { + require.Error(err) + assert.True(errors.Is(err, tt.wantIsErr)) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + if tt.create { + err = db.New(conn).Create(context.Background(), got) + if tt.wantCreateErr { + assert.Error(err) + return + } else { + assert.NoError(err) + } + } + }) + } +} + +func TestConnectionState_Delete(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + c2 := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + + tests := []struct { + name string + state *ConnectionState + deleteConnectionStateId string + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + state: TestConnectionState(t, conn, c.PublicId, StatusClosed), + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-id", + state: TestConnectionState(t, conn, c2.PublicId, StatusClosed), + deleteConnectionStateId: func() string { + id, err := db.NewPublicId(ConnectionStatePrefix) + require.NoError(t, err) + return id + }(), + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + var initialState ConnectionState + err := rw.LookupWhere(context.Background(), &initialState, "connection_id = ? and state = ?", tt.state.ConnectionId, tt.state.Status) + require.NoError(err) + + deleteState := allocConnectionState() + if tt.deleteConnectionStateId != "" { + deleteState.ConnectionId = tt.deleteConnectionStateId + } else { + deleteState.ConnectionId = tt.state.ConnectionId + } + deleteState.StartTime = initialState.StartTime + deletedRows, err := rw.Delete(context.Background(), &deleteState) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundState := allocConnectionState() + err = rw.LookupWhere(context.Background(), &foundState, "connection_id = ? and start_time = ?", tt.state.ConnectionId, initialState.StartTime) + require.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + }) + } +} + +func TestConnectionState_Clone(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + t.Run("valid", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + state := TestConnectionState(t, conn, c.PublicId, StatusConnected) + cp := state.Clone() + assert.Equal(cp.(*ConnectionState), state) + }) + t.Run("not-equal", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + state := TestConnectionState(t, conn, c.PublicId, StatusConnected) + state2 := TestConnectionState(t, conn, c.PublicId, StatusConnected) + + cp := state.Clone() + assert.NotEqual(cp.(*ConnectionState), state2) + }) +} + +func TestConnectionState_SetTableName(t *testing.T) { + t.Parallel() + defaultTableName := defaultConnectionStateTableName + tests := []struct { + name string + setNameTo string + want string + }{ + { + name: "new-name", + setNameTo: "new-name", + want: "new-name", + }, + { + name: "reset to default", + setNameTo: "", + want: defaultTableName, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + def := allocConnectionState() + require.Equal(defaultTableName, def.TableName()) + s := allocConnectionState() + s.SetTableName(tt.setNameTo) + assert.Equal(tt.want, s.TableName()) + }) + } +} diff --git a/internal/session/connection_test.go b/internal/session/connection_test.go new file mode 100644 index 000000000..8f0271b88 --- /dev/null +++ b/internal/session/connection_test.go @@ -0,0 +1,255 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnection_Create(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + sessionId string + clientTcpAddress string + clientTcpPort uint32 + backendTcpAddress string + backendTcpPort uint32 + } + tests := []struct { + name string + args args + want *Connection + wantErr bool + wantIsErr error + create bool + wantCreateErr bool + }{ + { + name: "valid", + args: args{ + sessionId: s.PublicId, + clientTcpAddress: "127.0.0.1", + clientTcpPort: 22, + backendTcpAddress: "127.0.0.1", + backendTcpPort: 2222, + }, + want: &Connection{ + SessionId: s.PublicId, + ClientTcpAddress: "127.0.0.1", + ClientTcpPort: 22, + BackendTcpAddress: "127.0.0.1", + BackendTcpPort: 2222, + }, + create: true, + }, + { + name: "empty-session-id", + args: args{ + clientTcpAddress: "127.0.0.1", + clientTcpPort: 22, + backendTcpAddress: "127.0.0.1", + backendTcpPort: 2222, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-client-address", + args: args{ + sessionId: s.PublicId, + clientTcpPort: 22, + backendTcpAddress: "127.0.0.1", + backendTcpPort: 2222, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-client-port", + args: args{ + sessionId: s.PublicId, + clientTcpAddress: "localhost", + backendTcpAddress: "127.0.0.1", + backendTcpPort: 2222, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-backend-address", + args: args{ + sessionId: s.PublicId, + clientTcpAddress: "localhost", + clientTcpPort: 22, + backendTcpPort: 2222, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-backend-port", + args: args{ + sessionId: s.PublicId, + clientTcpAddress: "localhost", + clientTcpPort: 22, + backendTcpAddress: "127.0.0.1", + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewConnection( + tt.args.sessionId, + tt.args.clientTcpAddress, + tt.args.clientTcpPort, + tt.args.backendTcpAddress, + tt.args.backendTcpPort, + ) + if tt.wantErr { + require.Error(err) + assert.True(errors.Is(err, tt.wantIsErr)) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + if tt.create { + id, err := db.NewPublicId(ConnectionPrefix) + require.NoError(err) + got.PublicId = id + err = db.New(conn).Create(context.Background(), got) + if tt.wantCreateErr { + assert.Error(err) + return + } else { + assert.NoError(err) + } + } + }) + } +} + +func TestConnection_Delete(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + + tests := []struct { + name string + connection *Connection + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + connection: TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222), + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-id", + connection: func() *Connection { + c := AllocConnection() + id, err := db.NewPublicId(ConnectionPrefix) + require.NoError(t, err) + c.PublicId = id + return &c + }(), + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + deleteConnection := AllocConnection() + deleteConnection.PublicId = tt.connection.PublicId + deletedRows, err := rw.Delete(context.Background(), &deleteConnection) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundConnection := AllocConnection() + foundConnection.PublicId = tt.connection.PublicId + err = rw.LookupById(context.Background(), &foundConnection) + require.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + }) + } +} + +func TestConnection_Clone(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + t.Run("valid", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + cp := c.Clone() + assert.Equal(cp.(*Connection), c) + }) + t.Run("not-equal", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + c2 := TestConnection(t, conn, s.PublicId, "127.0.0.1", 80, "127.0.0.1", 8080) + + cp := c.Clone() + assert.NotEqual(cp.(*Connection), c2) + }) +} + +func TestConnection_SetTableName(t *testing.T) { + t.Parallel() + defaultTableName := defaultConnectionTableName + tests := []struct { + name string + setNameTo string + want string + }{ + { + name: "new-name", + setNameTo: "new-name", + want: "new-name", + }, + { + name: "reset to default", + setNameTo: "", + want: defaultTableName, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + def := AllocConnection() + require.Equal(defaultTableName, def.TableName()) + c := AllocConnection() + c.SetTableName(tt.setNameTo) + assert.Equal(tt.want, c.TableName()) + }) + } +} diff --git a/internal/session/ids.go b/internal/session/ids.go new file mode 100644 index 000000000..6f0fc76b8 --- /dev/null +++ b/internal/session/ids.go @@ -0,0 +1,53 @@ +package session + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/db" +) + +const ( + // SessionPrefix for session PK ids + SessionPrefix = "s" + + // StatePrefix for state PK ids + StatePrefix = "ss" + + // ConnectionPrefix for connection PK ids + ConnectionPrefix = "sc" + + // ConnectionStatePrefix for connection state PK ids + ConnectionStatePrefix = "scs" +) + +func newId() (string, error) { + id, err := db.NewPublicId(SessionPrefix) + if err != nil { + return "", fmt.Errorf("new session id: %w", err) + } + return id, nil +} + +func newStateId() (string, error) { + id, err := db.NewPublicId(StatePrefix) + if err != nil { + return "", fmt.Errorf("new session state id: %w", err) + } + return id, nil +} + +func newConnectionId() (string, error) { + id, err := db.NewPublicId(ConnectionPrefix) + if err != nil { + return "", fmt.Errorf("new session connection id: %w", err) + } + return id, nil +} + +func newConnectionStateId() (string, error) { + id, err := db.NewPublicId(ConnectionStatePrefix) + if err != nil { + return "", fmt.Errorf("new session connection state id: %w", err) + } + return id, nil +} diff --git a/internal/session/ids_test.go b/internal/session/ids_test.go new file mode 100644 index 000000000..0c49fc312 --- /dev/null +++ b/internal/session/ids_test.go @@ -0,0 +1,33 @@ +package session + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Ids(t *testing.T) { + t.Parallel() + t.Run("s", func(t *testing.T) { + id, err := newId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, SessionPrefix+"_")) + }) + t.Run("ss", func(t *testing.T) { + id, err := newStateId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, StatePrefix+"_")) + }) + t.Run("sc", func(t *testing.T) { + id, err := newConnectionId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, ConnectionPrefix+"_")) + }) + t.Run("scs", func(t *testing.T) { + id, err := newConnectionStateId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, ConnectionStatePrefix+"_")) + }) +} diff --git a/internal/session/immutable_fields_test.go b/internal/session/immutable_fields_test.go new file mode 100644 index 000000000..642ad043e --- /dev/null +++ b/internal/session/immutable_fields_test.go @@ -0,0 +1,334 @@ +package session + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestSession_ImmutableFields(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + rw := db.New(conn) + iamRepo := iam.TestRepo(t, conn, wrapper) + + ts := timestamp.Timestamp{Timestamp: ×tamppb.Timestamp{Seconds: 0, Nanos: 0}} + + _, _ = iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + new := TestDefaultSession(t, conn, wrapper, iamRepo) + + var tests = []struct { + name string + update *Session + fieldMask []string + }{ + { + name: "public_id", + update: func() *Session { + s := new.Clone().(*Session) + s.PublicId = "o_thisIsNotAValidId" + return s + }(), + fieldMask: []string{"PublicId"}, + }, + { + name: "certificate", + update: func() *Session { + s := new.Clone().(*Session) + s.Certificate = []byte("fake cert for test") + return s + }(), + fieldMask: []string{"Certificate"}, + }, + { + name: "expiration time", + update: func() *Session { + s := new.Clone().(*Session) + s.ExpirationTime = &ts + return s + }(), + fieldMask: []string{"ExpirationTime"}, + }, + { + name: "create time", + update: func() *Session { + s := new.Clone().(*Session) + s.CreateTime = &ts + return s + }(), + fieldMask: []string{"CreateTime"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + orig := new.Clone() + err := rw.LookupById(context.Background(), orig) + require.NoError(err) + + rowsUpdated, err := rw.Update(context.Background(), tt.update, tt.fieldMask, nil, db.WithSkipVetForWrite(true)) + require.Error(err) + assert.Equal(0, rowsUpdated) + + after := new.Clone() + err = rw.LookupById(context.Background(), after) + require.NoError(err) + + assert.Equal(orig.(*Session), after) + + }) + } +} + +func TestState_ImmutableFields(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + + ts := timestamp.Timestamp{Timestamp: ×tamppb.Timestamp{Seconds: 0, Nanos: 0}} + + _, _ = iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + state := TestState(t, conn, session.PublicId, StatusPending) + + var new State + err := rw.LookupWhere(context.Background(), &new, "session_id = ? and state = ?", state.SessionId, state.Status) + require.NoError(t, err) + + var tests = []struct { + name string + update *State + fieldMask []string + }{ + { + name: "session_id", + update: func() *State { + s := new.Clone().(*State) + s.SessionId = "s_thisIsNotAValidId" + return s + }(), + fieldMask: []string{"PublicId"}, + }, + { + name: "start time", + update: func() *State { + s := new.Clone().(*State) + s.StartTime = &ts + return s + }(), + fieldMask: []string{"StartTime"}, + }, + { + name: "previous_end_time", + update: func() *State { + s := new.Clone().(*State) + s.PreviousEndTime = &ts + return s + }(), + fieldMask: []string{"PreviousEndTime"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + orig := new.Clone() + err := rw.LookupWhere(context.Background(), orig, "session_id = ? and start_time = ?", new.SessionId, new.StartTime) + require.NoError(err) + + rowsUpdated, err := rw.Update(context.Background(), tt.update, tt.fieldMask, nil, db.WithSkipVetForWrite(true)) + require.Error(err) + assert.Equal(0, rowsUpdated) + + after := new.Clone() + err = rw.LookupWhere(context.Background(), after, "session_id = ? and start_time = ?", new.SessionId, new.StartTime) + require.NoError(err) + assert.Equal(orig.(*State), after) + }) + } +} + +func TestConnection_ImmutableFields(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + rw := db.New(conn) + iamRepo := iam.TestRepo(t, conn, wrapper) + + ts := timestamp.Timestamp{Timestamp: ×tamppb.Timestamp{Seconds: 0, Nanos: 0}} + + _, _ = iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + new := TestConnection(t, conn, session.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + + var tests = []struct { + name string + update *Connection + fieldMask []string + }{ + { + name: "public_id", + update: func() *Connection { + c := new.Clone().(*Connection) + c.PublicId = "sc_thisIsNotAValidId" + return c + }(), + fieldMask: []string{"PublicId"}, + }, + { + name: "session_id", + update: func() *Connection { + c := new.Clone().(*Connection) + c.SessionId = "s_thisIsNotAValidId" + return c + }(), + fieldMask: []string{"SessionId"}, + }, + { + name: "client_tcp_address", + update: func() *Connection { + c := new.Clone().(*Connection) + c.ClientTcpAddress = "0.0.0.0" + return c + }(), + fieldMask: []string{"ClientTcpAddress"}, + }, + { + name: "client_tcp_port", + update: func() *Connection { + c := new.Clone().(*Connection) + c.ClientTcpPort = 443 + return c + }(), + fieldMask: []string{"ClientTcpPort"}, + }, + { + name: "backend_tcp_address", + update: func() *Connection { + c := new.Clone().(*Connection) + c.BackendTcpAddress = "0.0.0.0" + return c + }(), + fieldMask: []string{"BackendTcpAddress"}, + }, + { + name: "backend_tcp_port", + update: func() *Connection { + c := new.Clone().(*Connection) + c.BackendTcpPort = 443 + return c + }(), + fieldMask: []string{"BackendTcpPort"}, + }, + { + name: "create time", + update: func() *Connection { + s := new.Clone().(*Connection) + s.CreateTime = &ts + return s + }(), + fieldMask: []string{"CreateTime"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + orig := new.Clone() + err := rw.LookupById(context.Background(), orig) + require.NoError(err) + + rowsUpdated, err := rw.Update(context.Background(), tt.update, tt.fieldMask, nil, db.WithSkipVetForWrite(true)) + require.Error(err) + assert.Equal(0, rowsUpdated) + + after := new.Clone() + err = rw.LookupById(context.Background(), after) + require.NoError(err) + + assert.Equal(orig.(*Connection), after) + + }) + } +} + +func TestConnectionState_ImmutableFields(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + + ts := timestamp.Timestamp{Timestamp: ×tamppb.Timestamp{Seconds: 0, Nanos: 0}} + + _, _ = iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + connection := TestConnection(t, conn, session.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222) + state := TestConnectionState(t, conn, connection.PublicId, StatusClosed) + + var new ConnectionState + err := rw.LookupWhere(context.Background(), &new, "connection_id = ? and state = ?", state.ConnectionId, state.Status) + require.NoError(t, err) + + var tests = []struct { + name string + update *ConnectionState + fieldMask []string + }{ + { + name: "session_id", + update: func() *ConnectionState { + s := new.Clone().(*ConnectionState) + s.ConnectionId = "sc_thisIsNotAValidId" + return s + }(), + fieldMask: []string{"PublicId"}, + }, + { + name: "start time", + update: func() *ConnectionState { + s := new.Clone().(*ConnectionState) + s.StartTime = &ts + return s + }(), + fieldMask: []string{"StartTime"}, + }, + { + name: "previous_end_time", + update: func() *ConnectionState { + s := new.Clone().(*ConnectionState) + s.PreviousEndTime = &ts + return s + }(), + fieldMask: []string{"PreviousEndTime"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + orig := new.Clone() + err := rw.LookupWhere(context.Background(), orig, "connection_id = ? and start_time = ?", new.ConnectionId, new.StartTime) + require.NoError(err) + + rowsUpdated, err := rw.Update(context.Background(), tt.update, tt.fieldMask, nil, db.WithSkipVetForWrite(true)) + require.Error(err) + assert.Equal(0, rowsUpdated) + + after := new.Clone() + err = rw.LookupWhere(context.Background(), after, "connection_id = ? and start_time = ?", new.ConnectionId, new.StartTime) + require.NoError(err) + assert.Equal(orig.(*ConnectionState), after) + }) + } +} diff --git a/internal/session/options.go b/internal/session/options.go new file mode 100644 index 000000000..48282f792 --- /dev/null +++ b/internal/session/options.go @@ -0,0 +1,75 @@ +package session + +import ( + "github.com/hashicorp/boundary/internal/db/timestamp" +) + +// 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 { + withLimit int + withOrder string + withScopeId string + withUserId string + withExpirationTime *timestamp.Timestamp + withTestTofu []byte +} + +func getDefaultOptions() options { + return options{} +} + +// WithLimit provides an option to provide a limit. Intentionally allowing +// negative integers. If WithLimit < 0, then unlimited results are returned. If +// WithLimit == 0, then default limits are used for results. +func WithLimit(limit int) Option { + return func(o *options) { + o.withLimit = limit + } +} + +// WithOrder allows specifying an order for returned values +func WithOrder(order string) Option { + return func(o *options) { + o.withOrder = order + } +} + +// WithScopeId allows specifying a scope ID criteria for the function. +func WithScopeId(scopeId string) Option { + return func(o *options) { + o.withScopeId = scopeId + } +} + +// WithUserId allows specifying a user ID criteria for the function. +func WithUserId(userId string) Option { + return func(o *options) { + o.withUserId = userId + } +} + +// WithExpirationTime allows specifying an expiration time for the session +func WithExpirationTime(exp *timestamp.Timestamp) Option { + return func(o *options) { + o.withExpirationTime = exp + } +} + +// WithTestTofu allows specifying a test tofu for a test session +func WithTestTofu(tofu []byte) Option { + return func(o *options) { + o.withTestTofu = tofu + } +} diff --git a/internal/session/options_test.go b/internal/session/options_test.go new file mode 100644 index 000000000..6e37efffd --- /dev/null +++ b/internal/session/options_test.go @@ -0,0 +1,73 @@ +package session + +import ( + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("WithLimit", func(t *testing.T) { + assert := assert.New(t) + // test default of 0 + opts := getOpts() + testOpts := getDefaultOptions() + testOpts.withLimit = 0 + assert.Equal(opts, testOpts) + + opts = getOpts(WithLimit(-1)) + testOpts = getDefaultOptions() + testOpts.withLimit = -1 + assert.Equal(opts, testOpts) + + opts = getOpts(WithLimit(1)) + testOpts = getDefaultOptions() + testOpts.withLimit = 1 + assert.Equal(opts, testOpts) + }) + t.Run("WithScopeId", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithScopeId("o_1234")) + testOpts := getDefaultOptions() + testOpts.withScopeId = "o_1234" + assert.Equal(opts, testOpts) + }) + t.Run("WithOrder", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithOrder("create_time asc")) + testOpts := getDefaultOptions() + testOpts.withOrder = "create_time asc" + assert.Equal(opts, testOpts) + }) + t.Run("WithUserId", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithUserId("u_1234")) + testOpts := getDefaultOptions() + testOpts.withUserId = "u_1234" + assert.Equal(opts, testOpts) + }) + t.Run("WithExpirationTime", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + now, err := ptypes.TimestampProto(time.Now()) + require.NoError(err) + opts := getOpts(WithExpirationTime(×tamp.Timestamp{Timestamp: now})) + testOpts := getDefaultOptions() + testOpts.withExpirationTime = ×tamp.Timestamp{Timestamp: now} + assert.Equal(opts, testOpts) + }) + t.Run("WithTestTofu", func(t *testing.T) { + assert := assert.New(t) + tofu := TestTofu(t) + opts := getOpts(WithTestTofu(tofu)) + testOpts := getDefaultOptions() + testOpts.withTestTofu = make([]byte, len(tofu)) + copy(testOpts.withTestTofu, tofu) + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/session/query.go b/internal/session/query.go new file mode 100644 index 000000000..ab4af213e --- /dev/null +++ b/internal/session/query.go @@ -0,0 +1,20 @@ +package session + +const ( + activateStateCte = ` +insert into session_state +with not_active as ( + select session_id, 'active' as state + from + session s, + session_state ss + where + s.public_id = ss.session_id and + ss.state = 'pending' and + ss.session_id = $1 and + s.version = $2 and + s.public_id not in(select session_id from session_state where session_id = $1 and state = 'active') +) +select * from not_active; +` +) diff --git a/internal/session/repository.go b/internal/session/repository.go new file mode 100644 index 000000000..f83e20e80 --- /dev/null +++ b/internal/session/repository.go @@ -0,0 +1,66 @@ +package session + +import ( + "context" + "errors" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/kms" +) + +// Clonable provides a cloning interface +type Cloneable interface { + Clone() interface{} +} + +// Repository is the session database repository +type Repository struct { + reader db.Reader + writer db.Writer + kms *kms.Kms + + // defaultLimit provides a default for limiting the number of results returned from the repo + defaultLimit int +} + +// NewRepository creates a new session Repository. Supports the options: WithLimit +// which sets a default limit on results returned by repo operations. +func NewRepository(r db.Reader, w db.Writer, kms *kms.Kms, opt ...Option) (*Repository, error) { + if r == nil { + return nil, errors.New("error creating db repository with nil reader") + } + if w == nil { + return nil, errors.New("error creating db repository with nil writer") + } + if kms == nil { + return nil, errors.New("error creating db repository with nil kms") + } + opts := getOpts(opt...) + if opts.withLimit == 0 { + // zero signals the boundary defaults should be used. + opts.withLimit = db.DefaultLimit + } + return &Repository{ + reader: r, + writer: w, + kms: kms, + defaultLimit: opts.withLimit, + }, nil +} + +// list will return a listing of resources and honor the WithLimit option or the +// repo defaultLimit. Supports WithOrder option. +func (r *Repository) list(ctx context.Context, resources interface{}, where string, args []interface{}, opt ...Option) error { + opts := getOpts(opt...) + limit := r.defaultLimit + var dbOpts []db.Option + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + dbOpts = append(dbOpts, db.WithLimit(limit)) + if opts.withOrder != "" { + dbOpts = append(dbOpts, db.WithOrder(opts.withOrder)) + } + return r.reader.SearchWhere(ctx, resources, where, args, dbOpts...) +} diff --git a/internal/session/repository_connection.go b/internal/session/repository_connection.go new file mode 100644 index 000000000..0cb963ca5 --- /dev/null +++ b/internal/session/repository_connection.go @@ -0,0 +1,297 @@ +package session + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/boundary/internal/db" + dbcommon "github.com/hashicorp/boundary/internal/db/common" +) + +// CreateConnection inserts into the repository and returns the new Connection with +// its State of "Connected". The following fields must be empty when creating a +// session: PublicId, BytesUp, BytesDown, ClosedReason, Version, CreateTime, +// UpdateTime. No options are currently supported. +func (r *Repository) CreateConnection(ctx context.Context, newConnection *Connection, opt ...Option) (*Connection, *ConnectionState, error) { + if newConnection == nil { + return nil, nil, fmt.Errorf("create connection: missing connection: %w", db.ErrInvalidParameter) + } + if newConnection.PublicId != "" { + return nil, nil, fmt.Errorf("create connection: public id is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.BytesUp != 0 { + return nil, nil, fmt.Errorf("create connection: bytes down is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.BytesDown != 0 { + return nil, nil, fmt.Errorf("create connection: bytes up is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.ClosedReason != "" { + return nil, nil, fmt.Errorf("create connection: closed reason is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.Version != 0 { + return nil, nil, fmt.Errorf("create connection: version is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.CreateTime != nil { + return nil, nil, fmt.Errorf("create connection: create time is not empty: %w", db.ErrInvalidParameter) + } + if newConnection.UpdateTime != nil { + return nil, nil, fmt.Errorf("create connection: update time is not empty: %w", db.ErrInvalidParameter) + } + + id, err := newConnectionId() + if err != nil { + return nil, nil, fmt.Errorf("create connection: %w", err) + } + newConnection.PublicId = id + + var returnedConnection *Connection + var returnedState *ConnectionState + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + returnedConnection = newConnection.Clone().(*Connection) + if err = w.Create(ctx, returnedConnection); err != nil { + return err + } + var foundStates []*ConnectionState + // trigger will create new "Connected" state + if foundStates, err = fetchConnectionStates(ctx, read, returnedConnection.PublicId); err != nil { + return err + } + if len(foundStates) != 1 { + return fmt.Errorf("%d states found for new connection %s", len(foundStates), returnedConnection.PublicId) + } + returnedState = foundStates[0] + if returnedState.Status != StatusConnected.String() { + return fmt.Errorf("new connection %s state is not valid: %s", returnedConnection.PublicId, returnedState.Status) + } + return nil + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("create connection: %w", err) + } + return returnedConnection, returnedState, err +} + +// LookupConnection will look up a connection in the repository and return the connection +// with its states. If the connection is not found, it will return nil, nil, nil. +// No options are currently supported. +func (r *Repository) LookupConnection(ctx context.Context, connectionId string, opt ...Option) (*Connection, []*ConnectionState, error) { + if connectionId == "" { + return nil, nil, fmt.Errorf("lookup connection: missing connectionId id: %w", db.ErrInvalidParameter) + } + connection := AllocConnection() + connection.PublicId = connectionId + var states []*ConnectionState + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + if err := read.LookupById(ctx, &connection); err != nil { + return fmt.Errorf("lookup connection: failed %w for %s", err, connectionId) + } + var err error + if states, err = fetchConnectionStates(ctx, read, connectionId, db.WithOrder("start_time desc")); err != nil { + return err + } + return nil + }, + ) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("lookup connection: %w", err) + } + return &connection, states, nil +} + +// ListConnections will sessions. Supports the WithLimit and WithOrder options. +func (r *Repository) ListConnections(ctx context.Context, sessionId string, opt ...Option) ([]*Connection, error) { + var connections []*Connection + err := r.list(ctx, &connections, "session_id = ?", []interface{}{sessionId}, opt...) // pass options, so WithLimit and WithOrder are supported + if err != nil { + return nil, fmt.Errorf("list connections: %w", err) + } + return connections, nil +} + +// DeleteConnection will delete a connection from the repository. +func (r *Repository) DeleteConnection(ctx context.Context, publicId string, opt ...Option) (int, error) { + if publicId == "" { + return db.NoRowsAffected, fmt.Errorf("delete connection: missing public id %w", db.ErrInvalidParameter) + } + connection := AllocConnection() + connection.PublicId = publicId + if err := r.reader.LookupByPublicId(ctx, &connection); err != nil { + return db.NoRowsAffected, fmt.Errorf("delete connection: failed %w for %s", err, publicId) + } + + var rowsDeleted int + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + deleteConnection := connection.Clone() + var err error + rowsDeleted, err = w.Delete( + ctx, + deleteConnection, + ) + if err == nil && rowsDeleted > 1 { + // return err, which will result in a rollback of the delete + return errors.New("error more than 1 connection would have been deleted") + } + return err + }, + ) + if err != nil { + return db.NoRowsAffected, fmt.Errorf("delete connection: failed %w for %s", err, publicId) + } + return rowsDeleted, nil +} + +// UpdateConnection updates the repository entry for the connection, using the +// fieldMaskPaths. Only BytesUp, BytesDown, and ClosedReason are mutable and +// will be set to NULL if set to a zero value and included in the fieldMaskPaths. +func (r *Repository) UpdateConnection(ctx context.Context, connection *Connection, version uint32, fieldMaskPaths []string, opt ...Option) (*Connection, []*ConnectionState, int, error) { + if connection == nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update connection: missing connection %w", db.ErrInvalidParameter) + } + if connection.PublicId == "" { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update connection: missing connection public id %w", db.ErrInvalidParameter) + } + for _, f := range fieldMaskPaths { + switch { + case strings.EqualFold("BytesUp", f): + case strings.EqualFold("BytesDown", f): + case strings.EqualFold("ClosedReason", f): + default: + return nil, nil, db.NoRowsAffected, fmt.Errorf("update connection: field: %s: %w", f, db.ErrInvalidFieldMask) + } + } + var dbMask, nullFields []string + dbMask, nullFields = dbcommon.BuildUpdatePaths( + map[string]interface{}{ + "BytesUp": connection.BytesUp, + "BytesDown": connection.BytesDown, + "ClosedReason": connection.ClosedReason, + }, + fieldMaskPaths, + ) + if len(dbMask) == 0 && len(nullFields) == 0 { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update connection: %w", db.ErrEmptyFieldMask) + } + + var c *Connection + var states []*ConnectionState + var rowsUpdated int + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + var err error + c = connection.Clone().(*Connection) + rowsUpdated, err = w.Update( + ctx, + c, + dbMask, + nullFields, + ) + if err != nil { + return err + } + if err == nil && rowsUpdated > 1 { + // return err, which will result in a rollback of the update + return errors.New("error more than 1 connection would have been updated ") + } + states, err = fetchConnectionStates(ctx, reader, c.PublicId, db.WithOrder("start_time desc")) + if err != nil { + return err + } + return nil + }, + ) + if err != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update connection: %w for %s", err, connection.PublicId) + } + return c, states, rowsUpdated, err +} + +// UpdateConnectionState will update the connection's state using the connection id and its +// version. No options are currently supported. +func (r *Repository) UpdateConnectionState(ctx context.Context, connectionId string, connectionVersion uint32, s ConnectionStatus, opt ...Option) (*Connection, []*ConnectionState, error) { + if connectionId == "" { + return nil, nil, fmt.Errorf("update connection state: missing session id %w", db.ErrInvalidParameter) + } + if connectionVersion == 0 { + return nil, nil, fmt.Errorf("update connection state: version cannot be zero: %w", db.ErrInvalidParameter) + } + if s == "" { + return nil, nil, fmt.Errorf("update connection state: missing connection status: %w", db.ErrInvalidParameter) + } + + newState, err := NewConnectionState(connectionId, s) + if err != nil { + return nil, nil, fmt.Errorf("update connection state: %w", err) + } + sessionConnection, _, err := r.LookupConnection(ctx, connectionId) + if err != nil { + return nil, nil, fmt.Errorf("update connection state: %w", err) + } + if sessionConnection == nil { + return nil, nil, fmt.Errorf("update connection state: unable to look up connection for %s: %w", connectionId, err) + } + + updatedConnection := AllocConnection() + var returnedStates []*ConnectionState + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + // We need to update the connection version as that's the aggregate + updatedConnection.PublicId = connectionId + updatedConnection.Version = uint32(connectionVersion) + 1 + rowsUpdated, err := w.Update(ctx, &updatedConnection, []string{"Version"}, nil, db.WithVersion(&connectionVersion)) + if err != nil { + return fmt.Errorf("unable to update connection version: %w", err) + } + if rowsUpdated != 1 { + return fmt.Errorf("updated connection and %d rows updated", rowsUpdated) + } + if err := w.Create(ctx, newState); err != nil { + return fmt.Errorf("unable to add new state: %w", err) + } + + returnedStates, err = fetchConnectionStates(ctx, reader, connectionId, db.WithOrder("start_time desc")) + if err != nil { + return err + } + return nil + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("update connection state: error creating new state: %w", err) + } + return &updatedConnection, returnedStates, nil +} + +func fetchConnectionStates(ctx context.Context, r db.Reader, connectionId string, opt ...db.Option) ([]*ConnectionState, error) { + var states []*ConnectionState + if err := r.SearchWhere(ctx, &states, "connection_id = ?", []interface{}{connectionId}, opt...); err != nil { + return nil, fmt.Errorf("fetch connection states: %w", err) + } + if len(states) == 0 { + return nil, nil + } + return states, nil +} diff --git a/internal/session/repository_connection_test.go b/internal/session/repository_connection_test.go new file mode 100644 index 000000000..b9f264571 --- /dev/null +++ b/internal/session/repository_connection_test.go @@ -0,0 +1,639 @@ +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/boundary/internal/db" + dbassert "github.com/hashicorp/boundary/internal/db/assert" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/oplog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_ListConnection(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + const testLimit = 10 + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + rw := db.New(conn) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms, WithLimit(testLimit)) + require.NoError(t, err) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + searchForSessionId string + opt []Option + } + tests := []struct { + name string + createCnt int + args args + wantCnt int + wantErr bool + }{ + { + name: "no-limit", + createCnt: repo.defaultLimit + 1, + args: args{ + searchForSessionId: session.PublicId, + opt: []Option{WithLimit(-1)}, + }, + wantCnt: repo.defaultLimit + 1, + wantErr: false, + }, + { + name: "default-limit", + createCnt: repo.defaultLimit + 1, + args: args{ + searchForSessionId: session.PublicId, + }, + wantCnt: repo.defaultLimit, + wantErr: false, + }, + { + name: "custom-limit", + createCnt: repo.defaultLimit + 1, + args: args{ + searchForSessionId: session.PublicId, + opt: []Option{WithLimit(3)}, + }, + wantCnt: 3, + wantErr: false, + }, + { + name: "bad-session-id", + createCnt: repo.defaultLimit + 1, + args: args{ + searchForSessionId: "s_thisIsNotValid", + }, + wantCnt: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + require.NoError(conn.Where("1=1").Delete(AllocConnection()).Error) + testConnections := []*Connection{} + for i := 0; i < tt.createCnt; i++ { + c := TestConnection(t, conn, + session.PublicId, + "127.0.0.1", + 22, + "127.0.0.1", + 2222, + ) + testConnections = append(testConnections, c) + } + assert.Equal(tt.createCnt, len(testConnections)) + got, err := repo.ListConnections(context.Background(), tt.args.searchForSessionId, tt.args.opt...) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(tt.wantCnt, len(got)) + }) + } + t.Run("withOrder", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + require.NoError(conn.Where("1=1").Delete(AllocConnection()).Error) + wantCnt := 5 + for i := 0; i < wantCnt; i++ { + _ = TestConnection(t, conn, + session.PublicId, + "127.0.0.1", + 22, + "127.0.0.1", + 2222, + ) + } + got, err := repo.ListConnections(context.Background(), session.PublicId, WithOrder("create_time asc")) + require.NoError(err) + assert.Equal(wantCnt, len(got)) + + for i := 0; i < len(got)-1; i++ { + first, err := ptypes.Timestamp(got[i].CreateTime.Timestamp) + require.NoError(err) + second, err := ptypes.Timestamp(got[i+1].CreateTime.Timestamp) + require.NoError(err) + assert.True(first.Before(second)) + } + }) +} + +func TestRepository_CreateConnection(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + connection *Connection + } + tests := []struct { + name string + args args + wantErr bool + wantIsError error + }{ + { + name: "valid", + args: args{ + connection: func() *Connection { + c, err := NewConnection( + session.PublicId, + "127.0.0.1", + 22, + "127.0.0.1", + 2222, + ) + require.NoError(t, err) + return c + }(), + }, + wantErr: false, + }, + { + name: "empty-session-id", + args: args{ + connection: &Connection{ + ClientTcpAddress: "127.0.0.1", + ClientTcpPort: 22, + BackendTcpAddress: "127.0.0.1", + BackendTcpPort: 2222, + }, + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-client-address", + args: args{ + connection: &Connection{ + SessionId: session.PublicId, + ClientTcpPort: 22, + BackendTcpAddress: "127.0.0.1", + BackendTcpPort: 2222, + }, + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-client-port", + args: args{ + connection: &Connection{ + SessionId: session.PublicId, + ClientTcpAddress: "127.0.0.1", + BackendTcpAddress: "127.0.0.1", + BackendTcpPort: 2222, + }, + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-backend-address", + args: args{ + connection: &Connection{ + SessionId: session.PublicId, + ClientTcpAddress: "127.0.0.1", + ClientTcpPort: 22, + BackendTcpPort: 2222, + }, + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-backend-port", + args: args{ + connection: &Connection{ + SessionId: session.PublicId, + ClientTcpAddress: "127.0.0.1", + ClientTcpPort: 22, + BackendTcpAddress: "127.0.0.1", + }, + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + connection, st, err := repo.CreateConnection(context.Background(), tt.args.connection) + if tt.wantErr { + assert.Error(err) + assert.Nil(connection) + assert.Nil(st) + if tt.wantIsError != nil { + assert.True(errors.Is(err, tt.wantIsError)) + } + return + } + require.NoError(err) + assert.NotNil(connection.CreateTime) + assert.NotNil(st.StartTime) + assert.Equal(st.Status, StatusConnected.String()) + found, foundStates, err := repo.LookupConnection(context.Background(), connection.PublicId) + assert.NoError(err) + assert.Equal(found, connection) + + err = db.TestVerifyOplog(t, rw, connection.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + + require.Equal(1, len(foundStates)) + assert.Equal(foundStates[0].Status, StatusConnected.String()) + }) + } +} + +func TestRepository_UpdateConnectionState(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + tests := []struct { + name string + connection *Connection + newStatus ConnectionStatus + overrideConnectionId *string + overrideConnectionVersion *uint32 + wantStateCnt int + wantErr bool + wantIsError error + }{ + { + name: "closed", + connection: TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222), + newStatus: StatusClosed, + wantStateCnt: 2, + wantErr: false, + }, + { + name: "bad-version", + connection: TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222), + newStatus: StatusClosed, + overrideConnectionVersion: func() *uint32 { + v := uint32(22) + return &v + }(), + wantErr: true, + }, + { + name: "empty-version", + connection: TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222), + newStatus: StatusClosed, + overrideConnectionVersion: func() *uint32 { + v := uint32(0) + return &v + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "bad-connectionId", + connection: TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222), + newStatus: StatusClosed, + overrideConnectionId: func() *string { + s := "sc_thisIsNotValid" + return &s + }(), + wantErr: true, + }, + { + name: "empty-connectionId", + connection: TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222), + newStatus: StatusClosed, + overrideConnectionId: func() *string { + s := "" + return &s + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + var id string + var version uint32 + switch { + case tt.overrideConnectionId != nil: + id = *tt.overrideConnectionId + default: + id = tt.connection.PublicId + } + switch { + case tt.overrideConnectionVersion != nil: + version = *tt.overrideConnectionVersion + default: + version = tt.connection.Version + } + + s, ss, err := repo.UpdateConnectionState(context.Background(), id, version, tt.newStatus) + if tt.wantErr { + require.Error(err) + if tt.wantIsError != nil { + assert.Truef(errors.Is(err, tt.wantIsError), "unexpected error %s", err.Error()) + } + return + } + require.NoError(err) + require.NotNil(s) + require.NotNil(ss) + assert.Equal(tt.wantStateCnt, len(ss)) + assert.Equal(tt.newStatus.String(), ss[0].Status) + }) + } +} + +func TestRepository_UpdateConnection(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + closedReason ClosedReason + bytesUp uint64 + bytesDown uint64 + fieldMaskPaths []string + opt []Option + publicId *string // not updateable - db.ErrInvalidFieldMask + sessionId string // not updateable - db.ErrInvalidFieldMask + clientTcpAddress string // not updateable - db.ErrInvalidFieldMask + clientTcpPort uint32 // not updateable - db.ErrInvalidFieldMask + backendTcpAddress string // not updateable - db.ErrInvalidFieldMask + backendTcpPort uint32 // not updateable - db.ErrInvalidFieldMask + } + tests := []struct { + name string + args args + wantRowsUpdate int + wantErr bool + wantIsError error + }{ + { + name: "valid", + args: args{ + closedReason: ConnectionClosedByUser, + bytesUp: uint64(111), + bytesDown: uint64(1), + fieldMaskPaths: []string{"ClosedReason", "BytesUp", "BytesDown"}, + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "publicId", + args: args{ + publicId: func() *string { + id, err := newConnectionId() + require.NoError(t, err) + return &id + }(), + fieldMaskPaths: []string{"PublicId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "sessionId", + args: args{ + sessionId: func() string { + id, err := newId() + require.NoError(t, err) + return id + }(), + fieldMaskPaths: []string{"SessionId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "clientTcpAddress", + args: args{ + clientTcpAddress: "127.0.0.1", + fieldMaskPaths: []string{"ClientTcpAddress"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "clientTcpPort", + args: args{ + clientTcpPort: 443, + fieldMaskPaths: []string{"ClientTcpPort"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "backendTcpAddress", + args: args{ + backendTcpAddress: "127.0.0.1", + fieldMaskPaths: []string{"BackendTcpAddress"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "backendTcpPort", + args: args{ + backendTcpPort: 4443, + fieldMaskPaths: []string{"BackendTcpPort"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + c := TestConnection(t, conn, session.PublicId, "0.0.0.0", 22, "127.0.0.1", 2222) + + updateConnection := AllocConnection() + updateConnection.PublicId = c.PublicId + if tt.args.publicId != nil { + updateConnection.PublicId = *tt.args.publicId + } + updateConnection.BytesUp = tt.args.bytesUp + updateConnection.BytesDown = tt.args.bytesDown + updateConnection.ClosedReason = tt.args.closedReason.String() + updateConnection.Version = c.Version + afterUpdate, afterUpdateState, updatedRows, err := repo.UpdateConnection(context.Background(), &updateConnection, updateConnection.Version, tt.args.fieldMaskPaths, tt.args.opt...) + + if tt.wantErr { + require.Error(err) + if tt.wantIsError != nil { + assert.Truef(errors.Is(err, tt.wantIsError), "unexpected error: %s", err.Error()) + } + assert.Nil(afterUpdate) + assert.Nil(afterUpdateState) + assert.Equal(0, updatedRows) + err = db.TestVerifyOplog(t, rw, c.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + return + } + require.NoError(err) + assert.Equal(tt.wantRowsUpdate, updatedRows) + require.NotNil(afterUpdate) + require.NotNil(afterUpdateState) + switch tt.name { + case "valid-no-op": + assert.Equal(c.UpdateTime, afterUpdate.UpdateTime) + default: + assert.NotEqual(c.UpdateTime, afterUpdate.UpdateTime) + } + found, foundStates, err := repo.LookupConnection(context.Background(), c.PublicId) + require.NoError(err) + assert.Equal(afterUpdate, found) + dbassrt := dbassert.New(t, rw) + if tt.args.bytesUp == 0 { + dbassrt.IsNull(found, "BytesUp") + } + if tt.args.bytesDown == 0 { + dbassrt.IsNull(found, "BytesDown") + } + if tt.args.closedReason == "" { + dbassrt.IsNull(found, "ClosedReason") + } + assert.Equal(tt.args.closedReason.String(), found.ClosedReason) + assert.Equal(tt.args.bytesUp, found.BytesUp) + assert.Equal(tt.args.bytesDown, found.BytesDown) + + err = db.TestVerifyOplog(t, rw, c.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + + switch { + case tt.args.closedReason != "": + require.Equal(2, len(foundStates)) + // sorted by StartTime desc + assert.Equal(StatusClosed.String(), foundStates[0].Status) + assert.Equal(StatusConnected.String(), foundStates[1].Status) + default: + require.Equal(1, len(foundStates)) + assert.Equal(StatusConnected.String(), foundStates[0].Status) + } + }) + } + +} + +func TestRepository_DeleteConnection(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + connection *Connection + opt []Option + } + tests := []struct { + name string + args args + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + args: args{ + connection: TestConnection(t, conn, session.PublicId, "127.0.0.1", 22, "127.0.0.1", 2222), + }, + wantRowsDeleted: 1, + wantErr: false, + }, + { + name: "no-public-id", + args: args{ + connection: func() *Connection { + c := AllocConnection() + return &c + }(), + }, + wantRowsDeleted: 0, + wantErr: true, + wantErrMsg: "delete connection: missing public id invalid parameter", + }, + { + name: "not-found", + args: args{ + connection: func() *Connection { + c := AllocConnection() + id, err := newConnectionId() + require.NoError(t, err) + c.PublicId = id + return &c + }(), + }, + wantRowsDeleted: 0, + wantErr: true, + wantErrMsg: "delete connection: failed record not found for ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + deletedRows, err := repo.DeleteConnection(context.Background(), tt.args.connection.PublicId, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + assert.Equal(0, deletedRows) + assert.Contains(err.Error(), tt.wantErrMsg) + err = db.TestVerifyOplog(t, rw, tt.args.connection.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsDeleted, deletedRows) + found, _, err := repo.LookupConnection(context.Background(), tt.args.connection.PublicId) + assert.NoError(err) + assert.Nil(found) + + err = db.TestVerifyOplog(t, rw, tt.args.connection.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + }) + } +} diff --git a/internal/session/repository_session.go b/internal/session/repository_session.go new file mode 100644 index 000000000..6408e9e86 --- /dev/null +++ b/internal/session/repository_session.go @@ -0,0 +1,441 @@ +package session + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/boundary/internal/db" + dbcommon "github.com/hashicorp/boundary/internal/db/common" + "github.com/hashicorp/boundary/internal/kms" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/vault/sdk/helper/strutil" +) + +// CreateSession inserts into the repository and returns the new Session with +// its State of "Pending". The following fields must be empty when creating a +// session: ServerId, ServerType, and PublicId. No options are +// currently supported. +func (r *Repository) CreateSession(ctx context.Context, sessionWrapper wrapping.Wrapper, newSession *Session, opt ...Option) (*Session, *State, ed25519.PrivateKey, error) { + if newSession == nil { + return nil, nil, nil, fmt.Errorf("create session: missing session: %w", db.ErrInvalidParameter) + } + if newSession.PublicId != "" { + return nil, nil, nil, fmt.Errorf("create session: public id is not empty: %w", db.ErrInvalidParameter) + } + if len(newSession.Certificate) != 0 { + return nil, nil, nil, fmt.Errorf("create session: certificate is not empty: %w", db.ErrInvalidParameter) + } + if newSession.TargetId == "" { + return nil, nil, nil, fmt.Errorf("create session: target id is empty: %w", db.ErrInvalidParameter) + } + if newSession.HostId == "" { + return nil, nil, nil, fmt.Errorf("create session: user id is empty: %w", db.ErrInvalidParameter) + } + if newSession.UserId == "" { + return nil, nil, nil, fmt.Errorf("create session: user id is empty: %w", db.ErrInvalidParameter) + } + if newSession.HostSetId == "" { + return nil, nil, nil, fmt.Errorf("create session: host set id is empty: %w", db.ErrInvalidParameter) + } + if newSession.AuthTokenId == "" { + return nil, nil, nil, fmt.Errorf("create session: auth token id is empty: %w", db.ErrInvalidParameter) + } + if newSession.ScopeId == "" { + return nil, nil, nil, fmt.Errorf("create session: scope id is empty: %w", db.ErrInvalidParameter) + } + if newSession.ServerId != "" { + return nil, nil, nil, fmt.Errorf("create session: server id must empty: %w", db.ErrInvalidParameter) + } + if newSession.ServerType != "" { + return nil, nil, nil, fmt.Errorf("create session: server type must empty: %w", db.ErrInvalidParameter) + } + if newSession.CtTofuToken != nil { + return nil, nil, nil, fmt.Errorf("create session: ct must be empty: %w", db.ErrInvalidParameter) + } + if newSession.TofuToken != nil { + return nil, nil, nil, fmt.Errorf("create session: tofu token must be empty: %w", db.ErrInvalidParameter) + } + + id, err := newId() + if err != nil { + return nil, nil, nil, fmt.Errorf("create session: %w", err) + } + + privKey, certBytes, err := newCert(sessionWrapper, newSession.UserId, id) + if err != nil { + return nil, nil, nil, fmt.Errorf("create session: %w", err) + } + newSession.Certificate = certBytes + newSession.PublicId = id + + var returnedSession *Session + var returnedState *State + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + returnedSession = newSession.Clone().(*Session) + if err = w.Create(ctx, returnedSession); err != nil { + return err + } + var foundStates []*State + // trigger will create new "Pending" state + if foundStates, err = fetchStates(ctx, read, returnedSession.PublicId); err != nil { + return err + } + if len(foundStates) != 1 { + return fmt.Errorf("%d states found for new session %s", len(foundStates), returnedSession.PublicId) + } + returnedState = foundStates[0] + if returnedState.Status != StatusPending.String() { + return fmt.Errorf("new session %s state is not valid: %s", returnedSession.PublicId, returnedState.Status) + } + return nil + }, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("create session: %w", err) + } + return returnedSession, returnedState, privKey, err +} + +// LookupSession will look up a session in the repository and return the session +// with its states. Returned States are ordered by start time descending. If the +// session is not found, it will return nil, nil, nil. No options are currently +// supported. +func (r *Repository) LookupSession(ctx context.Context, sessionId string, opt ...Option) (*Session, []*State, error) { + if sessionId == "" { + return nil, nil, fmt.Errorf("lookup session: missing sessionId id: %w", db.ErrInvalidParameter) + } + session := AllocSession() + session.PublicId = sessionId + var states []*State + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + if err := read.LookupById(ctx, &session); err != nil { + return fmt.Errorf("lookup session: failed %w for %s", err, sessionId) + } + var err error + if states, err = fetchStates(ctx, read, sessionId, db.WithOrder("start_time desc")); err != nil { + return err + } + return nil + }, + ) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("lookup session: %w", err) + } + if len(session.CtTofuToken) > 0 { + databaseWrapper, err := r.kms.GetWrapper(ctx, session.ScopeId, kms.KeyPurposeDatabase, kms.WithKeyId(session.KeyId)) + if err != nil { + return nil, nil, fmt.Errorf("lookup session: unable to get database wrapper: %w", err) + } + if err := session.decrypt(ctx, databaseWrapper); err != nil { + return nil, nil, fmt.Errorf("lookup session: cannot decrypt session value: %w", err) + } + } else { + session.CtTofuToken = nil + } + return &session, states, nil +} + +// ListSessions will sessions. Supports the WithLimit, WithScopeId and WithOrder options. +func (r *Repository) ListSessions(ctx context.Context, opt ...Option) ([]*Session, error) { + opts := getOpts(opt...) + var where []string + var args []interface{} + switch { + case opts.withScopeId != "": + where, args = append(where, "scope_id = ?"), append(args, opts.withScopeId) + case opts.withUserId != "": + where, args = append(where, "user_id = ?"), append(args, opts.withUserId) + } + + var sessions []*Session + err := r.list(ctx, &sessions, strings.Join(where, " and"), args, opt...) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + for _, s := range sessions { + s.CtTofuToken = nil + s.TofuToken = nil + s.KeyId = "" + } + return sessions, nil +} + +// DeleteSession will delete a session from the repository. +func (r *Repository) DeleteSession(ctx context.Context, publicId string, opt ...Option) (int, error) { + if publicId == "" { + return db.NoRowsAffected, fmt.Errorf("delete session: missing public id %w", db.ErrInvalidParameter) + } + session := AllocSession() + session.PublicId = publicId + if err := r.reader.LookupByPublicId(ctx, &session); err != nil { + return db.NoRowsAffected, fmt.Errorf("delete session: failed %w for %s", err, publicId) + } + + var rowsDeleted int + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + deleteSession := session.Clone() + var err error + rowsDeleted, err = w.Delete( + ctx, + deleteSession, + ) + if err == nil && rowsDeleted > 1 { + // return err, which will result in a rollback of the delete + return errors.New("error more than 1 session would have been deleted") + } + return err + }, + ) + if err != nil { + return db.NoRowsAffected, fmt.Errorf("delete session: failed %w for %s", err, publicId) + } + return rowsDeleted, nil +} + +// UpdateSession updates the repository entry for the session, using the +// fieldMaskPaths. Only TerminationReason, ServerId and ServerType a muttable +// and will be set to NULL if set to a zero value and included in the +// fieldMaskPaths. Returned States are ordered by start time descending. +func (r *Repository) UpdateSession(ctx context.Context, session *Session, version uint32, fieldMaskPaths []string, opt ...Option) (*Session, []*State, int, error) { + if session == nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: missing session %w", db.ErrInvalidParameter) + } + if session.PublicId == "" { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: missing session public id %w", db.ErrInvalidParameter) + } + if session.CtTofuToken != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: ct must be empty: %w", db.ErrInvalidParameter) + } + + translatedFieldMasks := make([]string, 0, len(fieldMaskPaths)) + for _, f := range fieldMaskPaths { + switch { + case strings.EqualFold("TerminationReason", f): + translatedFieldMasks = append(translatedFieldMasks, f) + case strings.EqualFold("ServerId", f): + translatedFieldMasks = append(translatedFieldMasks, f) + case strings.EqualFold("ServerType", f): + translatedFieldMasks = append(translatedFieldMasks, f) + case strings.EqualFold("TofuToken", f): + translatedFieldMasks = append(translatedFieldMasks, "CtTofuToken") + default: + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: field: %s: %w", f, db.ErrInvalidFieldMask) + } + } + + updateSession := session.Clone().(*Session) + if strutil.StrListContains(translatedFieldMasks, "CtTofuToken") && len(updateSession.TofuToken) != 0 { + if err := r.reader.LookupById(ctx, updateSession); err != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: %w for %s", err, updateSession.PublicId) + } + databaseWrapper, err := r.kms.GetWrapper(ctx, updateSession.ScopeId, kms.KeyPurposeDatabase) + if err != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: unable to get database wrapper: %w", err) + } + if err := updateSession.encrypt(ctx, databaseWrapper); err != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("create session: %w", err) + } + } + + var dbMask, nullFields []string + dbMask, nullFields = dbcommon.BuildUpdatePaths( + map[string]interface{}{ + "TerminationReason": updateSession.TerminationReason, + "ServerId": updateSession.ServerId, + "ServerType": updateSession.ServerType, + "CtTofuToken": updateSession.CtTofuToken, + }, + translatedFieldMasks, + ) + if len(dbMask) == 0 && len(nullFields) == 0 { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: %w", db.ErrEmptyFieldMask) + } + + var s *Session + var states []*State + var rowsUpdated int + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + var err error + s = updateSession.Clone().(*Session) + + rowsUpdated, err = w.Update( + ctx, + s, + dbMask, + nullFields, + ) + if err != nil { + return err + } + if err == nil && rowsUpdated > 1 { + // return err, which will result in a rollback of the update + return errors.New("error more than 1 session would have been updated ") + } + states, err = fetchStates(ctx, reader, s.PublicId, db.WithOrder("start_time desc")) + if err != nil { + return err + } + return nil + }, + ) + if err != nil { + return nil, nil, db.NoRowsAffected, fmt.Errorf("update session: %w for %s", err, session.PublicId) + } + if len(s.CtTofuToken) == 0 { + s.CtTofuToken = nil + } + return s, states, rowsUpdated, err +} + +// ActivateSession will activate the session and is called by a worker after +// authenticating the session. States are ordered by start time descending. +func (r *Repository) ActivateSession(ctx context.Context, sessionId string, sessionVersion uint32, tofuToken []byte) (*Session, []*State, error) { + if sessionId == "" { + return nil, nil, fmt.Errorf("activate session state: missing session id %w", db.ErrInvalidParameter) + } + if sessionVersion == 0 { + return nil, nil, fmt.Errorf("activate session state: version cannot be zero: %w", db.ErrInvalidParameter) + } + + updatedSession := AllocSession() + updatedSession.PublicId = sessionId + var returnedStates []*State + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + rowsAffected, err := w.Exec(activateStateCte, []interface{}{sessionId, sessionVersion}) + if err != nil { + return fmt.Errorf("unable to activate session %s: %w", sessionId, err) + } + if rowsAffected == 0 { + return fmt.Errorf("unable to activate session %s", sessionId) + } + foundSession := AllocSession() + foundSession.PublicId = sessionId + if err := r.reader.LookupById(ctx, &foundSession); err != nil { + return fmt.Errorf("lookup session: failed %w for %s", err, sessionId) + } + databaseWrapper, err := r.kms.GetWrapper(ctx, foundSession.ScopeId, kms.KeyPurposeDatabase) + if err != nil { + return fmt.Errorf("unable to get database wrapper: %w", err) + } + + updatedSession.TofuToken = tofuToken + if err := updatedSession.encrypt(ctx, databaseWrapper); err != nil { + return err + } + rowsUpdated, err := w.Update(ctx, &updatedSession, []string{"CtTofuToken"}, nil) + if err != nil { + return err + } + if err == nil && rowsUpdated > 1 { + // return err, which will result in a rollback of the update + return errors.New("error more than 1 session would have been updated ") + } + + returnedStates, err = fetchStates(ctx, reader, sessionId, db.WithOrder("start_time desc")) + if err != nil { + return err + } + return nil + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("activate session: %w", err) + } + return &updatedSession, returnedStates, nil +} + +// UpdateState will update the session's state using the session id and its +// version. States are ordered by start time descending. No options are +// currently supported. +func (r *Repository) UpdateState(ctx context.Context, sessionId string, sessionVersion uint32, s Status, opt ...Option) (*Session, []*State, error) { + if sessionId == "" { + return nil, nil, fmt.Errorf("update session state: missing session id %w", db.ErrInvalidParameter) + } + if sessionVersion == 0 { + return nil, nil, fmt.Errorf("update session state: version cannot be zero: %w", db.ErrInvalidParameter) + } + if s == "" { + return nil, nil, fmt.Errorf("update session state: missing session status: %w", db.ErrInvalidParameter) + } + if s == StatusActive { + return nil, nil, fmt.Errorf("update session: you must call ActivateSession to update a session's state to active: %w", db.ErrInvalidParameter) + } + + newState, err := NewState(sessionId, s) + if err != nil { + return nil, nil, fmt.Errorf("update session state: %w", err) + } + + updatedSession := AllocSession() + var returnedStates []*State + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + // We need to update the session version as that's the aggregate + updatedSession.PublicId = sessionId + updatedSession.Version = uint32(sessionVersion) + 1 + rowsUpdated, err := w.Update(ctx, &updatedSession, []string{"Version"}, nil, db.WithVersion(&sessionVersion)) + if err != nil { + return fmt.Errorf("unable to update session version: %w", err) + } + if rowsUpdated != 1 { + return fmt.Errorf("updated session and %d rows updated", rowsUpdated) + } + if err := w.Create(ctx, newState); err != nil { + return fmt.Errorf("unable to add new state: %w", err) + } + + returnedStates, err = fetchStates(ctx, reader, sessionId, db.WithOrder("start_time desc")) + if err != nil { + return err + } + return nil + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("update session state: error creating new state: %w", err) + } + if len(updatedSession.CtTofuToken) == 0 { + updatedSession.CtTofuToken = nil + } + return &updatedSession, returnedStates, nil +} + +func fetchStates(ctx context.Context, r db.Reader, sessionId string, opt ...db.Option) ([]*State, error) { + var states []*State + if err := r.SearchWhere(ctx, &states, "session_id = ?", []interface{}{sessionId}, opt...); err != nil { + return nil, fmt.Errorf("fetch session states: %w", err) + } + if len(states) == 0 { + return nil, nil + } + return states, nil +} diff --git a/internal/session/repository_session_test.go b/internal/session/repository_session_test.go new file mode 100644 index 000000000..91cec5024 --- /dev/null +++ b/internal/session/repository_session_test.go @@ -0,0 +1,856 @@ +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/authtoken" + "github.com/hashicorp/boundary/internal/db" + dbassert "github.com/hashicorp/boundary/internal/db/assert" + "github.com/hashicorp/boundary/internal/host/static" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/oplog" + "github.com/hashicorp/boundary/internal/servers" + "github.com/hashicorp/boundary/internal/target" + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_ListSession(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + const testLimit = 10 + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + rw := db.New(conn) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms, WithLimit(testLimit)) + require.NoError(t, err) + composedOf := TestSessionParams(t, conn, wrapper, iamRepo) + + type args struct { + opt []Option + } + tests := []struct { + name string + createCnt int + args args + wantCnt int + wantErr bool + }{ + { + name: "no-limit", + createCnt: repo.defaultLimit + 1, + args: args{ + opt: []Option{WithLimit(-1)}, + }, + wantCnt: repo.defaultLimit + 1, + wantErr: false, + }, + { + name: "default-limit", + createCnt: repo.defaultLimit + 1, + args: args{}, + wantCnt: repo.defaultLimit, + wantErr: false, + }, + { + name: "custom-limit", + createCnt: repo.defaultLimit + 1, + args: args{ + opt: []Option{WithLimit(3)}, + }, + wantCnt: 3, + wantErr: false, + }, + { + name: "withScopeId", + createCnt: repo.defaultLimit + 1, + args: args{ + opt: []Option{WithScopeId(composedOf.ScopeId)}, + }, + wantCnt: repo.defaultLimit, + wantErr: false, + }, + { + name: "bad-withScopeId", + createCnt: repo.defaultLimit + 1, + args: args{ + opt: []Option{WithScopeId("o_thisIsNotValid")}, + }, + wantCnt: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + require.NoError(conn.Where("1=1").Delete(AllocSession()).Error) + testSessions := []*Session{} + for i := 0; i < tt.createCnt; i++ { + s := TestSession(t, conn, wrapper, composedOf) + testSessions = append(testSessions, s) + } + assert.Equal(tt.createCnt, len(testSessions)) + got, err := repo.ListSessions(context.Background(), tt.args.opt...) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(tt.wantCnt, len(got)) + }) + } + t.Run("withOrder", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + require.NoError(conn.Where("1=1").Delete(AllocSession()).Error) + wantCnt := 5 + for i := 0; i < wantCnt; i++ { + _ = TestSession(t, conn, wrapper, composedOf) + } + got, err := repo.ListSessions(context.Background(), WithOrder("create_time asc")) + require.NoError(err) + assert.Equal(wantCnt, len(got)) + + for i := 0; i < len(got)-1; i++ { + first, err := ptypes.Timestamp(got[i].CreateTime.Timestamp) + require.NoError(err) + second, err := ptypes.Timestamp(got[i+1].CreateTime.Timestamp) + require.NoError(err) + assert.True(first.Before(second)) + } + }) + t.Run("withUserId", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + require.NoError(conn.Where("1=1").Delete(AllocSession()).Error) + wantCnt := 5 + for i := 0; i < wantCnt; i++ { + _ = TestSession(t, conn, wrapper, composedOf) + } + s := TestDefaultSession(t, conn, wrapper, iamRepo) + got, err := repo.ListSessions(context.Background(), WithUserId(s.UserId)) + require.NoError(err) + assert.Equal(1, len(got)) + assert.Equal(got[0].UserId, s.UserId) + }) +} + +func TestRepository_CreateSession(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + + type args struct { + composedOf ComposedOf + } + tests := []struct { + name string + args args + wantErr bool + wantIsError error + }{ + { + name: "valid", + args: args{ + composedOf: TestSessionParams(t, conn, wrapper, iamRepo), + }, + wantErr: false, + }, + { + name: "empty-userId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.UserId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-hostId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.HostId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-targetId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.TargetId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-hostSetId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.HostSetId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-authTokenId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.AuthTokenId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-scopeId", + args: args{ + composedOf: func() ComposedOf { + c := TestSessionParams(t, conn, wrapper, iamRepo) + c.ScopeId = "" + return c + }(), + }, + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + s := &Session{ + UserId: tt.args.composedOf.UserId, + HostId: tt.args.composedOf.HostId, + TargetId: tt.args.composedOf.TargetId, + HostSetId: tt.args.composedOf.HostSetId, + AuthTokenId: tt.args.composedOf.AuthTokenId, + ScopeId: tt.args.composedOf.ScopeId, + } + ses, st, privKey, err := repo.CreateSession(context.Background(), wrapper, s) + if tt.wantErr { + assert.Error(err) + assert.Nil(ses) + assert.Nil(st) + if tt.wantIsError != nil { + assert.True(errors.Is(err, tt.wantIsError)) + } + return + } + require.NoError(err) + assert.NotNil(ses) + assert.NotNil(privKey) + assert.NotNil(st) + assert.NotNil(ses.CreateTime) + assert.NotNil(st.StartTime) + assert.Equal(st.Status, StatusPending.String()) + foundSession, foundStates, err := repo.LookupSession(context.Background(), ses.PublicId) + assert.NoError(err) + assert.Equal(foundSession, ses) + + err = db.TestVerifyOplog(t, rw, ses.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + + require.Equal(1, len(foundStates)) + assert.Equal(foundStates[0].Status, StatusPending.String()) + }) + } +} + +func TestRepository_UpdateState(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + + tests := []struct { + name string + session *Session + newStatus Status + overrideSessionId *string + overrideSessionVersion *uint32 + wantStateCnt int + wantErr bool + wantIsError error + }{ + { + name: "cancelling", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + newStatus: StatusCanceling, + wantStateCnt: 2, + wantErr: false, + }, + { + name: "closed", + session: func() *Session { + s := TestDefaultSession(t, conn, wrapper, iamRepo) + _ = TestState(t, conn, s.PublicId, StatusActive) + return s + }(), + newStatus: StatusTerminated, + wantStateCnt: 3, + wantErr: false, + }, + { + name: "bad-version", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + newStatus: StatusCanceling, + overrideSessionVersion: func() *uint32 { + v := uint32(22) + return &v + }(), + wantErr: true, + }, + { + name: "empty-version", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + newStatus: StatusCanceling, + overrideSessionVersion: func() *uint32 { + v := uint32(0) + return &v + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "bad-sessionId", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + newStatus: StatusCanceling, + overrideSessionId: func() *string { + s := "s_thisIsNotValid" + return &s + }(), + wantErr: true, + }, + { + name: "empty-session", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + newStatus: StatusCanceling, + overrideSessionId: func() *string { + s := "" + return &s + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + var id string + var version uint32 + switch { + case tt.overrideSessionId != nil: + id = *tt.overrideSessionId + default: + id = tt.session.PublicId + } + switch { + case tt.overrideSessionVersion != nil: + version = *tt.overrideSessionVersion + default: + version = tt.session.Version + } + + s, ss, err := repo.UpdateState(context.Background(), id, version, tt.newStatus) + if tt.wantErr { + require.Error(err) + if tt.wantIsError != nil { + assert.Truef(errors.Is(err, tt.wantIsError), "unexpected error %s", err.Error()) + } + return + } + require.NoError(err) + require.NotNil(s) + require.NotNil(ss) + assert.Equal(tt.wantStateCnt, len(ss)) + assert.Equal(tt.newStatus.String(), ss[0].Status) + }) + } +} + +func TestRepository_ActivateState(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + + tofu := TestTofu(t) + tests := []struct { + name string + session *Session + overrideSessionId *string + overrideSessionVersion *uint32 + wantErr bool + wantIsError error + }{ + { + name: "valid", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + wantErr: false, + }, + { + name: "already-active", + session: func() *Session { + s := TestDefaultSession(t, conn, wrapper, iamRepo) + activeSession, _, err := repo.ActivateSession(context.Background(), s.PublicId, s.Version, tofu) + require.NoError(t, err) + return activeSession + }(), + wantErr: true, + }, + { + name: "bad-session-id", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + overrideSessionId: func() *string { + id, err := newId() + require.NoError(t, err) + return &id + }(), + wantErr: true, + }, + { + name: "bad-session-version", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + overrideSessionVersion: func() *uint32 { + v := uint32(100) + return &v + }(), + wantErr: true, + }, + { + name: "empty-session-id", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + overrideSessionId: func() *string { + id := "" + return &id + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + { + name: "empty-session-version", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + overrideSessionVersion: func() *uint32 { + v := uint32(0) + return &v + }(), + wantErr: true, + wantIsError: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + var id string + var version uint32 + switch { + case tt.overrideSessionId != nil: + id = *tt.overrideSessionId + default: + id = tt.session.PublicId + } + switch { + case tt.overrideSessionVersion != nil: + version = *tt.overrideSessionVersion + default: + version = tt.session.Version + } + s, ss, err := repo.ActivateSession(context.Background(), id, version, tofu) + if tt.wantErr { + require.Error(err) + if tt.wantIsError != nil { + assert.Truef(errors.Is(err, tt.wantIsError), "unexpected error %s", err.Error()) + } + return + } + require.NoError(err) + require.NotNil(s) + require.NotNil(ss) + assert.Equal(tofu, s.TofuToken) + assert.Equal(2, len(ss)) + assert.Equal(StatusActive.String(), ss[0].Status) + }) + t.Run("already active", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + s, ss, err := repo.ActivateSession(context.Background(), session.PublicId, 1, tofu) + require.NoError(err) + require.NotNil(s) + require.NotNil(ss) + assert.Equal(2, len(ss)) + assert.Equal(StatusActive.String(), ss[0].Status) + + _, _, err = repo.ActivateSession(context.Background(), session.PublicId, 1, tofu) + require.Error(err) + + _, _, err = repo.ActivateSession(context.Background(), session.PublicId, 2, tofu) + require.Error(err) + }) + } +} +func TestRepository_UpdateSession(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + serversRepo, err := servers.NewRepository(rw, rw, kms) + require.NoError(t, err) + + newServerFunc := func() string { + id, err := uuid.GenerateUUID() + require.NoError(t, err) + worker := &servers.Server{ + Name: "test-session-worker-" + id, + Type: servers.ServerTypeWorker.String(), + Description: "Test Session Worker", + Address: "127.0.0.1", + } + _, _, err = serversRepo.UpsertServer(context.Background(), worker) + require.NoError(t, err) + return worker.Name + } + + type args struct { + terminationReason TerminationReason + serverId string + serverType string + tofu []byte + fieldMaskPaths []string + opt []Option + publicId *string // not updateable - db.ErrInvalidFieldMask + userId string // not updateable - db.ErrInvalidFieldMask + hostId string // not updateable - db.ErrInvalidFieldMask + targetId string // not updateable - db.ErrInvalidFieldMask + hostSetId string // not updateable - db.ErrInvalidFieldMask + authTokenId string // not updateable - db.ErrInvalidFieldMask + scopeId string // not updateable - db.ErrInvalidFieldMask + } + tests := []struct { + name string + args args + wantRowsUpdate int + wantErr bool + wantIsError error + }{ + { + name: "valid", + args: args{ + terminationReason: Terminated, + serverId: newServerFunc(), + serverType: servers.ServerTypeWorker.String(), + tofu: TestTofu(t), + fieldMaskPaths: []string{"TerminationReason", "ServerId", "ServerType", "TofuToken"}, + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "publicId", + args: args{ + publicId: func() *string { + id, err := newId() + require.NoError(t, err) + return &id + }(), + fieldMaskPaths: []string{"PublicId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "userId", + args: args{ + userId: func() string { + org, _ := iam.TestScopes(t, iamRepo) + u := iam.TestUser(t, iamRepo, org.PublicId) + return u.PublicId + }(), + fieldMaskPaths: []string{"UserId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "hostId", + args: args{ + hostId: func() string { + _, proj := iam.TestScopes(t, iamRepo) + cats := static.TestCatalogs(t, conn, proj.PublicId, 1) + hosts := static.TestHosts(t, conn, cats[0].PublicId, 1) + return hosts[0].PublicId + }(), + fieldMaskPaths: []string{"HostId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "targetId", + args: args{ + targetId: func() string { + _, proj := iam.TestScopes(t, iamRepo) + tcpTarget := target.TestTcpTarget(t, conn, proj.PublicId, "test target") + return tcpTarget.PublicId + }(), + fieldMaskPaths: []string{"TargetId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "hostSetId", + args: args{ + hostSetId: func() string { + _, proj := iam.TestScopes(t, iamRepo) + cats := static.TestCatalogs(t, conn, proj.PublicId, 1) + sets := static.TestSets(t, conn, cats[0].PublicId, 1) + return sets[0].PublicId + }(), + fieldMaskPaths: []string{"HostSetId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "AuthTokenId", + args: args{ + authTokenId: func() string { + ctx := context.Background() + org, _ := iam.TestScopes(t, iamRepo) + authMethod := password.TestAuthMethods(t, conn, org.PublicId, 1)[0] + acct := password.TestAccounts(t, conn, authMethod.GetPublicId(), 1)[0] + user, err := iamRepo.LookupUserWithLogin(ctx, acct.GetPublicId(), iam.WithAutoVivify(true)) + require.NoError(t, err) + + authTokenRepo, err := authtoken.NewRepository(rw, rw, kms) + require.NoError(t, err) + at, err := authTokenRepo.CreateAuthToken(ctx, user, acct.GetPublicId()) + require.NoError(t, err) + return at.PublicId + }(), + fieldMaskPaths: []string{"AuthTokenId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + { + name: "ScopeId", + args: args{ + scopeId: func() string { + _, proj := iam.TestScopes(t, iamRepo) + return proj.PublicId + }(), + fieldMaskPaths: []string{"ScopeId"}, + }, + wantErr: true, + wantRowsUpdate: 0, + wantIsError: db.ErrInvalidFieldMask, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + composedOf := TestSessionParams(t, conn, wrapper, iamRepo) + s := TestSession(t, conn, wrapper, composedOf) + + updateSession := AllocSession() + updateSession.PublicId = s.PublicId + if tt.args.publicId != nil { + updateSession.PublicId = *tt.args.publicId + } + updateSession.ServerId = tt.args.serverId + updateSession.ServerType = tt.args.serverType + updateSession.TerminationReason = tt.args.terminationReason.String() + if tt.args.tofu != nil { + updateSession.TofuToken = make([]byte, len(tt.args.tofu)) + copy(updateSession.TofuToken, tt.args.tofu) + } + updateSession.Version = s.Version + afterUpdateSession, afterUpdateState, updatedRows, err := repo.UpdateSession(context.Background(), &updateSession, updateSession.Version, tt.args.fieldMaskPaths, tt.args.opt...) + + if tt.wantErr { + require.Error(err) + if tt.wantIsError != nil { + assert.Truef(errors.Is(err, tt.wantIsError), "unexpected error: %s", err.Error()) + } + assert.Nil(afterUpdateSession) + assert.Nil(afterUpdateState) + assert.Equal(0, updatedRows) + err = db.TestVerifyOplog(t, rw, s.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + return + } + require.NoError(err) + assert.Equal(tt.wantRowsUpdate, updatedRows) + require.NotNil(afterUpdateSession) + require.NotNil(afterUpdateState) + switch tt.name { + case "valid-no-op": + assert.Equal(s.UpdateTime, afterUpdateSession.UpdateTime) + default: + assert.NotEqual(s.UpdateTime, afterUpdateSession.UpdateTime) + } + foundSession, foundStates, err := repo.LookupSession(context.Background(), s.PublicId) + require.NoError(err) + assert.Equal(afterUpdateSession, foundSession) + dbassrt := dbassert.New(t, rw) + if tt.args.serverId == "" { + dbassrt.IsNull(foundSession, "ServerId") + } + if tt.args.serverType == "" { + dbassrt.IsNull(foundSession, "ServerType") + } + if tt.args.tofu == nil { + dbassrt.IsNull(foundSession, "CtTofuToken") + dbassrt.IsNull(foundSession, "KeyId") + } else { + dbassrt.NotNull(foundSession, "CtTofuToken") + dbassrt.NotNull(foundSession, "KeyId") + } + assert.Equal(tt.args.terminationReason.String(), foundSession.TerminationReason) + assert.Equal(tt.args.serverId, foundSession.ServerId) + assert.Equal(tt.args.serverType, foundSession.ServerType) + + err = db.TestVerifyOplog(t, rw, s.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + + switch { + case tt.args.terminationReason != "": + require.Equal(2, len(foundStates)) + assert.Equal(StatusTerminated.String(), foundStates[0].Status) + assert.Equal(StatusPending.String(), foundStates[1].Status) + default: + require.Equal(1, len(foundStates)) + assert.Equal(StatusPending.String(), foundStates[0].Status) + } + }) + } + +} + +func TestRepository_DeleteSession(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms) + require.NoError(t, err) + + type args struct { + session *Session + opt []Option + } + tests := []struct { + name string + args args + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + args: args{ + session: TestDefaultSession(t, conn, wrapper, iamRepo), + }, + wantRowsDeleted: 1, + wantErr: false, + }, + { + name: "no-public-id", + args: args{ + session: func() *Session { + s := AllocSession() + return &s + }(), + }, + wantRowsDeleted: 0, + wantErr: true, + wantErrMsg: "delete session: missing public id invalid parameter", + }, + { + name: "not-found", + args: args{ + session: func() *Session { + s := TestDefaultSession(t, conn, wrapper, iamRepo) + id, err := newId() + require.NoError(t, err) + s.PublicId = id + return s + }(), + }, + wantRowsDeleted: 0, + wantErr: true, + wantErrMsg: "delete session: failed record not found for ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + deletedRows, err := repo.DeleteSession(context.Background(), tt.args.session.PublicId, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + assert.Equal(0, deletedRows) + assert.Contains(err.Error(), tt.wantErrMsg) + err = db.TestVerifyOplog(t, rw, tt.args.session.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundSession, _, err := repo.LookupSession(context.Background(), tt.args.session.PublicId) + assert.NoError(err) + assert.Nil(foundSession) + + err = db.TestVerifyOplog(t, rw, tt.args.session.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + }) + } +} diff --git a/internal/session/repository_test.go b/internal/session/repository_test.go new file mode 100644 index 000000000..ed1f68ae1 --- /dev/null +++ b/internal/session/repository_test.go @@ -0,0 +1,74 @@ +package session + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/kms" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRepository(t *testing.T) { + t.Parallel() + 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 *kms.Repository + wantErr bool + wantErrString string + }{ + { + name: "valid", + args: args{ + r: rw, + w: rw, + }, + want: func() *kms.Repository { + ret, err := kms.NewRepository(rw, rw) + require.NoError(t, err) + return ret + }(), + wantErr: false, + }, + { + name: "nil-writer", + args: args{ + r: rw, + w: nil, + }, + want: nil, + wantErr: true, + wantErrString: "error creating db repository with nil writer", + }, + { + name: "nil-reader", + args: args{ + r: nil, + w: rw, + }, + want: nil, + wantErr: true, + wantErrString: "error creating db repository with nil reader", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := kms.NewRepository(tt.args.r, tt.args.w) + if tt.wantErr { + require.Error(err) + assert.Equal(err.Error(), tt.wantErrString) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + }) + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 000000000..cbbb9ce5b --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,329 @@ +package session + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "fmt" + "math/big" + mathrand "math/rand" + "strings" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/go-kms-wrapping/structwrapping" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + defaultSessionTableName = "session" +) + +// ComposedOf defines the boundary data that is referenced to compose a session. +type ComposedOf struct { + // UserId of the session + UserId string + // HostId of the session + HostId string + // TargetId of the session + TargetId string + // HostSetId of the session + HostSetId string + // AuthTokenId of the session + AuthTokenId string + // ScopeId of the session + ScopeId string +} + +// Session contains information about a user's session with a target +type Session struct { + // PublicId is used to access the session via an API + PublicId string `json:"public_id,omitempty" gorm:"primary_key"` + // UserId for the session + UserId string `json:"user_id,omitempty" gorm:"default:null"` + // HostId of the session + HostId string `json:"host_id,omitempty" gorm:"default:null"` + // ServerId that proxied the session + ServerId string `json:"server_id,omitempty" gorm:"default:null"` + // ServerType that proxied the session + ServerType string `json:"server_type,omitempty" gorm:"default:null"` + // TargetId for the session + TargetId string `json:"target_id,omitempty" gorm:"default:null"` + // HostSetId for the session + HostSetId string `json:"host_set_id,omitempty" gorm:"default:null"` + // AuthTokenId for the session + AuthTokenId string `json:"auth_token_id,omitempty" gorm:"default:null"` + // ScopeId for the session + ScopeId string `json:"scope_id,omitempty" gorm:"default:null"` + // Certificate to use when connecting (or if using custom certs, to + // serve as the "login"). Raw DER bytes. Private key is not, and should not be + // stored in the database. + Certificate []byte `json:"certificate,omitempty" gorm:"default:null"` + // ExpirationTime - after this time the connection will be expired, e.g. forcefully terminated + ExpirationTime *timestamp.Timestamp `json:"expiration_time,omitempty" gorm:"default:null"` + // CtTofuToken is the ciphertext Tofutoken value stored in the database + CtTofuToken []byte `json:"ct_tofu_token,omitempty" gorm:"column:tofu_token;default:null" wrapping:"ct,tofu_token"` + // TofuToken - plain text of the "trust on first use" token for session + TofuToken []byte `json:"tofu_token,omitempty" gorm:"-" wrapping:"pt,tofu_token"` + // termination_reason for the session + TerminationReason string `json:"termination_reason,omitempty" gorm:"default:null"` + // CreateTime from the RDBMS + CreateTime *timestamp.Timestamp `json:"create_time,omitempty" gorm:"default:current_timestamp"` + // UpdateTime from the RDBMS + UpdateTime *timestamp.Timestamp `json:"update_time,omitempty" gorm:"default:current_timestamp"` + // Version for the session + Version uint32 `json:"version,omitempty" gorm:"default:null"` + + // key_id is the key ID that was used for the encryption operation. It can be + // used to identify a specific version of the key needed to decrypt the value, + // which is useful for caching purposes. + // @inject_tag: `gorm:"not_null"` + KeyId string `json:"key_id,omitempty" gorm:"not_null"` + + tableName string `gorm:"-"` +} + +func (s *Session) GetPublicId() string { + return s.PublicId +} + +var _ Cloneable = (*Session)(nil) +var _ db.VetForWriter = (*Session)(nil) + +// New creates a new in memory session. WithExpirationTime option is support to +// set the session's expiration time. +func New(c ComposedOf, opt ...Option) (*Session, error) { + opts := getOpts(opt...) + + s := Session{ + UserId: c.UserId, + HostId: c.HostId, + TargetId: c.TargetId, + HostSetId: c.HostSetId, + AuthTokenId: c.AuthTokenId, + ScopeId: c.ScopeId, + ExpirationTime: opts.withExpirationTime, + } + if err := s.validateNewSession("new session:"); err != nil { + return nil, err + } + return &s, nil +} + +// AllocSession will allocate a Session +func AllocSession() Session { + return Session{} +} + +// Clone creates a clone of the Session +func (s *Session) Clone() interface{} { + clone := &Session{ + PublicId: s.PublicId, + UserId: s.UserId, + HostId: s.HostId, + ServerId: s.ServerId, + ServerType: s.ServerType, + TargetId: s.TargetId, + HostSetId: s.HostSetId, + AuthTokenId: s.AuthTokenId, + ScopeId: s.ScopeId, + TerminationReason: s.TerminationReason, + Version: s.Version, + } + if s.TofuToken != nil { + clone.TofuToken = make([]byte, len(s.TofuToken)) + copy(clone.TofuToken, s.TofuToken) + } + if s.CtTofuToken != nil { + clone.CtTofuToken = make([]byte, len(s.CtTofuToken)) + copy(clone.CtTofuToken, s.CtTofuToken) + } + if s.Certificate != nil { + clone.Certificate = make([]byte, len(s.Certificate)) + copy(clone.Certificate, s.Certificate) + } + if s.ExpirationTime != nil { + clone.ExpirationTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.ExpirationTime.Timestamp.Seconds, + Nanos: s.ExpirationTime.Timestamp.Nanos, + }, + } + } + if s.CreateTime != nil { + clone.CreateTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.CreateTime.Timestamp.Seconds, + Nanos: s.CreateTime.Timestamp.Nanos, + }, + } + } + if s.UpdateTime != nil { + clone.UpdateTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.UpdateTime.Timestamp.Seconds, + Nanos: s.UpdateTime.Timestamp.Nanos, + }, + } + } + return clone +} + +// VetForWrite implements db.VetForWrite() interface and validates the session +// before it's written. +func (s *Session) VetForWrite(ctx context.Context, r db.Reader, opType db.OpType, opt ...db.Option) error { + opts := db.GetOpts(opt...) + if s.PublicId == "" { + return fmt.Errorf("session vet for write: missing public id: %w", db.ErrInvalidParameter) + } + switch opType { + case db.CreateOp: + if err := s.validateNewSession("session vet for write:"); err != nil { + return err + } + if len(s.Certificate) == 0 { + return fmt.Errorf("session vet for write: certificate is missing: %w", db.ErrInvalidParameter) + } + case db.UpdateOp: + switch { + case contains(opts.WithFieldMaskPaths, "PublicId"): + return fmt.Errorf("session vet for write: public id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "UserId"): + return fmt.Errorf("session vet for write: user id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "HostId"): + return fmt.Errorf("session vet for write: host id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "TargetId"): + return fmt.Errorf("session vet for write: target id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "HostSetId"): + return fmt.Errorf("session vet for write: host set id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "AuthTokenId"): + return fmt.Errorf("session vet for write: auth token id is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "Certificate"): + return fmt.Errorf("session vet for write: certificate is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "CreateTime"): + return fmt.Errorf("session vet for write: create time is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "UpdateTime"): + return fmt.Errorf("session vet for write: update time is immutable: %w", db.ErrInvalidParameter) + case contains(opts.WithFieldMaskPaths, "TerminationReason"): + if _, err := convertToReason(s.TerminationReason); err != nil { + return fmt.Errorf("session vet for write: %w", db.ErrInvalidParameter) + } + } + } + return nil +} + +// TableName returns the tablename to override the default gorm table name +func (s *Session) TableName() string { + if s.tableName != "" { + return s.tableName + } + return defaultSessionTableName +} + +// SetTableName sets the tablename and satisfies the ReplayableMessage +// interface. If the caller attempts to set the name to "" the name will be +// reset to the default name. +func (s *Session) SetTableName(n string) { + s.tableName = n +} + +// validateNewSession checks everything but the session's PublicId +func (s *Session) validateNewSession(errorPrefix string) error { + if s.UserId == "" { + return fmt.Errorf("%s missing user id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.HostId == "" { + return fmt.Errorf("%s missing host id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.TargetId == "" { + return fmt.Errorf("%s missing target id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.HostSetId == "" { + return fmt.Errorf("%s missing host set id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.AuthTokenId == "" { + return fmt.Errorf("%s missing auth token id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.ScopeId == "" { + return fmt.Errorf("%s missing scope id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.TerminationReason != "" { + return fmt.Errorf("%s termination reason must be empty: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.ServerId != "" { + return fmt.Errorf("%s server id must be empty: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.ServerType != "" { + return fmt.Errorf("%s server type must be empty: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.TofuToken != nil { + return fmt.Errorf("%s tofu token must be empty: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.CtTofuToken != nil { + return fmt.Errorf("%s ct must be empty: %w", errorPrefix, db.ErrInvalidParameter) + } + return nil +} + +func contains(ss []string, t string) bool { + for _, s := range ss { + if strings.EqualFold(s, t) { + return true + } + } + return false +} + +func newCert(wrapper wrapping.Wrapper, userId, jobId string) (ed25519.PrivateKey, []byte, error) { + if wrapper == nil { + return nil, nil, fmt.Errorf("new session cert: missing wrapper: %w", db.ErrInvalidParameter) + } + if userId == "" { + return nil, nil, fmt.Errorf("new session cert: missing user id: %w", db.ErrInvalidParameter) + } + if jobId == "" { + return nil, nil, fmt.Errorf("new session cert: missing job id: %w", db.ErrInvalidParameter) + } + pubKey, privKey, err := DeriveED25519Key(wrapper, userId, jobId) + if err != nil { + return nil, nil, fmt.Errorf("new session cert: ") + } + template := &x509.Certificate{ + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + DNSNames: []string{jobId}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement | x509.KeyUsageCertSign, + SerialNumber: big.NewInt(mathrand.Int63()), + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(5 * time.Minute), + BasicConstraintsValid: true, + IsCA: true, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey) + if err != nil { + return nil, nil, fmt.Errorf("new session cert: %w", err) + } + return privKey, certBytes, nil +} + +func (s *Session) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { + if err := structwrapping.WrapStruct(ctx, cipher, s, nil); err != nil { + return fmt.Errorf("error encrypting session: %w", err) + } + s.KeyId = cipher.KeyID() + return nil +} + +func (s *Session) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { + if err := structwrapping.UnwrapStruct(ctx, cipher, s, nil); err != nil { + return fmt.Errorf("error decrypting session: %w", err) + } + return nil +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 000000000..44f7ad17b --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,268 @@ +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSession_Create(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + + composedOf := TestSessionParams(t, conn, wrapper, iamRepo) + future, err := ptypes.TimestampProto(time.Now().Add(time.Hour)) + require.NoError(t, err) + exp := ×tamp.Timestamp{Timestamp: future} + + type args struct { + composedOf ComposedOf + opt []Option + } + tests := []struct { + name string + args args + want *Session + wantErr bool + wantIsErr error + create bool + wantCreateErr bool + }{ + { + name: "valid", + args: args{ + composedOf: composedOf, + opt: []Option{WithExpirationTime(exp)}, + }, + want: &Session{ + UserId: composedOf.UserId, + HostId: composedOf.HostId, + TargetId: composedOf.TargetId, + HostSetId: composedOf.HostSetId, + AuthTokenId: composedOf.AuthTokenId, + ScopeId: composedOf.ScopeId, + }, + create: true, + }, + { + name: "empty-userId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.UserId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-hostId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.HostId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-targetId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.TargetId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-hostSetId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.HostSetId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-authTokenId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.AuthTokenId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-scopeId", + args: args{ + composedOf: func() ComposedOf { + c := composedOf + c.ScopeId = "" + return c + }(), + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := New(tt.args.composedOf) + if tt.wantErr { + require.Error(err) + assert.True(errors.Is(err, tt.wantIsErr)) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + if tt.create { + id, err := db.NewPublicId(SessionPrefix) + require.NoError(err) + got.PublicId = id + _, certBytes, err := newCert(wrapper, got.UserId, id) + require.NoError(err) + got.Certificate = certBytes + err = db.New(conn).Create(context.Background(), got) + if tt.wantCreateErr { + assert.Error(err) + return + } else { + assert.NoError(err) + } + } + }) + } +} + +func TestSession_Delete(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + + tests := []struct { + name string + session *Session + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + session: TestDefaultSession(t, conn, wrapper, iamRepo), + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-id", + session: func() *Session { + s := AllocSession() + id, err := db.NewPublicId(SessionPrefix) + require.NoError(t, err) + s.PublicId = id + return &s + }(), + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + deleteSession := AllocSession() + deleteSession.PublicId = tt.session.PublicId + deletedRows, err := rw.Delete(context.Background(), &deleteSession) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundSession := AllocSession() + foundSession.PublicId = tt.session.PublicId + err = rw.LookupById(context.Background(), &foundSession) + require.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + }) + } +} + +func TestSession_Clone(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + t.Run("valid", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + cp := s.Clone() + assert.Equal(cp.(*Session), s) + }) + t.Run("not-equal", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + s2 := TestDefaultSession(t, conn, wrapper, iamRepo) + + cp := s.Clone() + assert.NotEqual(cp.(*Session), s2) + }) +} + +func TestSession_SetTableName(t *testing.T) { + t.Parallel() + defaultTableName := defaultSessionTableName + tests := []struct { + name string + setNameTo string + want string + }{ + { + name: "new-name", + setNameTo: "new-name", + want: "new-name", + }, + { + name: "reset to default", + setNameTo: "", + want: defaultTableName, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + def := AllocSession() + require.Equal(defaultTableName, def.TableName()) + s := AllocSession() + s.SetTableName(tt.setNameTo) + assert.Equal(tt.want, s.TableName()) + }) + } +} diff --git a/internal/session/state.go b/internal/session/state.go new file mode 100644 index 000000000..d31ea54e4 --- /dev/null +++ b/internal/session/state.go @@ -0,0 +1,145 @@ +package session + +import ( + "context" + "fmt" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + defaultStateTableName = "session_state" +) + +// Status of the session's state +type Status string + +const ( + StatusPending Status = "pending" + StatusActive Status = "active" + StatusCanceling Status = "canceling" + StatusTerminated Status = "terminated" +) + +// String representation of the state's status +func (s Status) String() string { + return string(s) +} + +// State of the session +type State struct { + // SessionId references the session public id + SessionId string `json:"session_id,omitempty" gorm:"primary_key"` + // status of the session + Status string `json:"status,omitempty" gorm:"column:state"` + // PreviousEndTime from the RDBMS + PreviousEndTime *timestamp.Timestamp `json:"previous_end_time,omitempty" gorm:"default:current_timestamp"` + // StartTime from the RDBMS + StartTime *timestamp.Timestamp `json:"start_time,omitempty" gorm:"default:current_timestamp;primary_key"` + // EndTime from the RDBMS + EndTime *timestamp.Timestamp `json:"end_time,omitempty" gorm:"default:current_timestamp"` + + tableName string `gorm:"-"` +} + +var _ Cloneable = (*State)(nil) +var _ db.VetForWriter = (*State)(nil) + +// NewState creates a new in memory session state. No options +// are currently supported. +func NewState(session_id string, state Status, opt ...Option) (*State, error) { + s := State{ + SessionId: session_id, + Status: state.String(), + } + + if err := s.validate("new session state:"); err != nil { + return nil, err + } + return &s, nil +} + +// allocState will allocate a State +func allocState() State { + return State{} +} + +// Clone creates a clone of the State +func (s *State) Clone() interface{} { + clone := &State{ + SessionId: s.SessionId, + Status: s.Status, + } + if s.PreviousEndTime != nil { + clone.PreviousEndTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.PreviousEndTime.Timestamp.Seconds, + Nanos: s.PreviousEndTime.Timestamp.Nanos, + }, + } + } + + if s.StartTime != nil { + clone.StartTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.StartTime.Timestamp.Seconds, + Nanos: s.StartTime.Timestamp.Nanos, + }, + } + } + if s.EndTime != nil { + clone.EndTime = ×tamp.Timestamp{ + Timestamp: ×tamppb.Timestamp{ + Seconds: s.EndTime.Timestamp.Seconds, + Nanos: s.EndTime.Timestamp.Nanos, + }, + } + } + return clone +} + +// VetForWrite implements db.VetForWrite() interface and validates the state +// before it's written. +func (s *State) VetForWrite(ctx context.Context, r db.Reader, opType db.OpType, opt ...db.Option) error { + if err := s.validate("session state vet for write:"); err != nil { + return err + } + return nil +} + +// TableName returns the tablename to override the default gorm table name +func (s *State) TableName() string { + if s.tableName != "" { + return s.tableName + } + return defaultStateTableName +} + +// SetTableName sets the tablename and satisfies the ReplayableMessage +// interface. If the caller attempts to set the name to "" the name will be +// reset to the default name. +func (s *State) SetTableName(n string) { + s.tableName = n +} + +// validate checks the session state +func (s *State) validate(errorPrefix string) error { + if s.Status == "" { + return fmt.Errorf("%s missing status: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.SessionId == "" { + return fmt.Errorf("%s missing session id: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.StartTime != nil { + return fmt.Errorf("%s start time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.EndTime != nil { + return fmt.Errorf("%s end time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + if s.PreviousEndTime != nil { + return fmt.Errorf("%s previous end time is not settable: %w", errorPrefix, db.ErrInvalidParameter) + } + return nil +} diff --git a/internal/session/state_test.go b/internal/session/state_test.go new file mode 100644 index 000000000..f8a51cdc4 --- /dev/null +++ b/internal/session/state_test.go @@ -0,0 +1,209 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestState_Create(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + + type args struct { + sessionId string + status Status + } + tests := []struct { + name string + args args + want *State + wantErr bool + wantIsErr error + create bool + wantCreateErr bool + }{ + { + name: "valid", + args: args{ + sessionId: session.PublicId, + status: StatusPending, + }, + want: &State{ + SessionId: session.PublicId, + Status: StatusPending.String(), + }, + create: true, + }, + { + name: "empty-sessionId", + args: args{ + status: StatusPending, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "empty-status", + args: args{ + sessionId: session.PublicId, + }, + wantErr: true, + wantIsErr: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewState(tt.args.sessionId, tt.args.status) + if tt.wantErr { + require.Error(err) + assert.True(errors.Is(err, tt.wantIsErr)) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + if tt.create { + err = db.New(conn).Create(context.Background(), got) + if tt.wantCreateErr { + assert.Error(err) + return + } else { + assert.NoError(err) + } + } + }) + } +} + +func TestState_Delete(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + session := TestDefaultSession(t, conn, wrapper, iamRepo) + session2 := TestDefaultSession(t, conn, wrapper, iamRepo) + + tests := []struct { + name string + state *State + deleteStateId string + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + state: TestState(t, conn, session.PublicId, StatusTerminated), + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-id", + state: TestState(t, conn, session2.PublicId, StatusTerminated), + deleteStateId: func() string { + id, err := db.NewPublicId(StatePrefix) + require.NoError(t, err) + return id + }(), + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + var initialState State + err := rw.LookupWhere(context.Background(), &initialState, "session_id = ? and state = ?", tt.state.SessionId, tt.state.Status) + require.NoError(err) + + deleteState := allocState() + if tt.deleteStateId != "" { + deleteState.SessionId = tt.deleteStateId + } else { + deleteState.SessionId = tt.state.SessionId + } + deleteState.StartTime = initialState.StartTime + deletedRows, err := rw.Delete(context.Background(), &deleteState) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundState := allocState() + err = rw.LookupWhere(context.Background(), &foundState, "session_id = ? and start_time = ?", tt.state.SessionId, initialState.StartTime) + require.Error(err) + assert.True(errors.Is(db.ErrRecordNotFound, err)) + }) + } +} + +func TestState_Clone(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + t.Run("valid", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + state := TestState(t, conn, s.PublicId, StatusPending) + cp := state.Clone() + assert.Equal(cp.(*State), state) + }) + t.Run("not-equal", func(t *testing.T) { + assert := assert.New(t) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + s2 := TestDefaultSession(t, conn, wrapper, iamRepo) + state := TestState(t, conn, s.PublicId, StatusPending) + state2 := TestState(t, conn, s2.PublicId, StatusPending) + + cp := state.Clone() + assert.NotEqual(cp.(*State), state2) + }) +} + +func TestState_SetTableName(t *testing.T) { + t.Parallel() + defaultTableName := defaultStateTableName + tests := []struct { + name string + setNameTo string + want string + }{ + { + name: "new-name", + setNameTo: "new-name", + want: "new-name", + }, + { + name: "reset to default", + setNameTo: "", + want: defaultTableName, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + def := allocState() + require.Equal(defaultTableName, def.TableName()) + s := allocState() + s.SetTableName(tt.setNameTo) + assert.Equal(tt.want, s.TableName()) + }) + } +} diff --git a/internal/session/term_reason.go b/internal/session/term_reason.go new file mode 100644 index 000000000..5b86aeb5a --- /dev/null +++ b/internal/session/term_reason.go @@ -0,0 +1,42 @@ +package session + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/db" +) + +// TerminationReason of the session +type TerminationReason string + +const ( + UnknownTermination TerminationReason = "unknown" + TimedOut TerminationReason = "timed out" + ClosedByUser TerminationReason = "closed by end-user" + Terminated TerminationReason = "terminated" + NetworkError TerminationReason = "network error" + SystemError TerminationReason = "system error" +) + +// String representation of the termination reason +func (r TerminationReason) String() string { + return string(r) +} +func convertToReason(s string) (TerminationReason, error) { + switch s { + case UnknownTermination.String(): + return UnknownTermination, nil + case TimedOut.String(): + return TimedOut, nil + case ClosedByUser.String(): + return ClosedByUser, nil + case Terminated.String(): + return Terminated, nil + case NetworkError.String(): + return NetworkError, nil + case SystemError.String(): + return SystemError, nil + default: + return "", fmt.Errorf("termination reason: %s is not a valid reason: %w", s, db.ErrInvalidParameter) + } +} diff --git a/internal/session/testing.go b/internal/session/testing.go new file mode 100644 index 000000000..80f2e4dc2 --- /dev/null +++ b/internal/session/testing.go @@ -0,0 +1,151 @@ +package session + +import ( + "context" + "crypto/ed25519" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/authtoken" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/host/static" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/target" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/vault/sdk/helper/base62" + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/require" +) + +// TestConnection creates a test connection for the sessionId in the repository. +func TestConnection(t *testing.T, conn *gorm.DB, sessionId, clientTcpAddr string, clientTcpPort uint32, backendTcpAddr string, backendTcpPort uint32) *Connection { + t.Helper() + require := require.New(t) + rw := db.New(conn) + c, err := NewConnection(sessionId, clientTcpAddr, clientTcpPort, backendTcpAddr, backendTcpPort) + require.NoError(err) + id, err := newConnectionId() + require.NoError(err) + c.PublicId = id + err = rw.Create(context.Background(), c) + require.NoError(err) + return c +} + +// TestConnectionState creates a test connection state for the connectionId in the repository. +func TestConnectionState(t *testing.T, conn *gorm.DB, connectionId string, state ConnectionStatus) *ConnectionState { + t.Helper() + require := require.New(t) + rw := db.New(conn) + s, err := NewConnectionState(connectionId, state) + require.NoError(err) + err = rw.Create(context.Background(), s) + require.NoError(err) + return s +} + +// TestState creates a test state for the sessionId in the repository. +func TestState(t *testing.T, conn *gorm.DB, sessionId string, state Status) *State { + t.Helper() + require := require.New(t) + rw := db.New(conn) + s, err := NewState(sessionId, state) + require.NoError(err) + err = rw.Create(context.Background(), s) + require.NoError(err) + return s +} + +// TestSession creates a test session composed of c in the repository. +func TestSession(t *testing.T, conn *gorm.DB, wrapper wrapping.Wrapper, c ComposedOf, opt ...Option) *Session { + t.Helper() + require := require.New(t) + rw := db.New(conn) + s, err := New(c, opt...) + require.NoError(err) + id, err := newId() + require.NoError(err) + s.PublicId = id + _, certBytes, err := newCert(wrapper, c.UserId, id) + require.NoError(err) + s.Certificate = certBytes + err = rw.Create(context.Background(), s) + require.NoError(err) + return s +} + +// TestDefaultSession creates a test session in the repository using defaults. +func TestDefaultSession(t *testing.T, conn *gorm.DB, wrapper wrapping.Wrapper, iamRepo *iam.Repository, opt ...Option) *Session { + t.Helper() + require := require.New(t) + composedOf := TestSessionParams(t, conn, wrapper, iamRepo) + future, err := ptypes.TimestampProto(time.Now().Add(time.Hour)) + require.NoError(err) + exp := ×tamp.Timestamp{Timestamp: future} + return TestSession(t, conn, wrapper, composedOf, WithExpirationTime(exp)) +} + +// TestSessionParams returns an initialized ComposedOf which can be used to +// create a session in the repository. +func TestSessionParams(t *testing.T, conn *gorm.DB, wrapper wrapping.Wrapper, iamRepo *iam.Repository) ComposedOf { + t.Helper() + ctx := context.Background() + + require := require.New(t) + rw := db.New(conn) + org, proj := iam.TestScopes(t, iamRepo) + + cats := static.TestCatalogs(t, conn, proj.PublicId, 1) + + hosts := static.TestHosts(t, conn, cats[0].PublicId, 1) + + sets := static.TestSets(t, conn, cats[0].PublicId, 1) + _ = static.TestSetMembers(t, conn, sets[0].PublicId, hosts) + + tcpTarget := target.TestTcpTarget(t, conn, proj.PublicId, "test target") + + kms := kms.TestKms(t, conn, wrapper) + targetRepo, err := target.NewRepository(rw, rw, kms) + require.NoError(err) + _, _, err = targetRepo.AddTargetHostSets(ctx, tcpTarget.GetPublicId(), tcpTarget.GetVersion(), []string{sets[0].PublicId}) + require.NoError(err) + + authMethod := password.TestAuthMethods(t, conn, org.PublicId, 1)[0] + acct := password.TestAccounts(t, conn, authMethod.GetPublicId(), 1)[0] + user, err := iamRepo.LookupUserWithLogin(ctx, acct.GetPublicId(), iam.WithAutoVivify(true)) + require.NoError(err) + + authTokenRepo, err := authtoken.NewRepository(rw, rw, kms) + require.NoError(err) + at, err := authTokenRepo.CreateAuthToken(ctx, user, acct.GetPublicId()) + require.NoError(err) + + return ComposedOf{ + UserId: user.PublicId, + HostId: hosts[0].PublicId, + TargetId: tcpTarget.PublicId, + HostSetId: sets[0].PublicId, + AuthTokenId: at.PublicId, + ScopeId: tcpTarget.ScopeId, + } +} + +// TestTofu will create a test "trust on first use" token +func TestTofu(t *testing.T) []byte { + t.Helper() + require := require.New(t) + tofu, err := base62.Random(20) + require.NoError(err) + return []byte(tofu) +} + +// TestCert is a temporary test func that intentionally doesn't take testing.T +// as a parameter. It's currently used in controller.jobTestingHandler() and +// should be deprecated once that function is refactored to use sessions properly. +func TestCert(wrapper wrapping.Wrapper, userId, jobId string) (ed25519.PrivateKey, []byte, error) { + return newCert(wrapper, userId, jobId) +} diff --git a/internal/session/testing_test.go b/internal/session/testing_test.go new file mode 100644 index 000000000..4612e296a --- /dev/null +++ b/internal/session/testing_test.go @@ -0,0 +1,88 @@ +package session + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_TestSession(t *testing.T) { + t.Helper() + assert, require := assert.New(t), require.New(t) + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + require.NotNil(s) + assert.NotEmpty(s.PublicId) +} + +func Test_TestState(t *testing.T) { + t.Helper() + assert, require := assert.New(t), require.New(t) + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + require.NotNil(s) + assert.NotEmpty(s.PublicId) + + state := TestState(t, conn, s.PublicId, StatusPending) + require.NotNil(state) +} + +func Test_TestConnection(t *testing.T) { + t.Helper() + assert, require := assert.New(t), require.New(t) + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + require.NotNil(s) + assert.NotEmpty(s.PublicId) + + c := TestConnection(t, conn, s.PublicId, "127.0.0.1", 6500, "127.0.0.1", 22) + require.NotNil(c) +} + +func Test_TestConnectionState(t *testing.T) { + t.Helper() + assert, require := assert.New(t), require.New(t) + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + iamRepo := iam.TestRepo(t, conn, wrapper) + s := TestDefaultSession(t, conn, wrapper, iamRepo) + require.NotNil(s) + assert.NotEmpty(s.PublicId) + + c := TestConnection(t, conn, s.PublicId, "0.0.0.0", 22, "0.0.0.0", 2222) + require.NotNil(c) + assert.NotEmpty(c.PublicId) + + cs := TestConnectionState(t, conn, c.PublicId, StatusClosed) + require.NotNil(cs) + + rw := db.New(conn) + var initialState ConnectionState + err := rw.LookupWhere(context.Background(), &initialState, "connection_id = ? and state = ?", cs.ConnectionId, cs.Status) + require.NoError(err) + assert.NotEmpty(initialState.StartTime) +} + +func Test_TestCert(t *testing.T) { + t.Helper() + assert, require := assert.New(t), require.New(t) + wrapper := db.TestWrapper(t) + userId, err := db.NewPublicId(iam.UserPrefix) + require.NoError(err) + sessionId, err := newId() + require.NoError(err) + key, cert, err := TestCert(wrapper, userId, sessionId) + require.NoError(err) + assert.NotNil(key) + assert.NotNil(cert) +} diff --git a/internal/sessions/util.go b/internal/session/util.go similarity index 97% rename from internal/sessions/util.go rename to internal/session/util.go index 6c7d426d3..90503d590 100644 --- a/internal/sessions/util.go +++ b/internal/session/util.go @@ -1,4 +1,4 @@ -package sessions +package session import ( "crypto/ed25519" @@ -12,7 +12,7 @@ import ( "golang.org/x/crypto/hkdf" ) -// DeriveED25519Key generates a key based on the scope's sessions DEK, the +// DeriveED25519Key generates a key based on the scope's session DEK, the // requesting user, and the generated job ID. func DeriveED25519Key(wrapper wrapping.Wrapper, userId, jobId string) (ed25519.PublicKey, ed25519.PrivateKey, error) { var aeadWrapper *aead.Wrapper