From 1553f921a030f0c949b6109cb9f78d755058f5e3 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Wed, 14 Aug 2019 15:09:12 -0700 Subject: [PATCH 1/8] add regex testing for log lines --- docs/modules/Test::Conch.md | 17 ++++++++++++++ lib/Test/Conch.pm | 47 ++++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/modules/Test::Conch.md b/docs/modules/Test::Conch.md index 9fa560a1f..db1fcfc22 100644 --- a/docs/modules/Test::Conch.md +++ b/docs/modules/Test::Conch.md @@ -224,6 +224,23 @@ one specific log level: ## log\_fatal\_is +## log\_like + +Like ["log\_like"](#log_like), but uses a regular expression to express the expected log content. + +A log line at any level matches, or you can use a more specific method that matches only +one specific log level: + +## log\_debug\_like + +## log\_info\_like + +## log\_warn\_like + +## log\_error\_like + +## log\_fatal\_like + ## logs\_are Like ["log\_is"](#log_is), but tests for multiple messages at once. diff --git a/lib/Test/Conch.pm b/lib/Test/Conch.pm index 34e9147d4..5650f2f16 100644 --- a/lib/Test/Conch.pm +++ b/lib/Test/Conch.pm @@ -616,11 +616,46 @@ sub log_is ($self, $expected_msg, $test_name = 'log line', $level = undef) { return $self; } -sub log_debug_is ($s, $e, $n = 'log line') { @_ = ($s, $e, $n, 'debug'); goto \&log_is } -sub log_info_is ($s, $e, $n = 'log line') { @_ = ($s, $e, $n, 'info'); goto \&log_is } -sub log_warn_is ($s, $e, $n = 'log line') { @_ = ($s, $e, $n, 'warn'); goto \&log_is } -sub log_error_is ($s, $e, $n = 'log line') { @_ = ($s, $e, $n, 'error'); goto \&log_is } -sub log_fatal_is ($s, $e, $n = 'log line') { @_ = ($s, $e, $n, 'fatal'); goto \&log_is } +sub log_debug_is ($s, $e, $n = 'debug log line') { @_ = ($s, $e, $n, 'debug'); goto \&log_is } +sub log_info_is ($s, $e, $n = 'info log line') { @_ = ($s, $e, $n, 'info'); goto \&log_is } +sub log_warn_is ($s, $e, $n = 'warn log line') { @_ = ($s, $e, $n, 'warn'); goto \&log_is } +sub log_error_is ($s, $e, $n = 'error log line') { @_ = ($s, $e, $n, 'error'); goto \&log_is } +sub log_fatal_is ($s, $e, $n = 'fatal log line') { @_ = ($s, $e, $n, 'fatal'); goto \&log_is } + +=head2 log_like + +Like L, but uses a regular expression to express the expected log content. + +A log line at any level matches, or you can use a more specific method that matches only +one specific log level: + +=head2 log_debug_like + +=head2 log_info_like + +=head2 log_warn_like + +=head2 log_error_like + +=head2 log_fatal_like + +=cut + +sub log_like ($self, $expected_msg_re, $test_name = 'log line', $level = undef) { + @_ = ($self, + ref $expected_msg_re eq 'ARRAY' + ? (map Test::Deep::re($_), $expected_msg_re->@*) + : Test::Deep::re($expected_msg_re), + $test_name, $level); + + goto \&log_is; +} + +sub log_debug_like ($s, $e, $n = 'debug log line') { @_ = ($s, $e, $n, 'debug'); goto \&log_like } +sub log_info_like ($s, $e, $n = 'info log line') { @_ = ($s, $e, $n, 'info'); goto \&log_like } +sub log_warn_like ($s, $e, $n = 'warn log line') { @_ = ($s, $e, $n, 'warn'); goto \&log_like } +sub log_error_like ($s, $e, $n = 'error log line') { @_ = ($s, $e, $n, 'error'); goto \&log_like } +sub log_fatal_like ($s, $e, $n = 'fatal log line') { @_ = ($s, $e, $n, 'fatal'); goto \&log_like } =head2 logs_are @@ -628,7 +663,7 @@ Like L, but tests for multiple messages at once. =cut -sub logs_are ($self, $expected_msgs, $test_name = 'log line', $level = undef) { +sub logs_are ($self, $expected_msgs, $test_name = 'log lines', $level = undef) { $self->_test( 'Test::Deep::cmp_deeply', $self->app->log->history, From 449e1a645f86b851aa31e7878f5a14d79c871e72 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Wed, 17 Jul 2019 10:53:35 -0700 Subject: [PATCH 2/8] separate local helpers into ResultSet, Row namespaces ..this will allow the same name to be used as helpers for both, in the future --- ...sEpoch.md => Conch::DB::Helper::ResultSet::AsEpoch.md} | 4 ++-- ....md => Conch::DB::Helper::ResultSet::Deactivatable.md} | 4 ++-- ...t.md => Conch::DB::Helper::ResultSet::ResultsExist.md} | 4 ++-- ...h::DB::ToJSON.md => Conch::DB::Helper::Row::ToJSON.md} | 4 ++-- docs/modules/index.md | 8 ++++---- lib/Conch/DB/{ => Helper/ResultSet}/AsEpoch.pm | 6 +++--- lib/Conch/DB/{ => Helper/ResultSet}/Deactivatable.pm | 6 +++--- lib/Conch/DB/{ => Helper/ResultSet}/ResultsExist.pm | 6 +++--- lib/Conch/DB/{ => Helper/Row}/ToJSON.pm | 6 +++--- lib/Conch/DB/Result.pm | 2 +- lib/Conch/DB/ResultSet.pm | 6 +++--- 11 files changed, 28 insertions(+), 28 deletions(-) rename docs/modules/{Conch::DB::AsEpoch.md => Conch::DB::Helper::ResultSet::AsEpoch.md} (88%) rename docs/modules/{Conch::DB::Deactivatable.md => Conch::DB::Helper::ResultSet::Deactivatable.md} (83%) rename docs/modules/{Conch::DB::ResultsExist.md => Conch::DB::Helper::ResultSet::ResultsExist.md} (89%) rename docs/modules/{Conch::DB::ToJSON.md => Conch::DB::Helper::Row::ToJSON.md} (85%) rename lib/Conch/DB/{ => Helper/ResultSet}/AsEpoch.pm (91%) rename lib/Conch/DB/{ => Helper/ResultSet}/Deactivatable.pm (88%) rename lib/Conch/DB/{ => Helper/ResultSet}/ResultsExist.pm (89%) rename lib/Conch/DB/{ => Helper/Row}/ToJSON.pm (84%) diff --git a/docs/modules/Conch::DB::AsEpoch.md b/docs/modules/Conch::DB::Helper::ResultSet::AsEpoch.md similarity index 88% rename from docs/modules/Conch::DB::AsEpoch.md rename to docs/modules/Conch::DB::Helper::ResultSet::AsEpoch.md index 5dce418d7..2184b901b 100644 --- a/docs/modules/Conch::DB::AsEpoch.md +++ b/docs/modules/Conch::DB::Helper::ResultSet::AsEpoch.md @@ -1,6 +1,6 @@ # NAME -Conch::DB::AsEpoch +Conch::DB::Helper::ResultSet::AsEpoch # DESCRIPTION @@ -11,7 +11,7 @@ This code is postgres-specific. # USAGE ``` -__PACKAGE__->load_components('+Conch::DB::AsEpoch'); +__PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::AsEpoch'); ``` # METHODS diff --git a/docs/modules/Conch::DB::Deactivatable.md b/docs/modules/Conch::DB::Helper::ResultSet::Deactivatable.md similarity index 83% rename from docs/modules/Conch::DB::Deactivatable.md rename to docs/modules/Conch::DB::Helper::ResultSet::Deactivatable.md index bd10dc8a7..71baba457 100644 --- a/docs/modules/Conch::DB::Deactivatable.md +++ b/docs/modules/Conch::DB::Helper::ResultSet::Deactivatable.md @@ -1,6 +1,6 @@ # NAME -Conch::DB::Deactivatable +Conch::DB::Helper::ResultSet::Deactivatable # DESCRIPTION @@ -10,7 +10,7 @@ column, to provide common query functionality. # USAGE ``` -__PACKAGE__->load_components('+Conch::DB::Deactivatable'); +__PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::Deactivatable'); ``` # METHODS diff --git a/docs/modules/Conch::DB::ResultsExist.md b/docs/modules/Conch::DB::Helper::ResultSet::ResultsExist.md similarity index 89% rename from docs/modules/Conch::DB::ResultsExist.md rename to docs/modules/Conch::DB::Helper::ResultSet::ResultsExist.md index 12220b4db..b447aaf7e 100644 --- a/docs/modules/Conch::DB::ResultsExist.md +++ b/docs/modules/Conch::DB::Helper::ResultSet::ResultsExist.md @@ -1,6 +1,6 @@ # NAME -Conch::DB::ResultsExist +Conch::DB::Helper::ResultSet::ResultsExist # DESCRIPTION @@ -14,7 +14,7 @@ This code is postgres-specific. # USAGE ``` -__PACKAGE__->load_components('+Conch::DB::ResultsExist'); +__PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::ResultsExist'); ``` # METHODS diff --git a/docs/modules/Conch::DB::ToJSON.md b/docs/modules/Conch::DB::Helper::Row::ToJSON.md similarity index 85% rename from docs/modules/Conch::DB::ToJSON.md rename to docs/modules/Conch::DB::Helper::Row::ToJSON.md index 324ace292..fc0301325 100644 --- a/docs/modules/Conch::DB::ToJSON.md +++ b/docs/modules/Conch::DB::Helper::Row::ToJSON.md @@ -1,6 +1,6 @@ # NAME -Conch::DB::ToJSON +Conch::DB::Helper::Row::ToJSON # DESCRIPTION @@ -10,7 +10,7 @@ Sub-classes [DBIx::Class::Helper::Row::ToJSON](https://metacpan.org/pod/DBIx::Cl # USAGE ``` -__PACKAGE__->load_components('+Conch::DB::ToJSON'); +__PACKAGE__->load_components('+Conch::DB::Helper::Row::ToJSON'); ``` # LICENSING diff --git a/docs/modules/index.md b/docs/modules/index.md index fb2daba21..04ea6faed 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -37,8 +37,10 @@ * [Conch::Controller::WorkspaceRelay](../modules/Conch::Controller::WorkspaceRelay) * [Conch::Controller::WorkspaceUser](../modules/Conch::Controller::WorkspaceUser) * [Conch::DB](../modules/Conch::DB) -* [Conch::DB::AsEpoch](../modules/Conch::DB::AsEpoch) -* [Conch::DB::Deactivatable](../modules/Conch::DB::Deactivatable) +* [Conch::DB::Helper::ResultSet::AsEpoch](../modules/Conch::DB::Helper::ResultSet::AsEpoch) +* [Conch::DB::Helper::ResultSet::Deactivatable](../modules/Conch::DB::Helper::ResultSet::Deactivatable) +* [Conch::DB::Helper::ResultSet::ResultsExist](../modules/Conch::DB::Helper::ResultSet::ResultsExist) +* [Conch::DB::Helper::Row::ToJSON](../modules/Conch::DB::Helper::Row::ToJSON) * [Conch::DB::InflateColumn::Time](../modules/Conch::DB::InflateColumn::Time) * [Conch::DB::Result](../modules/Conch::DB::Result) * [Conch::DB::Result::Datacenter](../modules/Conch::DB::Result::Datacenter) @@ -84,8 +86,6 @@ * [Conch::DB::ResultSet::UserWorkspaceRole](../modules/Conch::DB::ResultSet::UserWorkspaceRole) * [Conch::DB::ResultSet::ValidationState](../modules/Conch::DB::ResultSet::ValidationState) * [Conch::DB::ResultSet::Workspace](../modules/Conch::DB::ResultSet::Workspace) -* [Conch::DB::ResultsExist](../modules/Conch::DB::ResultsExist) -* [Conch::DB::ToJSON](../modules/Conch::DB::ToJSON) * [Conch::DB::Util](../modules/Conch::DB::Util) * [Conch::Log](../modules/Conch::Log) * [Conch::Plugin::AuthHelpers](../modules/Conch::Plugin::AuthHelpers) diff --git a/lib/Conch/DB/AsEpoch.pm b/lib/Conch/DB/Helper/ResultSet/AsEpoch.pm similarity index 91% rename from lib/Conch/DB/AsEpoch.pm rename to lib/Conch/DB/Helper/ResultSet/AsEpoch.pm index b2abfcf0b..b5f98cd12 100644 --- a/lib/Conch/DB/AsEpoch.pm +++ b/lib/Conch/DB/Helper/ResultSet/AsEpoch.pm @@ -1,11 +1,11 @@ -package Conch::DB::AsEpoch; +package Conch::DB::Helper::ResultSet::AsEpoch; use v5.26; use warnings; use experimental 'signatures'; =head1 NAME -Conch::DB::AsEpoch +Conch::DB::Helper::ResultSet::AsEpoch =head1 DESCRIPTION @@ -15,7 +15,7 @@ This code is postgres-specific. =head1 USAGE - __PACKAGE__->load_components('+Conch::DB::AsEpoch'); + __PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::AsEpoch'); =head1 METHODS diff --git a/lib/Conch/DB/Deactivatable.pm b/lib/Conch/DB/Helper/ResultSet/Deactivatable.pm similarity index 88% rename from lib/Conch/DB/Deactivatable.pm rename to lib/Conch/DB/Helper/ResultSet/Deactivatable.pm index d9911b13a..f0806306f 100644 --- a/lib/Conch/DB/Deactivatable.pm +++ b/lib/Conch/DB/Helper/ResultSet/Deactivatable.pm @@ -1,4 +1,4 @@ -package Conch::DB::Deactivatable; +package Conch::DB::Helper::ResultSet::Deactivatable; use v5.26; use warnings; @@ -6,7 +6,7 @@ use experimental 'signatures'; =head1 NAME -Conch::DB::Deactivatable +Conch::DB::Helper::ResultSet::Deactivatable =head1 DESCRIPTION @@ -15,7 +15,7 @@ column, to provide common query functionality. =head1 USAGE - __PACKAGE__->load_components('+Conch::DB::Deactivatable'); + __PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::Deactivatable'); =head1 METHODS diff --git a/lib/Conch/DB/ResultsExist.pm b/lib/Conch/DB/Helper/ResultSet/ResultsExist.pm similarity index 89% rename from lib/Conch/DB/ResultsExist.pm rename to lib/Conch/DB/Helper/ResultSet/ResultsExist.pm index faa96b00b..3a5ed9d97 100644 --- a/lib/Conch/DB/ResultsExist.pm +++ b/lib/Conch/DB/Helper/ResultSet/ResultsExist.pm @@ -1,11 +1,11 @@ -package Conch::DB::ResultsExist; +package Conch::DB::Helper::ResultSet::ResultsExist; use v5.26; use warnings; use experimental 'signatures'; =head1 NAME -Conch::DB::ResultsExist +Conch::DB::Helper::ResultSet::ResultsExist =head1 DESCRIPTION @@ -18,7 +18,7 @@ This code is postgres-specific. =head1 USAGE - __PACKAGE__->load_components('+Conch::DB::ResultsExist'); + __PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::ResultsExist'); =head1 METHODS diff --git a/lib/Conch/DB/ToJSON.pm b/lib/Conch/DB/Helper/Row/ToJSON.pm similarity index 84% rename from lib/Conch/DB/ToJSON.pm rename to lib/Conch/DB/Helper/Row/ToJSON.pm index 7fb7995b8..4862ff802 100644 --- a/lib/Conch/DB/ToJSON.pm +++ b/lib/Conch/DB/Helper/Row/ToJSON.pm @@ -1,4 +1,4 @@ -package Conch::DB::ToJSON; +package Conch::DB::Helper::Row::ToJSON; use v5.26; use warnings; @@ -6,7 +6,7 @@ use parent 'DBIx::Class::Helper::Row::ToJSON'; =head1 NAME -Conch::DB::ToJSON +Conch::DB::Helper::Row::ToJSON =head1 DESCRIPTION @@ -15,7 +15,7 @@ Sub-classes L to also serialize 'text' data. =head1 USAGE - __PACKAGE__->load_components('+Conch::DB::ToJSON'); + __PACKAGE__->load_components('+Conch::DB::Helper::Row::ToJSON'); =cut diff --git a/lib/Conch/DB/Result.pm b/lib/Conch/DB/Result.pm index 1e3777851..4bf08bdc2 100644 --- a/lib/Conch/DB/Result.pm +++ b/lib/Conch/DB/Result.pm @@ -16,7 +16,7 @@ available in core L. __PACKAGE__->load_components( '+Conch::DB::InflateColumn::Time', # inflates 'timestamp with time zone' columns to Conch::Time - '+Conch::DB::ToJSON', # provides serialization hooks + '+Conch::DB::Helper::Row::ToJSON', # provides serialization hooks 'Helper::Row::SelfResultSet', # provides self_rs ); diff --git a/lib/Conch/DB/ResultSet.pm b/lib/Conch/DB/ResultSet.pm index 5e4e4f16c..2cf514371 100644 --- a/lib/Conch/DB/ResultSet.pm +++ b/lib/Conch/DB/ResultSet.pm @@ -15,7 +15,7 @@ available in core L. =cut __PACKAGE__->load_components( - '+Conch::DB::Deactivatable', # provides active, deactivate + '+Conch::DB::Helper::ResultSet::Deactivatable', # provides active, deactivate 'Helper::ResultSet::RemoveColumns', # provides remove_columns (must be applied early!) 'Helper::ResultSet::OneRow', # provides one_row 'Helper::ResultSet::Shortcut::HRI', # provides hri: raw unblessed + uninflated data @@ -23,12 +23,12 @@ __PACKAGE__->load_components( 'Helper::ResultSet::Shortcut::OrderBy', # provides order_by 'Helper::ResultSet::Shortcut::Rows', # provides rows 'Helper::ResultSet::Shortcut::Distinct', # provides distinct - '+Conch::DB::ResultsExist', # provides exists + '+Conch::DB::Helper::ResultSet::ResultsExist', # provides exists 'Helper::ResultSet::Shortcut::Columns', # provides columns 'Helper::ResultSet::Shortcut::Page', # provides page 'Helper::ResultSet::CorrelateRelationship', # provides correlate 'Helper::ResultSet::Shortcut::AddColumns', # provides add_columns - '+Conch::DB::AsEpoch', # provides as_epoch + '+Conch::DB::Helper::ResultSet::AsEpoch', # provides as_epoch 'Helper::ResultSet::SetOperations', # provides union, intersect, except, and *_all 'Helper::ResultSet::Shortcut::GroupBy', # provides group_by ); From 4d6b31d750a578dbdddbc2afce40fef3c6350365 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Thu, 1 Aug 2019 12:38:18 -0700 Subject: [PATCH 3/8] rename user_workspace_role_enum to role_enum ..and the corresponding json schema type so it can be used for additional tables that also use the role enum --- docs/modules/Conch::DB::Result::UserWorkspaceRole.md | 2 +- json-schema/common.yaml | 4 ++-- json-schema/request.yaml | 2 +- json-schema/response.yaml | 4 ++-- lib/Conch/DB/Result/UserWorkspaceRole.pm | 9 +++------ lib/Conch/DB/ResultSet/UserWorkspaceRole.pm | 2 +- lib/Conch/DB/ResultSet/Workspace.pm | 4 ++-- sql/migrations/0128-organizations.sql | 5 +++++ sql/schema.sql | 8 ++++---- 9 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 sql/migrations/0128-organizations.sql diff --git a/docs/modules/Conch::DB::Result::UserWorkspaceRole.md b/docs/modules/Conch::DB::Result::UserWorkspaceRole.md index 56094b5e7..ca4b5f4b8 100644 --- a/docs/modules/Conch::DB::Result::UserWorkspaceRole.md +++ b/docs/modules/Conch::DB::Result::UserWorkspaceRole.md @@ -31,7 +31,7 @@ size: 16 ```perl data_type: 'enum' default_value: 'ro' -extra: {custom_type_name => "user_workspace_role_enum",list => ["ro","rw","admin"]} +extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} is_nullable: 0 ``` diff --git a/json-schema/common.yaml b/json-schema/common.yaml index c14c4d56c..5ca4d671d 100644 --- a/json-schema/common.yaml +++ b/json-schema/common.yaml @@ -56,8 +56,8 @@ definitions: pattern: ^[\w-]+$ user_setting_key: $ref: /definitions/mojo_relaxed_placeholder - user_workspace_role: - description: corresponds to user_workspace_role_enum in the database + role: + description: corresponds to role_enum in the database type: string enum: - ro diff --git a/json-schema/request.yaml b/json-schema/request.yaml index 77a7a43e6..a2f8ad23e 100644 --- a/json-schema/request.yaml +++ b/json-schema/request.yaml @@ -485,7 +485,7 @@ definitions: email: $ref: common.yaml#/definitions/email_address role: - $ref: common.yaml#/definitions/user_workspace_role + $ref: common.yaml#/definitions/role DeviceAssetTag: type: object additionalProperties: false diff --git a/json-schema/response.yaml b/json-schema/response.yaml index 8d7e75c68..a4ce8211d 100644 --- a/json-schema/response.yaml +++ b/json-schema/response.yaml @@ -1136,7 +1136,7 @@ definitions: - $ref: common.yaml#/definitions/uuid - type: 'null' role: - $ref: common.yaml#/definitions/user_workspace_role + $ref: common.yaml#/definitions/role role_via: allOf: - description: the id of the workspace where the role comes from @@ -1165,7 +1165,7 @@ definitions: email: $ref: common.yaml#/definitions/email_address role: - $ref: common.yaml#/definitions/user_workspace_role + $ref: common.yaml#/definitions/role role_via: allOf: - description: this is the id of the workspace where the role comes from diff --git a/lib/Conch/DB/Result/UserWorkspaceRole.pm b/lib/Conch/DB/Result/UserWorkspaceRole.pm index 49aeb6116..c4a099ef4 100644 --- a/lib/Conch/DB/Result/UserWorkspaceRole.pm +++ b/lib/Conch/DB/Result/UserWorkspaceRole.pm @@ -46,7 +46,7 @@ __PACKAGE__->table("user_workspace_role"); data_type: 'enum' default_value: 'ro' - extra: {custom_type_name => "user_workspace_role_enum",list => ["ro","rw","admin"]} + extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} is_nullable: 0 =cut @@ -60,10 +60,7 @@ __PACKAGE__->add_columns( { data_type => "enum", default_value => "ro", - extra => { - custom_type_name => "user_workspace_role_enum", - list => ["ro", "rw", "admin"], - }, + extra => { custom_type_name => "role_enum", list => ["ro", "rw", "admin"] }, is_nullable => 0, }, ); @@ -116,7 +113,7 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07049 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ou3awMImfudGc6HMnEfdlQ +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:hztQ0pH4Hj4A+sMl+Ih55A =head2 role_cmp diff --git a/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm b/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm index da63dbc90..9db3d13e0 100644 --- a/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm +++ b/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm @@ -29,7 +29,7 @@ sub with_role ($self, $role) { if none { $role eq $_ } qw(ro rw admin); return $self->search if $role eq 'ro'; - $self->search({ role => { '>=' => \[ '?::user_workspace_role_enum', $role ] } }); + $self->search({ role => { '>=' => \[ '?::role_enum', $role ] } }); } 1; diff --git a/lib/Conch/DB/ResultSet/Workspace.pm b/lib/Conch/DB/ResultSet/Workspace.pm index ee91b3cd2..39e036b88 100644 --- a/lib/Conch/DB/ResultSet/Workspace.pm +++ b/lib/Conch/DB/ResultSet/Workspace.pm @@ -159,7 +159,7 @@ sub add_role_column ($self, $role) { if !$ENV{MOJO_MODE} and none { $role eq $_ } qw(ro rw admin); $self->add_columns({ - role => [ \[ '?::user_workspace_role_enum as role', $role ] ], + role => [ \[ '?::role_enum as role', $role ] ], }); } @@ -240,7 +240,7 @@ sub with_user_role ($self, $user_id, $role) { $self->search( { - $role ne 'ro' ? ('user_workspace_roles.role' => { '>=' => \[ '?::user_workspace_role_enum', $role ] } ) : (), + $role ne 'ro' ? ('user_workspace_roles.role' => { '>=' => \[ '?::role_enum', $role ] } ) : (), 'user_workspace_roles.user_id' => $user_id, }, { join => 'user_workspace_roles' }, diff --git a/sql/migrations/0128-organizations.sql b/sql/migrations/0128-organizations.sql new file mode 100644 index 000000000..05786f8e2 --- /dev/null +++ b/sql/migrations/0128-organizations.sql @@ -0,0 +1,5 @@ +SELECT run_migration(128, $$ + + alter type user_workspace_role_enum rename to role_enum; + +$$); diff --git a/sql/schema.sql b/sql/schema.sql index 19859d7b1..ed5bf68fe 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -74,17 +74,17 @@ CREATE TYPE public.device_phase_enum AS ENUM ( ALTER TYPE public.device_phase_enum OWNER TO conch; -- --- Name: user_workspace_role_enum; Type: TYPE; Schema: public; Owner: conch +-- Name: role_enum; Type: TYPE; Schema: public; Owner: conch -- -CREATE TYPE public.user_workspace_role_enum AS ENUM ( +CREATE TYPE public.role_enum AS ENUM ( 'ro', 'rw', 'admin' ); -ALTER TYPE public.user_workspace_role_enum OWNER TO conch; +ALTER TYPE public.role_enum OWNER TO conch; -- -- Name: validation_status_enum; Type: TYPE; Schema: public; Owner: conch @@ -580,7 +580,7 @@ ALTER TABLE public.user_setting OWNER TO conch; CREATE TABLE public.user_workspace_role ( user_id uuid NOT NULL, workspace_id uuid NOT NULL, - role public.user_workspace_role_enum DEFAULT 'ro'::public.user_workspace_role_enum NOT NULL + role public.role_enum DEFAULT 'ro'::public.role_enum NOT NULL ); From 3e3f072fe01025b1a2c5eb7404d046fdbadc8198 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Thu, 13 Jun 2019 13:28:35 -0700 Subject: [PATCH 4/8] organizations are born -- autogenerated files only --- .../Conch::DB::Result::Organization.md | 86 +++++++++ ...::DB::Result::OrganizationWorkspaceRole.md | 63 +++++++ .../modules/Conch::DB::Result::UserAccount.md | 12 ++ ...Conch::DB::Result::UserOrganizationRole.md | 63 +++++++ docs/modules/Conch::DB::Result::Workspace.md | 12 ++ docs/modules/index.md | 3 + lib/Conch/DB/Result/Organization.pm | 168 ++++++++++++++++++ .../DB/Result/OrganizationWorkspaceRole.pm | 133 ++++++++++++++ lib/Conch/DB/Result/UserAccount.pm | 27 ++- lib/Conch/DB/Result/UserOrganizationRole.pm | 133 ++++++++++++++ lib/Conch/DB/Result/Workspace.pm | 31 +++- sql/migrations/0128-organizations.sql | 26 +++ sql/schema.sql | 125 +++++++++++++ 13 files changed, 880 insertions(+), 2 deletions(-) create mode 100644 docs/modules/Conch::DB::Result::Organization.md create mode 100644 docs/modules/Conch::DB::Result::OrganizationWorkspaceRole.md create mode 100644 docs/modules/Conch::DB::Result::UserOrganizationRole.md create mode 100644 lib/Conch/DB/Result/Organization.pm create mode 100644 lib/Conch/DB/Result/OrganizationWorkspaceRole.pm create mode 100644 lib/Conch/DB/Result/UserOrganizationRole.pm diff --git a/docs/modules/Conch::DB::Result::Organization.md b/docs/modules/Conch::DB::Result::Organization.md new file mode 100644 index 000000000..ed3eb2153 --- /dev/null +++ b/docs/modules/Conch::DB::Result::Organization.md @@ -0,0 +1,86 @@ +# NAME + +Conch::DB::Result::Organization + +# BASE CLASS: [Conch::DB::Result](../modules/Conch::DB::Result) + +# TABLE: `organization` + +# ACCESSORS + +## id + +``` +data_type: 'uuid' +default_value: gen_random_uuid() +is_nullable: 0 +size: 16 +``` + +## name + +``` +data_type: 'text' +is_nullable: 0 +``` + +## description + +``` +data_type: 'text' +is_nullable: 1 +``` + +## created + +```perl +data_type: 'timestamp with time zone' +default_value: current_timestamp +is_nullable: 0 +original: {default_value => \"now()"} +``` + +## deactivated + +``` +data_type: 'timestamp with time zone' +is_nullable: 1 +``` + +# PRIMARY KEY + +- ["id"](#id) + +# RELATIONS + +## organization\_workspace\_roles + +Type: has\_many + +Related object: [Conch::DB::Result::OrganizationWorkspaceRole](../modules/Conch::DB::Result::OrganizationWorkspaceRole) + +## user\_organization\_roles + +Type: has\_many + +Related object: [Conch::DB::Result::UserOrganizationRole](../modules/Conch::DB::Result::UserOrganizationRole) + +## user\_accounts + +Type: many\_to\_many + +Composing rels: ["user\_organization\_roles"](#user_organization_roles) -> user\_account + +## workspaces + +Type: many\_to\_many + +Composing rels: ["organization\_workspace\_roles"](#organization_workspace_roles) -> workspace + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Result::OrganizationWorkspaceRole.md b/docs/modules/Conch::DB::Result::OrganizationWorkspaceRole.md new file mode 100644 index 000000000..1651f67d6 --- /dev/null +++ b/docs/modules/Conch::DB::Result::OrganizationWorkspaceRole.md @@ -0,0 +1,63 @@ +# NAME + +Conch::DB::Result::OrganizationWorkspaceRole + +# BASE CLASS: [Conch::DB::Result](../modules/Conch::DB::Result) + +# TABLE: `organization_workspace_role` + +# ACCESSORS + +## organization\_id + +``` +data_type: 'uuid' +is_foreign_key: 1 +is_nullable: 0 +size: 16 +``` + +## workspace\_id + +``` +data_type: 'uuid' +is_foreign_key: 1 +is_nullable: 0 +size: 16 +``` + +## role + +```perl +data_type: 'enum' +default_value: 'ro' +extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} +is_nullable: 0 +``` + +# PRIMARY KEY + +- ["organization\_id"](#organization_id) +- ["workspace\_id"](#workspace_id) + +# RELATIONS + +## organization + +Type: belongs\_to + +Related object: [Conch::DB::Result::Organization](../modules/Conch::DB::Result::Organization) + +## workspace + +Type: belongs\_to + +Related object: [Conch::DB::Result::Workspace](../modules/Conch::DB::Result::Workspace) + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Result::UserAccount.md b/docs/modules/Conch::DB::Result::UserAccount.md index 723ebbaab..421d34f2a 100644 --- a/docs/modules/Conch::DB::Result::UserAccount.md +++ b/docs/modules/Conch::DB::Result::UserAccount.md @@ -98,6 +98,12 @@ is_nullable: 1 # RELATIONS +## user\_organization\_roles + +Type: has\_many + +Related object: [Conch::DB::Result::UserOrganizationRole](../modules/Conch::DB::Result::UserOrganizationRole) + ## user\_relay\_connections Type: has\_many @@ -122,6 +128,12 @@ Type: has\_many Related object: [Conch::DB::Result::UserWorkspaceRole](../modules/Conch::DB::Result::UserWorkspaceRole) +## organizations + +Type: many\_to\_many + +Composing rels: ["user\_organization\_roles"](#user_organization_roles) -> organization + ## relays Type: many\_to\_many diff --git a/docs/modules/Conch::DB::Result::UserOrganizationRole.md b/docs/modules/Conch::DB::Result::UserOrganizationRole.md new file mode 100644 index 000000000..82705ef28 --- /dev/null +++ b/docs/modules/Conch::DB::Result::UserOrganizationRole.md @@ -0,0 +1,63 @@ +# NAME + +Conch::DB::Result::UserOrganizationRole + +# BASE CLASS: [Conch::DB::Result](../modules/Conch::DB::Result) + +# TABLE: `user_organization_role` + +# ACCESSORS + +## user\_id + +``` +data_type: 'uuid' +is_foreign_key: 1 +is_nullable: 0 +size: 16 +``` + +## organization\_id + +``` +data_type: 'uuid' +is_foreign_key: 1 +is_nullable: 0 +size: 16 +``` + +## role + +```perl +data_type: 'enum' +default_value: 'ro' +extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} +is_nullable: 0 +``` + +# PRIMARY KEY + +- ["user\_id"](#user_id) +- ["organization\_id"](#organization_id) + +# RELATIONS + +## organization + +Type: belongs\_to + +Related object: [Conch::DB::Result::Organization](../modules/Conch::DB::Result::Organization) + +## user\_account + +Type: belongs\_to + +Related object: [Conch::DB::Result::UserAccount](../modules/Conch::DB::Result::UserAccount) + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Result::Workspace.md b/docs/modules/Conch::DB::Result::Workspace.md index 675edaf49..c4f15b548 100644 --- a/docs/modules/Conch::DB::Result::Workspace.md +++ b/docs/modules/Conch::DB::Result::Workspace.md @@ -52,6 +52,12 @@ size: 16 # RELATIONS +## organization\_workspace\_roles + +Type: has\_many + +Related object: [Conch::DB::Result::OrganizationWorkspaceRole](../modules/Conch::DB::Result::OrganizationWorkspaceRole) + ## parent\_workspace Type: belongs\_to @@ -76,6 +82,12 @@ Type: has\_many Related object: [Conch::DB::Result::Workspace](../modules/Conch::DB::Result::Workspace) +## organizations + +Type: many\_to\_many + +Composing rels: ["organization\_workspace\_roles"](#organization_workspace_roles) -> organization + ## racks Type: many\_to\_many diff --git a/docs/modules/index.md b/docs/modules/index.md index 04ea6faed..ab017af77 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -57,11 +57,14 @@ * [Conch::DB::Result::HardwareProductProfile](../modules/Conch::DB::Result::HardwareProductProfile) * [Conch::DB::Result::HardwareVendor](../modules/Conch::DB::Result::HardwareVendor) * [Conch::DB::Result::Migration](../modules/Conch::DB::Result::Migration) +* [Conch::DB::Result::Organization](../modules/Conch::DB::Result::Organization) +* [Conch::DB::Result::OrganizationWorkspaceRole](../modules/Conch::DB::Result::OrganizationWorkspaceRole) * [Conch::DB::Result::Rack](../modules/Conch::DB::Result::Rack) * [Conch::DB::Result::RackLayout](../modules/Conch::DB::Result::RackLayout) * [Conch::DB::Result::RackRole](../modules/Conch::DB::Result::RackRole) * [Conch::DB::Result::Relay](../modules/Conch::DB::Result::Relay) * [Conch::DB::Result::UserAccount](../modules/Conch::DB::Result::UserAccount) +* [Conch::DB::Result::UserOrganizationRole](../modules/Conch::DB::Result::UserOrganizationRole) * [Conch::DB::Result::UserRelayConnection](../modules/Conch::DB::Result::UserRelayConnection) * [Conch::DB::Result::UserSessionToken](../modules/Conch::DB::Result::UserSessionToken) * [Conch::DB::Result::UserSetting](../modules/Conch::DB::Result::UserSetting) diff --git a/lib/Conch/DB/Result/Organization.pm b/lib/Conch/DB/Result/Organization.pm new file mode 100644 index 000000000..cd2bd5365 --- /dev/null +++ b/lib/Conch/DB/Result/Organization.pm @@ -0,0 +1,168 @@ +use utf8; +package Conch::DB::Result::Organization; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Conch::DB::Result::Organization + +=cut + +use strict; +use warnings; + + +=head1 BASE CLASS: L + +=cut + +use base 'Conch::DB::Result'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("organization"); + +=head1 ACCESSORS + +=head2 id + + data_type: 'uuid' + default_value: gen_random_uuid() + is_nullable: 0 + size: 16 + +=head2 name + + data_type: 'text' + is_nullable: 0 + +=head2 description + + data_type: 'text' + is_nullable: 1 + +=head2 created + + data_type: 'timestamp with time zone' + default_value: current_timestamp + is_nullable: 0 + original: {default_value => \"now()"} + +=head2 deactivated + + data_type: 'timestamp with time zone' + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "id", + { + data_type => "uuid", + default_value => \"gen_random_uuid()", + is_nullable => 0, + size => 16, + }, + "name", + { data_type => "text", is_nullable => 0 }, + "description", + { data_type => "text", is_nullable => 1 }, + "created", + { + data_type => "timestamp with time zone", + default_value => \"current_timestamp", + is_nullable => 0, + original => { default_value => \"now()" }, + }, + "deactivated", + { data_type => "timestamp with time zone", is_nullable => 1 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("id"); + +=head1 RELATIONS + +=head2 organization_workspace_roles + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "organization_workspace_roles", + "Conch::DB::Result::OrganizationWorkspaceRole", + { "foreign.organization_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + +=head2 user_organization_roles + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "user_organization_roles", + "Conch::DB::Result::UserOrganizationRole", + { "foreign.organization_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + +=head2 user_accounts + +Type: many_to_many + +Composing rels: L -> user_account + +=cut + +__PACKAGE__->many_to_many("user_accounts", "user_organization_roles", "user_account"); + +=head2 workspaces + +Type: many_to_many + +Composing rels: L -> workspace + +=cut + +__PACKAGE__->many_to_many("workspaces", "organization_workspace_roles", "workspace"); + + +# Created by DBIx::Class::Schema::Loader v0.07049 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VfC1BoN/FWn5FjaRHSj03Q + + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm b/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm new file mode 100644 index 000000000..98fa87052 --- /dev/null +++ b/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm @@ -0,0 +1,133 @@ +use utf8; +package Conch::DB::Result::OrganizationWorkspaceRole; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Conch::DB::Result::OrganizationWorkspaceRole + +=cut + +use strict; +use warnings; + + +=head1 BASE CLASS: L + +=cut + +use base 'Conch::DB::Result'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("organization_workspace_role"); + +=head1 ACCESSORS + +=head2 organization_id + + data_type: 'uuid' + is_foreign_key: 1 + is_nullable: 0 + size: 16 + +=head2 workspace_id + + data_type: 'uuid' + is_foreign_key: 1 + is_nullable: 0 + size: 16 + +=head2 role + + data_type: 'enum' + default_value: 'ro' + extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "organization_id", + { data_type => "uuid", is_foreign_key => 1, is_nullable => 0, size => 16 }, + "workspace_id", + { data_type => "uuid", is_foreign_key => 1, is_nullable => 0, size => 16 }, + "role", + { + data_type => "enum", + default_value => "ro", + extra => { custom_type_name => "role_enum", list => ["ro", "rw", "admin"] }, + is_nullable => 0, + }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("organization_id", "workspace_id"); + +=head1 RELATIONS + +=head2 organization + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "organization", + "Conch::DB::Result::Organization", + { id => "organization_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + +=head2 workspace + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "workspace", + "Conch::DB::Result::Workspace", + { id => "workspace_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07049 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZZaSreTpDXoTMxhiWIz9zQ + + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/Result/UserAccount.pm b/lib/Conch/DB/Result/UserAccount.pm index 6e95bc9ad..1c0a038e2 100644 --- a/lib/Conch/DB/Result/UserAccount.pm +++ b/lib/Conch/DB/Result/UserAccount.pm @@ -141,6 +141,21 @@ __PACKAGE__->set_primary_key("id"); =head1 RELATIONS +=head2 user_organization_roles + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "user_organization_roles", + "Conch::DB::Result::UserOrganizationRole", + { "foreign.user_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + =head2 user_relay_connections Type: has_many @@ -201,6 +216,16 @@ __PACKAGE__->has_many( { cascade_copy => 0, cascade_delete => 0 }, ); +=head2 organizations + +Type: many_to_many + +Composing rels: L -> organization + +=cut + +__PACKAGE__->many_to_many("organizations", "user_organization_roles", "organization"); + =head2 relays Type: many_to_many @@ -223,7 +248,7 @@ __PACKAGE__->many_to_many("workspaces", "user_workspace_roles", "workspace"); # Created by DBIx::Class::Schema::Loader v0.07049 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:vpTbeHmoxnxcjZ7EUUg9yA +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:KQ7+QU/4ZJXYM8WZw89t6g use DBIx::Class::PassphraseColumn 0.04 (); __PACKAGE__->load_components('PassphraseColumn'); diff --git a/lib/Conch/DB/Result/UserOrganizationRole.pm b/lib/Conch/DB/Result/UserOrganizationRole.pm new file mode 100644 index 000000000..4f944ef34 --- /dev/null +++ b/lib/Conch/DB/Result/UserOrganizationRole.pm @@ -0,0 +1,133 @@ +use utf8; +package Conch::DB::Result::UserOrganizationRole; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Conch::DB::Result::UserOrganizationRole + +=cut + +use strict; +use warnings; + + +=head1 BASE CLASS: L + +=cut + +use base 'Conch::DB::Result'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("user_organization_role"); + +=head1 ACCESSORS + +=head2 user_id + + data_type: 'uuid' + is_foreign_key: 1 + is_nullable: 0 + size: 16 + +=head2 organization_id + + data_type: 'uuid' + is_foreign_key: 1 + is_nullable: 0 + size: 16 + +=head2 role + + data_type: 'enum' + default_value: 'ro' + extra: {custom_type_name => "role_enum",list => ["ro","rw","admin"]} + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "user_id", + { data_type => "uuid", is_foreign_key => 1, is_nullable => 0, size => 16 }, + "organization_id", + { data_type => "uuid", is_foreign_key => 1, is_nullable => 0, size => 16 }, + "role", + { + data_type => "enum", + default_value => "ro", + extra => { custom_type_name => "role_enum", list => ["ro", "rw", "admin"] }, + is_nullable => 0, + }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("user_id", "organization_id"); + +=head1 RELATIONS + +=head2 organization + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "organization", + "Conch::DB::Result::Organization", + { id => "organization_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + +=head2 user_account + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "user_account", + "Conch::DB::Result::UserAccount", + { id => "user_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07049 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:elBmld/kFpmlmPYd88GVlQ + + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/Result/Workspace.pm b/lib/Conch/DB/Result/Workspace.pm index 20d2bacff..9d3143a0b 100644 --- a/lib/Conch/DB/Result/Workspace.pm +++ b/lib/Conch/DB/Result/Workspace.pm @@ -98,6 +98,21 @@ __PACKAGE__->add_unique_constraint("workspace_name_key", ["name"]); =head1 RELATIONS +=head2 organization_workspace_roles + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "organization_workspace_roles", + "Conch::DB::Result::OrganizationWorkspaceRole", + { "foreign.workspace_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + =head2 parent_workspace Type: belongs_to @@ -163,6 +178,20 @@ __PACKAGE__->has_many( { cascade_copy => 0, cascade_delete => 0 }, ); +=head2 organizations + +Type: many_to_many + +Composing rels: L -> organization + +=cut + +__PACKAGE__->many_to_many( + "organizations", + "organization_workspace_roles", + "organization", +); + =head2 racks Type: many_to_many @@ -185,7 +214,7 @@ __PACKAGE__->many_to_many("user_accounts", "user_workspace_roles", "user_account # Created by DBIx::Class::Schema::Loader v0.07049 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:UTkb6H9/XmVkYnMTHm2uQw +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Urk+hHQeLcSJ6VUznFO6Hg use experimental 'signatures'; use Sub::Install; diff --git a/sql/migrations/0128-organizations.sql b/sql/migrations/0128-organizations.sql index 05786f8e2..f97a0b7c8 100644 --- a/sql/migrations/0128-organizations.sql +++ b/sql/migrations/0128-organizations.sql @@ -2,4 +2,30 @@ SELECT run_migration(128, $$ alter type user_workspace_role_enum rename to role_enum; + create table organization ( + id uuid default gen_random_uuid() not null primary key, + name text not null, + description text, + created timestamp with time zone default now() not null, + deactivated timestamp with time zone + ); + create unique index organization_name_key + on organization (name) where deactivated is null; + + create table user_organization_role ( + user_id uuid not null references user_account (id), + organization_id uuid not null references organization (id), + role role_enum default 'ro' not null, + primary key (user_id, organization_id) + ); + + create table organization_workspace_role ( + organization_id uuid not null references organization (id), + workspace_id uuid not null references workspace (id), + role role_enum default 'ro' not null, + primary key (organization_id, workspace_id) + ); + + grant select on all tables in schema public to conch_read_only; + $$); diff --git a/sql/schema.sql b/sql/schema.sql index ed5bf68fe..d960907c9 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -433,6 +433,34 @@ CREATE TABLE public.migration ( ALTER TABLE public.migration OWNER TO conch; +-- +-- Name: organization; Type: TABLE; Schema: public; Owner: conch +-- + +CREATE TABLE public.organization ( + id uuid DEFAULT public.gen_random_uuid() NOT NULL, + name text NOT NULL, + description text, + created timestamp with time zone DEFAULT now() NOT NULL, + deactivated timestamp with time zone +); + + +ALTER TABLE public.organization OWNER TO conch; + +-- +-- Name: organization_workspace_role; Type: TABLE; Schema: public; Owner: conch +-- + +CREATE TABLE public.organization_workspace_role ( + organization_id uuid NOT NULL, + workspace_id uuid NOT NULL, + role public.role_enum DEFAULT 'ro'::public.role_enum NOT NULL +); + + +ALTER TABLE public.organization_workspace_role OWNER TO conch; + -- -- Name: rack; Type: TABLE; Schema: public; Owner: conch -- @@ -527,6 +555,19 @@ CREATE TABLE public.user_account ( ALTER TABLE public.user_account OWNER TO conch; +-- +-- Name: user_organization_role; Type: TABLE; Schema: public; Owner: conch +-- + +CREATE TABLE public.user_organization_role ( + user_id uuid NOT NULL, + organization_id uuid NOT NULL, + role public.role_enum DEFAULT 'ro'::public.role_enum NOT NULL +); + + +ALTER TABLE public.user_organization_role OWNER TO conch; + -- -- Name: user_relay_connection; Type: TABLE; Schema: public; Owner: conch -- @@ -869,6 +910,22 @@ ALTER TABLE ONLY public.migration ADD CONSTRAINT migration_pkey PRIMARY KEY (id); +-- +-- Name: organization organization_pkey; Type: CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.organization + ADD CONSTRAINT organization_pkey PRIMARY KEY (id); + + +-- +-- Name: organization_workspace_role organization_workspace_role_pkey; Type: CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.organization_workspace_role + ADD CONSTRAINT organization_workspace_role_pkey PRIMARY KEY (organization_id, workspace_id); + + -- -- Name: rack_layout rack_layout_pkey; Type: CONSTRAINT; Schema: public; Owner: conch -- @@ -941,6 +998,14 @@ ALTER TABLE ONLY public.user_account ADD CONSTRAINT user_account_pkey PRIMARY KEY (id); +-- +-- Name: user_organization_role user_organization_role_pkey; Type: CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.user_organization_role + ADD CONSTRAINT user_organization_role_pkey PRIMARY KEY (user_id, organization_id); + + -- -- Name: user_relay_connection user_relay_connection_pkey; Type: CONSTRAINT; Schema: public; Owner: conch -- @@ -1214,6 +1279,13 @@ CREATE UNIQUE INDEX hardware_product_sku_key ON public.hardware_product USING bt CREATE UNIQUE INDEX hardware_vendor_name_key ON public.hardware_vendor USING btree (name) WHERE (deactivated IS NULL); +-- +-- Name: organization_name_key; Type: INDEX; Schema: public; Owner: conch +-- + +CREATE UNIQUE INDEX organization_name_key ON public.organization USING btree (name) WHERE (deactivated IS NULL); + + -- -- Name: rack_datacenter_room_id_idx; Type: INDEX; Schema: public; Owner: conch -- @@ -1563,6 +1635,22 @@ ALTER TABLE ONLY public.hardware_product ADD CONSTRAINT hardware_product_vendor_fkey FOREIGN KEY (hardware_vendor_id) REFERENCES public.hardware_vendor(id); +-- +-- Name: organization_workspace_role organization_workspace_role_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.organization_workspace_role + ADD CONSTRAINT organization_workspace_role_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES public.organization(id); + + +-- +-- Name: organization_workspace_role organization_workspace_role_workspace_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.organization_workspace_role + ADD CONSTRAINT organization_workspace_role_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES public.workspace(id); + + -- -- Name: rack rack_datacenter_room_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch -- @@ -1603,6 +1691,22 @@ ALTER TABLE ONLY public.rack ADD CONSTRAINT rack_role_fkey FOREIGN KEY (rack_role_id) REFERENCES public.rack_role(id); +-- +-- Name: user_organization_role user_organization_role_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.user_organization_role + ADD CONSTRAINT user_organization_role_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES public.organization(id); + + +-- +-- Name: user_organization_role user_organization_role_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch +-- + +ALTER TABLE ONLY public.user_organization_role + ADD CONSTRAINT user_organization_role_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.user_account(id); + + -- -- Name: user_relay_connection user_relay_connection_relay_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: conch -- @@ -1853,6 +1957,20 @@ GRANT SELECT ON TABLE public.hardware_vendor TO conch_read_only; GRANT SELECT ON TABLE public.migration TO conch_read_only; +-- +-- Name: TABLE organization; Type: ACL; Schema: public; Owner: conch +-- + +GRANT SELECT ON TABLE public.organization TO conch_read_only; + + +-- +-- Name: TABLE organization_workspace_role; Type: ACL; Schema: public; Owner: conch +-- + +GRANT SELECT ON TABLE public.organization_workspace_role TO conch_read_only; + + -- -- Name: TABLE rack; Type: ACL; Schema: public; Owner: conch -- @@ -1888,6 +2006,13 @@ GRANT SELECT ON TABLE public.relay TO conch_read_only; GRANT SELECT ON TABLE public.user_account TO conch_read_only; +-- +-- Name: TABLE user_organization_role; Type: ACL; Schema: public; Owner: conch +-- + +GRANT SELECT ON TABLE public.user_organization_role TO conch_read_only; + + -- -- Name: TABLE user_relay_connection; Type: ACL; Schema: public; Owner: conch -- From f3f0814ad4552f876b3d39391d4c8812aec00c88 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Fri, 14 Jun 2019 11:24:47 -0700 Subject: [PATCH 5/8] add /organization endpoints GET /organization POST /organization GET /organization/:organization_id_or_name DELETE /organization/:organization_id_or_name GET /organization/:organization_id_or_name/user POST /organization/:organization_id_or_name/user?send_mail=<1|0> DELETE /organization/:organization_id_or_name/user/#target_user_id_or_email?send_mail=<1|0> --- docs/index.md | 3 + .../Conch::Controller::Organization.md | 70 +++ docs/modules/Conch::Controller::User.md | 2 +- .../Conch::DB::Result::Organization.md | 6 + docs/modules/Conch::DB::Result::Workspace.md | 7 +- .../Conch::DB::ResultSet::Organization.md | 22 + docs/modules/Conch::Route.md | 4 + docs/modules/Conch::Route::Organization.md | 61 +++ docs/modules/index.md | 3 + json-schema/request.yaml | 45 ++ json-schema/response.yaml | 70 +++ lib/Conch/Controller/Organization.pm | 330 +++++++++++++++ lib/Conch/Controller/User.pm | 26 +- lib/Conch/DB/Result/Organization.pm | 55 +++ lib/Conch/DB/Result/Workspace.pm | 9 +- lib/Conch/DB/ResultSet/Organization.pm | 52 +++ lib/Conch/Route.pm | 6 + lib/Conch/Route/Organization.pm | 149 +++++++ t/integration/crud/organization.t | 399 ++++++++++++++++++ .../email/organization_user_add_admins.txt.ep | 8 + .../email/organization_user_add_user.txt.ep | 7 + .../organization_user_remove_admins.txt.ep | 8 + .../organization_user_remove_user.txt.ep | 7 + .../organization_user_update_admins.txt.ep | 8 + .../organization_user_update_user.txt.ep | 7 + 25 files changed, 1359 insertions(+), 5 deletions(-) create mode 100644 docs/modules/Conch::Controller::Organization.md create mode 100644 docs/modules/Conch::DB::ResultSet::Organization.md create mode 100644 docs/modules/Conch::Route::Organization.md create mode 100644 lib/Conch/Controller/Organization.pm create mode 100644 lib/Conch/DB/ResultSet/Organization.pm create mode 100644 lib/Conch/Route/Organization.pm create mode 100644 t/integration/crud/organization.t create mode 100644 templates/email/organization_user_add_admins.txt.ep create mode 100644 templates/email/organization_user_add_user.txt.ep create mode 100644 templates/email/organization_user_remove_admins.txt.ep create mode 100644 templates/email/organization_user_remove_user.txt.ep create mode 100644 templates/email/organization_user_update_admins.txt.ep create mode 100644 templates/email/organization_user_update_user.txt.ep diff --git a/docs/index.md b/docs/index.md index a1cbe42a9..ddb64ec0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,9 @@ directory in the main repository. * [Conch::Route::RackLayout](modules/Conch::Route::RackLayout) * `/layout` +* [Conch::Route::Organization](modules/Conch::Route::Organization) + * `/organization` + * [Conch::Route::Rack](modules/Conch::Route::Rack) * `/rack` diff --git a/docs/modules/Conch::Controller::Organization.md b/docs/modules/Conch::Controller::Organization.md new file mode 100644 index 000000000..0d90b3f5b --- /dev/null +++ b/docs/modules/Conch::Controller::Organization.md @@ -0,0 +1,70 @@ +# NAME + +Conch::Controller::Organization + +# METHODS + +## list + +If the user is a system admin, retrieve a list of all active organizations in the database; +otherwise, limits the list to those organizations of which the user is a member. + +Response uses the Organizations json schema. + +## create + +Creates an organization. + +Requires the user to be a system admin. + +## find\_organization + +Chainable action that validates the `organization_id` or `organization_name` provided in the +path, and stashes the query to get to it in `organization_rs`. + +Requires the 'admin' role on the organization (or the user to be a system admin). + +## get + +Get the details of a single organization. +Requires the 'admin' role on the organization. + +Response uses the Organization json schema. + +## delete + +Deactivates the organization, preventing its members from exercising any privileges from it. + +User must have system admin privileges. + +## list\_users + +Get a list of members of the current organization. +Requires the 'admin' role on the organization. + +Response uses the OrganizationUsers json schema. + +## add\_user + +Adds a user to the current organization, or upgrades an existing role entry to access the +organization. +Requires the 'admin' role on the organization. + +Optionally takes a query parameter `send_mail` (defaulting to true), to send an email +to the user and to all organization admins. + +## remove\_user + +Removes the indicated user from the organization. +Requires the 'admin' role on the organization. + +Optionally takes a query parameter `send_mail` (defaulting to true), to send an email +to the user and to all organization admins. + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::Controller::User.md b/docs/modules/Conch::Controller::User.md index 47636b6f9..44f0cf0ca 100644 --- a/docs/modules/Conch::Controller::User.md +++ b/docs/modules/Conch::Controller::User.md @@ -124,7 +124,7 @@ Optionally takes a query parameter `clear_tokens` (defaulting to true), to also session tokens for the user, which would force all tools to log in again should the account be reactivated (for which there is no api endpoint at present). -All user\_workspace\_role entries are removed and are not recoverable. +All memberships in workspaces and organizations are removed and are not recoverable. Response uses the UserError json schema on some error conditions. diff --git a/docs/modules/Conch::DB::Result::Organization.md b/docs/modules/Conch::DB::Result::Organization.md index ed3eb2153..7cd836b06 100644 --- a/docs/modules/Conch::DB::Result::Organization.md +++ b/docs/modules/Conch::DB::Result::Organization.md @@ -77,6 +77,12 @@ Type: many\_to\_many Composing rels: ["organization\_workspace\_roles"](#organization_workspace_roles) -> workspace +# METHODS + +## TO\_JSON + +Include information about the organization's admins and workspaces. + # LICENSING Copyright Joyent, Inc. diff --git a/docs/modules/Conch::DB::Result::Workspace.md b/docs/modules/Conch::DB::Result::Workspace.md index c4f15b548..f5e2b9010 100644 --- a/docs/modules/Conch::DB::Result::Workspace.md +++ b/docs/modules/Conch::DB::Result::Workspace.md @@ -111,7 +111,12 @@ Accessor for informational column, which is by the serializer in the result data ## user\_id\_for\_role Accessor for informational column, which is used by the serializer to signal we should fetch -and include inherited role data. +and include inherited role data for the user. + +## organization\_id\_for\_role + +Accessor for informational column, which is used by the serializer to signal we should fetch +and include inherited role data for the organization. # LICENSING diff --git a/docs/modules/Conch::DB::ResultSet::Organization.md b/docs/modules/Conch::DB::ResultSet::Organization.md new file mode 100644 index 000000000..75ac72515 --- /dev/null +++ b/docs/modules/Conch::DB::ResultSet::Organization.md @@ -0,0 +1,22 @@ +# NAME + +Conch::DB::ResultSet::Organization + +# DESCRIPTION + +Interface to queries involving organizations. + +# METHODS + +## admins + +All the 'admin' users for the provided organization(s). Pass a true argument to also include all +system admin users in the result. + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::Route.md b/docs/modules/Conch::Route.md index f2621adad..2dc20338e 100644 --- a/docs/modules/Conch::Route.md +++ b/docs/modules/Conch::Route.md @@ -88,6 +88,10 @@ See ["routes" in Conch::Route::HardwareProduct](../modules/Conch::Route::Hardwar See ["routes" in Conch::Route::HardwareVendor](../modules/Conch::Route::HardwareVendor#routes) +### `* /organization` + +See ["routes" in Conch::Route::Organization](../modules/Conch::Route::Organization#routes) + ### `* /relay` See ["routes" in Conch::Route::Relay](../modules/Conch::Route::Relay#routes) diff --git a/docs/modules/Conch::Route::Organization.md b/docs/modules/Conch::Route::Organization.md new file mode 100644 index 000000000..7d9bf4685 --- /dev/null +++ b/docs/modules/Conch::Route::Organization.md @@ -0,0 +1,61 @@ +# NAME + +Conch::Route::Organization + +# METHODS + +## routes + +Sets up the routes for /organization. + +Unless otherwise noted, all routes require authentication. + +### `GET /organization` + +- Response: response.yaml#/Organizations + +### `POST /organization` + +- Requires system admin authorization +- Request: request.yaml#/OrganizationCreate +- Response: Redirect to the organization + +### `GET /organization/:organization_id_or_name` + +- Requires system admin authorization or the admin role on the organization +- Response: response.yaml#/Organization + +### `DELETE /organization/:organization_id_or_name` + +- Requires system admin authorization +- Response: `204 NO CONTENT` + +### `GET /organization/:organization_id_or_name/user` + +- Requires system admin authorization or the admin role on the organization +- Response: response.yaml#/OrganizationUsers + +### `POST /organization/:organization_id_or_name/user?send_mail=<1|0`> + +Takes one optional query parameter `send_mail=<1|0>` (defaults to 1) to send +an email to the user. + +- Requires system admin authorization or the admin role on the organization +- Request: request.yaml#/OrganizationAddUser +- Response: `204 NO CONTENT` + +### `DELETE /organization/:organization_id_or_name/user/#target_user_id_or_email?send_mail=<1|0`> + +Takes one optional query parameter `send_mail=<1|0>` (defaults to 1) to send +an email to the user. + +- Requires system admin authorization or the admin role on the organization +- Returns `204 NO CONTENT` + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/index.md b/docs/modules/index.md index ab017af77..07ca5ff30 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -22,6 +22,7 @@ * [Conch::Controller::HardwareProduct](../modules/Conch::Controller::HardwareProduct) * [Conch::Controller::HardwareVendor](../modules/Conch::Controller::HardwareVendor) * [Conch::Controller::Login](../modules/Conch::Controller::Login) +* [Conch::Controller::Organization](../modules/Conch::Controller::Organization) * [Conch::Controller::Rack](../modules/Conch::Controller::Rack) * [Conch::Controller::RackLayout](../modules/Conch::Controller::RackLayout) * [Conch::Controller::RackRole](../modules/Conch::Controller::RackRole) @@ -82,6 +83,7 @@ * [Conch::DB::ResultSet::DeviceLocation](../modules/Conch::DB::ResultSet::DeviceLocation) * [Conch::DB::ResultSet::DeviceNic](../modules/Conch::DB::ResultSet::DeviceNic) * [Conch::DB::ResultSet::DeviceReport](../modules/Conch::DB::ResultSet::DeviceReport) +* [Conch::DB::ResultSet::Organization](../modules/Conch::DB::ResultSet::Organization) * [Conch::DB::ResultSet::Rack](../modules/Conch::DB::ResultSet::Rack) * [Conch::DB::ResultSet::RackLayout](../modules/Conch::DB::ResultSet::RackLayout) * [Conch::DB::ResultSet::UserAccount](../modules/Conch::DB::ResultSet::UserAccount) @@ -107,6 +109,7 @@ * [Conch::Route::DeviceReport](../modules/Conch::Route::DeviceReport) * [Conch::Route::HardwareProduct](../modules/Conch::Route::HardwareProduct) * [Conch::Route::HardwareVendor](../modules/Conch::Route::HardwareVendor) +* [Conch::Route::Organization](../modules/Conch::Route::Organization) * [Conch::Route::Rack](../modules/Conch::Route::Rack) * [Conch::Route::RackLayout](../modules/Conch::Route::RackLayout) * [Conch::Route::RackRole](../modules/Conch::Route::RackRole) diff --git a/json-schema/request.yaml b/json-schema/request.yaml index a2f8ad23e..87e00981b 100644 --- a/json-schema/request.yaml +++ b/json-schema/request.yaml @@ -547,5 +547,50 @@ definitions: type: string ssh_port: $ref: common.yaml#/definitions/non_negative_integer + OrganizationCreate: + type: object + additionalProperties: false + required: + - name + - admins + properties: + name: + $ref: common.yaml#/definitions/mojo_standard_placeholder + description: + type: string + admins: + type: array + uniqueItems: true + minItems: 1 + items: + type: object + additionalProperties: false + oneOf: + - required: + - user_id + - required: + - email + properties: + user_id: + $ref: common.yaml#/definitions/uuid + email: + $ref: common.yaml#/definitions/email_address + OrganizationAddUser: + type: object + additionalProperties: false + required: + - role + oneOf: + - required: + - user_id + - required: + - email + properties: + user_id: + $ref: common.yaml#/definitions/uuid + email: + $ref: common.yaml#/definitions/email_address + role: + $ref: common.yaml#/definitions/role # vim: set sts=2 sw=2 et : diff --git a/json-schema/response.yaml b/json-schema/response.yaml index a4ce8211d..c5c905501 100644 --- a/json-schema/response.yaml +++ b/json-schema/response.yaml @@ -1696,5 +1696,75 @@ definitions: created: type: string format: date-time + UserTerse: + type: object + additionalProperties: false + required: + - id + - name + - email + properties: + id: + $ref: common.yaml#/definitions/uuid + name: + type: string + email: + $ref: common.yaml#/definitions/email_address + Organization: + type: object + additionalProperties: false + required: + - id + - name + - description + - created + - admins + - workspaces + properties: + id: + $ref: common.yaml#/definitions/uuid + name: + $ref: common.yaml#/definitions/mojo_relaxed_placeholder + description: + oneOf: + - type: 'null' + - type: string + created: + type: string + format: date-time + admins: + type: array + uniqueItems: true + minItems: 1 + items: + $ref: /definitions/UserTerse + workspaces: + $ref: /definitions/WorkspacesAndRoles + Organizations: + type: array + uniqueItems: true + items: + $ref: /definitions/Organization + OrganizationUsers: + type: array + uniqueItems: true + minItems: 1 + items: + type: object + additionalProperties: false + required: + - id + - name + - email + - role + properties: + id: + $ref: common.yaml#/definitions/uuid + name: + type: string + email: + $ref: common.yaml#/definitions/email_address + role: + $ref: common.yaml#/definitions/role # vim: set sts=2 sw=2 et : diff --git a/lib/Conch/Controller/Organization.pm b/lib/Conch/Controller/Organization.pm new file mode 100644 index 000000000..7dbafc572 --- /dev/null +++ b/lib/Conch/Controller/Organization.pm @@ -0,0 +1,330 @@ +package Conch::Controller::Organization; + +use Mojo::Base 'Mojolicious::Controller', -signatures; + +use Conch::UUID 'is_uuid'; + +=pod + +=head1 NAME + +Conch::Controller::Organization + +=head1 METHODS + +=head2 list + +If the user is a system admin, retrieve a list of all active organizations in the database; +otherwise, limits the list to those organizations of which the user is a member. + +Response uses the Organizations json schema. + +=cut + +sub list ($c) { + my $rs = $c->db_organizations + ->active + ->search({ 'user_organization_roles.role' => 'admin' }) + ->prefetch({ + user_organization_roles => 'user_account', + organization_workspace_roles => 'workspace', + }) + ->order_by([qw(organization.name user_account.name)]); + + return $c->status(200, [ $rs->all ]) if $c->is_system_admin; + + # normal users can only see organizations in which they are a member + $rs = $rs->search({ 'organization.id' => { -in => + $c->db_user_organization_roles->search({ user_id => $c->stash('user_id') }) + ->get_column('organization_id')->as_query + } }) + if not $c->is_system_admin; + + $c->status(200, [ $rs->all ]); +} + +=head2 create + +Creates an organization. + +Requires the user to be a system admin. + +=cut + +sub create ($c) { + my $input = $c->validate_request('OrganizationCreate'); + return if not $input; + + return $c->status(409, { error => 'an organization already exists with that name' }) + if $c->db_organizations->active->search({ $input->%{name} })->exists; + + # turn emails into user_ids, and confirm they all exist... + # [ user_id|email, $value, $user_id ], [ ... ] + my @admins = map [ + $_->%*, + ($_->{user_id} && $c->db_user_accounts->search({ id => $_->{user_id} })->exists ? $_->{user_id} + : $_->{email} ? $c->db_user_accounts->search_by_email($_->{email})->get_column('id')->single + : undef) + ], (delete $input->{admins})->@*; + + my @errors = map join(' ', $_->@[0,1]), grep !$_->[2], @admins; + return $c->status(409, { error => 'unrecognized '.join(', ', @errors) }) if @errors; + + my $organization = $c->db_organizations->create({ + $input->%*, + user_organization_roles => [ map +{ user_id => $_->[2], role => 'admin' }, @admins ], + }); + $c->log->info('created organization '.$organization->id.' ('.$organization->name.')'); + $c->status(303, '/organization/'.$organization->id); +} + +=head2 find_organization + +Chainable action that validates the C or C provided in the +path, and stashes the query to get to it in C. + +Requires the 'admin' role on the organization (or the user to be a system admin). + +=cut + +sub find_organization ($c) { + my $identifier = $c->stash('organization_id_or_name'); + my $rs = $c->db_organizations->active; + if (is_uuid($identifier)) { + $c->stash('organization_id', $identifier); + $rs = $rs->search({ 'organization.id' => $identifier }); + } + else { + $c->stash('organization_name', $identifier); + $rs = $rs->search({ 'organization.name' => $identifier }); + } + + return $c->status(404) if not $rs->exists; + + return $c->status(403) + if not $c->is_system_admin + and not $rs->search_related('user_organization_roles', + { user_id => $c->stash('user_id'), role => 'admin' })->exists; + + $c->stash('organization_rs', $rs); +} + +=head2 get + +Get the details of a single organization. +Requires the 'admin' role on the organization. + +Response uses the Organization json schema. + +=cut + +sub get ($c) { + my ($organization) = $c->stash('organization_rs') + ->search({ 'user_organization_roles.role' => 'admin' }) + ->prefetch({ + user_organization_roles => 'user_account', + organization_workspace_roles => 'workspace', + }) + ->order_by('user_account.name') + ->all; + $c->status(200, $organization); +} + +=head2 delete + +Deactivates the organization, preventing its members from exercising any privileges from it. + +User must have system admin privileges. + +=cut + +sub delete ($c) { + my $user_count = 0+$c->stash('organization_rs')->related_resultset('user_organization_roles')->delete; + my $workspace_count = 0+$c->stash('organization_rs')->related_resultset('organization_workspace_roles')->delete; + + $c->stash('organization_rs')->deactivate; + $c->log->debug('Deactivated organization '.$c->stash('organization_id_or_name') + .', removing '.$user_count.' user memberships and removing from ' + .$workspace_count.' workspaces'); + return $c->status(204); +} + +=head2 list_users + +Get a list of members of the current organization. +Requires the 'admin' role on the organization. + +Response uses the OrganizationUsers json schema. + +=cut + +sub list_users ($c) { + my $rs = $c->stash('organization_rs') + ->related_resultset('user_organization_roles') + ->related_resultset('user_account') + ->active + ->columns([ { role => 'user_organization_roles.role' }, map 'user_account.'.$_, qw(id name email) ]) + ->order_by([ { -desc => 'role' }, 'name' ]); + + $c->status(200, [ $rs->hri->all ]); +} + +=head2 add_user + +Adds a user to the current organization, or upgrades an existing role entry to access the +organization. +Requires the 'admin' role on the organization. + +Optionally takes a query parameter C (defaulting to true), to send an email +to the user and to all organization admins. + +=cut + +sub add_user ($c) { + my $params = $c->validate_query_params('NotifyUsers'); + return if not $params; + + my $input = $c->validate_request('OrganizationAddUser'); + return if not $input; + + my $user_rs = $c->db_user_accounts->active; + my $user = $input->{user_id} ? $user_rs->find($input->{user_id}) + : $input->{email} ? $user_rs->find_by_email($input->{email}) + : undef; + return $c->status(404) if not $user; + + $c->stash('target_user', $user); + my $organization_name = $c->stash('organization_name') // $c->stash('organization_rs')->get_column('name')->single; + + # check if the user already has access to this organization + if (my $existing_role = $c->stash('organization_rs') + ->search_related('user_organization_roles', { user_id => $user->id })->single) { + if ($existing_role->role eq $input->{role}) { + $c->log->debug('user '.$user->id.' ('.$user->name.') already has '.$input->{role} + .' access to organization '.$c->stash('organization_id_or_name').': nothing to do'); + return $c->status(204); + } + + $existing_role->update({ role => $input->{role} }); + $c->log->info('Updated access for user '.$user->id.' ('.$user->name.') in organization ' + .$c->stash('organization_id_or_name').' to the '.$input->{role}.' role'); + + if ($params->{send_mail} // 1) { + $c->send_mail( + template_file => 'organization_user_update_user', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + organization => $organization_name, + role => $input->{role}, + ); + my @admins = $c->stash('organization_rs') + ->admins('with_sysadmins') + ->search({ 'user_account.id' => { '!=' => $user->id } }); + $c->send_mail( + template_file => 'organization_user_update_admins', + To => $c->construct_address_list(@admins), + From => 'noreply@conch.joyent.us', + Subject => 'We modified a user\'s access to your organization', + organization => $organization_name, + role => $input->{role}, + ) if @admins; + } + + return $c->status(204); + } + + $user->create_related('user_organization_roles', { + organization_id => $c->stash('organization_id') // $c->stash('organization_rs')->get_column('id')->as_query, + role => $input->{role}, + }); + $c->log->info('Added user '.$user->id.' ('.$user->name.') to organization '.$c->stash('organization_id_or_name').' with the '.$input->{role}.' role'); + + if ($params->{send_mail} // 1) { + $c->send_mail( + template_file => 'organization_user_add_user', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + organization => $organization_name, + role => $input->{role}, + ); + my @admins = $c->stash('organization_rs') + ->admins('with_sysadmins') + ->search({ 'user_account.id' => { '!=' => $user->id } }); + $c->send_mail( + template_file => 'organization_user_add_admins', + To => $c->construct_address_list(@admins), + From => 'noreply@conch.joyent.us', + Subject => 'We added a user to your organization', + organization => $organization_name, + role => $input->{role}, + ) if @admins; + } + + $c->status(204); +} + +=head2 remove_user + +Removes the indicated user from the organization. +Requires the 'admin' role on the organization. + +Optionally takes a query parameter C (defaulting to true), to send an email +to the user and to all organization admins. + +=cut + +sub remove_user ($c) { + my $params = $c->validate_query_params('NotifyUsers'); + return if not $params; + + my $user = $c->stash('target_user'); + return $c->status(403) if $user->id eq $c->stash('user_id'); + + my $rs = $c->stash('organization_rs') + ->search_related('user_organization_roles', { user_id => $user->id }); + return $c->status(204) if not $rs->exists; + + return $c->status(409, { error => 'organizations must have an admin' }) + if $rs->search({ role => 'admin' })->exists + and $c->stash('organization_rs') + ->search_related('user_organization_roles', { role => 'admin' })->count == 1; + + $c->log->info('removing user '.$user->id.' ('.$user->name.') from organization '.$c->stash('organization_id_or_name')); + my $deleted = $rs->delete; + + if ($deleted > 0 and $params->{send_mail} // 1) { + my $organization_name = $c->stash('organization_name') // $c->stash('organization_rs')->get_column('name')->single; + $c->send_mail( + template_file => 'organization_user_remove_user', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch organizations have been updated', + organization => $organization_name, + ); + my @admins = $c->stash('organization_rs')->admins('with_sysadmins'); + $c->send_mail( + template_file => 'organization_user_remove_admins', + To => $c->construct_address_list(@admins), + From => 'noreply@conch.joyent.us', + Subject => 'We removed a user from your organization', + organization => $organization_name, + ) if @admins; + } + + return $c->status(204); +} + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/Controller/User.pm b/lib/Conch/Controller/User.pm index 017632599..b14d07d14 100644 --- a/lib/Conch/Controller/User.pm +++ b/lib/Conch/Controller/User.pm @@ -469,7 +469,7 @@ Optionally takes a query parameter C (defaulting to true), to also session tokens for the user, which would force all tools to log in again should the account be reactivated (for which there is no api endpoint at present). -All user_workspace_role entries are removed and are not recoverable. +All memberships in workspaces and organizations are removed and are not recoverable. Response uses the UserError json schema on some error conditions. @@ -488,14 +488,38 @@ sub deactivate ($c) { }); } + # do not allow removing user if he is the only admin of an organization + my $org_admins_rs = $c->db_organizations->correlate('user_organization_roles')->search({ role => 'admin' }); + my $org_rs = $c->db_organizations->search( + { user_id => $user->id, role => 'admin' }, + { + '+select' => [{ '' => $org_admins_rs->count_rs->as_query, -as => 'admin_count' }], + join => 'user_organization_roles', + }, + ) + ->as_subselect_rs + ->search({ admin_count => 1 }); + + if (my $organization = $org_rs->rows(1)->one_row) { + return $c->status(409, { + error => 'user is the only admin of the "'.$organization->name.'" organization ('.$organization->id.')', + user => { map +($_ => $user->$_), qw(id email name created deactivated) }, + }); + } + + my $organizations = join(', ', map $_->{organization}{name}.' ('.$_->{role}.')', + $user->search_related('user_organization_roles', undef, { join => 'organization' }) + ->columns([ qw(organization.name role) ])->hri->all); my $workspaces = join(', ', map $_->{workspace}{name}.' ('.$_->{role}.')', $user->search_related('user_workspace_roles', undef, { join => 'workspace' }) ->columns([ qw(workspace.name role) ])->hri->all); $c->log->warn('user '.$c->stash('user')->name.' deactivating user '.$user->name + .($organizations ? ', member of organizations: '.$organizations : '') .($workspaces ? ', direct member of workspaces: '.$workspaces : '')); $user->update({ password => Authen::Passphrase::RejectAll->new, deactivated => \'now()' }); + $user->delete_related('user_organization_roles'); $user->delete_related('user_workspace_roles'); if ($params->{clear_tokens} // 1) { diff --git a/lib/Conch/DB/Result/Organization.pm b/lib/Conch/DB/Result/Organization.pm index cd2bd5365..25803054c 100644 --- a/lib/Conch/DB/Result/Organization.pm +++ b/lib/Conch/DB/Result/Organization.pm @@ -150,6 +150,61 @@ __PACKAGE__->many_to_many("workspaces", "organization_workspace_roles", "workspa # Created by DBIx::Class::Schema::Loader v0.07049 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VfC1BoN/FWn5FjaRHSj03Q +__PACKAGE__->add_columns( + '+deactivated' => { is_serializable => 0 }, +); + +use experimental 'signatures'; + +=head1 METHODS + +=head2 TO_JSON + +Include information about the organization's admins and workspaces. + +=cut + +sub TO_JSON ($self) { + my $data = $self->next::method(@_); + + $data->{admins} = [ + map { + my ($user) = $_->related_resultset('user_account')->get_cache->@*; + +{ map +($_ => $user->$_), qw(id name email) }; + } + $self->related_resultset('user_organization_roles')->get_cache->@* + ]; + + # add workspace data (very similar to Conch::DB::Result::UserAccount::TO_JSON) + my $cached_owrs = $self->related_resultset('organization_workspace_roles')->get_cache; + my %seen_workspaces; + $data->{workspaces} = [ + # we process the direct owr+workspace entries first so we do not produce redundant rows + (map { + my $workspace = $_->workspace; + ++$seen_workspaces{$workspace->id}; + +{ + $workspace->TO_JSON->%*, + role => $_->role, + }, + } $cached_owrs->@*), + + (map +( + map +( + # $_ is a workspace where the organization inherits a role + $seen_workspaces{$_->id} ? () : do { + ++$seen_workspaces{$_->id}; + # instruct the workspace serializer to fill in the role fields + $_->organization_id_for_role($self->id); + $_->TO_JSON + } + ), $self->result_source->schema->resultset('workspace') + ->workspaces_beneath($_->workspace_id) + ), $cached_owrs->@*), + ]; + + return $data; +} 1; __END__ diff --git a/lib/Conch/DB/Result/Workspace.pm b/lib/Conch/DB/Result/Workspace.pm index 9d3143a0b..a19619670 100644 --- a/lib/Conch/DB/Result/Workspace.pm +++ b/lib/Conch/DB/Result/Workspace.pm @@ -252,11 +252,16 @@ Accessor for informational column, which is by the serializer in the result data =head2 user_id_for_role Accessor for informational column, which is used by the serializer to signal we should fetch -and include inherited role data. +and include inherited role data for the user. + +=head2 organization_id_for_role + +Accessor for informational column, which is used by the serializer to signal we should fetch +and include inherited role data for the organization. =cut -foreach my $column (qw(role user_id_for_role)) { +foreach my $column (qw(role user_id_for_role organization_id_for_role)) { Sub::Install::install_sub({ as => $column, code => sub { diff --git a/lib/Conch/DB/ResultSet/Organization.pm b/lib/Conch/DB/ResultSet/Organization.pm new file mode 100644 index 000000000..8128c690e --- /dev/null +++ b/lib/Conch/DB/ResultSet/Organization.pm @@ -0,0 +1,52 @@ +package Conch::DB::ResultSet::Organization; +use v5.26; +use warnings; +use parent 'Conch::DB::ResultSet'; + +use experimental 'signatures'; + +=head1 NAME + +Conch::DB::ResultSet::Organization + +=head1 DESCRIPTION + +Interface to queries involving organizations. + +=head1 METHODS + +=head2 admins + +All the 'admin' users for the provided organization(s). Pass a true argument to also include all +system admin users in the result. + +=cut + +sub admins ($self, $include_sysadmins = undef) { + my $rs = $self->search_related('user_organization_roles', { role => 'admin' }) + ->related_resultset('user_account'); + + $rs = $rs->union_all($self->result_source->schema->resultset('user_account')->search_rs({ is_admin => 1 })) + if $include_sysadmins; + + return $rs + ->active + ->distinct + ->order_by('user_account.name'); +} + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/Route.pm b/lib/Conch/Route.pm index 0406fa849..5a23e0ecc 100644 --- a/lib/Conch/Route.pm +++ b/lib/Conch/Route.pm @@ -16,6 +16,7 @@ use Conch::Route::RackRole; use Conch::Route::Rack; use Conch::Route::RackLayout; use Conch::Route::HardwareVendor; +use Conch::Route::Organization; =pod @@ -101,6 +102,7 @@ sub all_routes ( Conch::Route::Rack->routes($secured->any('/rack')); Conch::Route::RackLayout->routes($secured->any('/layout')); Conch::Route::HardwareVendor->routes($secured->any('/hardware_vendor')); + Conch::Route::Organization->routes($secured->any('/organization')); $root->any('/*all', sub ($c) { $c->log->error('no endpoint found for: '.$c->req->method.' '.$c->req->url->path); @@ -231,6 +233,10 @@ See L See L +=head3 C<* /organization> + +See L + =head3 C<* /relay> See L diff --git a/lib/Conch/Route/Organization.pm b/lib/Conch/Route/Organization.pm new file mode 100644 index 000000000..19e79e2d5 --- /dev/null +++ b/lib/Conch/Route/Organization.pm @@ -0,0 +1,149 @@ +package Conch::Route::Organization; + +use Mojo::Base -strict, -signatures; + +=pod + +=head1 NAME + +Conch::Route::Organization + +=head1 METHODS + +=head2 routes + +Sets up the routes for /organization. + +=cut + +sub routes { + my $class = shift; + my $organization = shift; # secured, under /organization + + $organization->to({ controller => 'organization' }); + + # GET /organization + $organization->get('/')->to('#list'); + + # POST /organization + $organization->require_system_admin->post('/')->to('#create'); + + { + # chainable action that extracts and looks up organization_id from the path + # and performs basic role checking for the organization + my $with_organization = $organization->under('/:organization_id_or_name') + ->to('#find_organization'); + + # GET /organization/:organization_id_or_name + $with_organization->get('/')->to('#get'); + + # DELETE /organization/:organization_id_or_name + $with_organization->require_system_admin->delete('/')->to('#delete'); + + # GET /organization/:organization_id_or_name/user + $with_organization->get('/user')->to('#list_users'); + + # POST /organization/:organization_id_or_name/user?send_mail=<1|0> + $with_organization->post('/user')->to('#add_user'); + + # DELETE /organization/:organization_id_or_name/user/#target_user_id_or_email?send_mail=<1|0> + $with_organization->under('/user/#target_user_id_or_email')->to('user#find_user') + ->delete('/')->to('organization#remove_user'); + } +} + +1; +__END__ + +=pod + +Unless otherwise noted, all routes require authentication. + +=head3 C + +=over 4 + +=item * Response: response.yaml#/Organizations + +=back + +=head3 C + +=over 4 + +=item * Requires system admin authorization + +=item * Request: request.yaml#/OrganizationCreate + +=item * Response: Redirect to the organization + +=back + +=head3 C + +=over 4 + +=item * Requires system admin authorization or the admin role on the organization + +=item * Response: response.yaml#/Organization + +=back + +=head3 C + +=over 4 + +=item * Requires system admin authorization + +=item * Response: C<204 NO CONTENT> + +=back + +=head3 C + +=over 4 + +=item * Requires system admin authorization or the admin role on the organization + +=item * Response: response.yaml#/OrganizationUsers + +=back + +=head3 C> + +Takes one optional query parameter C<< send_mail=<1|0> >> (defaults to 1) to send +an email to the user. + +=over 4 + +=item * Requires system admin authorization or the admin role on the organization + +=item * Request: request.yaml#/OrganizationAddUser + +=item * Response: C<204 NO CONTENT> + +=back + +=head3 C> + +Takes one optional query parameter C<< send_mail=<1|0> >> (defaults to 1) to send +an email to the user. + +=over 4 + +=item * Requires system admin authorization or the admin role on the organization + +=item * Returns C<204 NO CONTENT> + +=back + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/t/integration/crud/organization.t b/t/integration/crud/organization.t new file mode 100644 index 000000000..e4683ed16 --- /dev/null +++ b/t/integration/crud/organization.t @@ -0,0 +1,399 @@ +use strict; +use warnings; +use Test::More; +use Test::Warnings; +use Test::Deep; +use Test::Conch; +use Conch::UUID 'create_uuid_str'; + +my $t = Test::Conch->new; +my $super_user = $t->load_fixture('super_user'); + +$t->authenticate; + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([]); + +$t->post_ok('/organization', json => { name => $_, admins => [ { user_id => create_uuid_str() } ] }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/name', message => re(qr/does not match/i) } ]) + foreach '', 'foo/bar', 'foo.bar'; + +$t->post_ok('/organization', json => { name => 'my first organization', admins => [ {} ] }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', bag(map +{ path => '/admins/0/'.$_, message => re(qr/missing property/i) }, qw(user_id email))); + +$t->post_ok('/organization', json => { name => 'my first organization' }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/admins', message => re(qr/missing property/i) } ] ); + +$t->post_ok('/organization', json => { + name => 'my first organization', + admins => [ { user_id => create_uuid_str(), email => 'foo@bar.com' } ], + }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/admins/0', message => re(qr/all of the oneof rules/i) } ] ); + +$t->post_ok('/organization', json => { name => 'my first organization', admins => [ { user_id => create_uuid_str() } ] }) + ->status_is(409) + ->json_cmp_deeply({ error => re(qr/^unrecognized user_id ${\Conch::UUID::UUID_FORMAT}$/) }); + +$t->post_ok('/organization', json => { name => 'my first organization', admins => [ { email => 'foo@bar.com' } ] }) + ->status_is(409) + ->json_is({ error => 'unrecognized email foo@bar.com' }); + +$t->post_ok('/organization', json => { + name => 'my first organization', + admins => [ { user_id => create_uuid_str() }, { email => 'foo@bar.com' } ], + }) + ->status_is(409) + ->json_cmp_deeply({ error => re(qr/^unrecognized user_id ${\Conch::UUID::UUID_FORMAT}, email foo\@bar.com$/) }); + +my $admin_user = $t->generate_fixtures('user_account'); +$t->post_ok('/organization', json => { name => 'my first organization', admins => [ { user_id => $admin_user->id } ] }) + ->status_is(303) + ->location_like(qr!^/organization/${\Conch::UUID::UUID_FORMAT}!) + ->log_info_like(qr/^created organization ${\Conch::UUID::UUID_FORMAT} \(my first organization\)$/); + +$t->get_ok($t->tx->res->headers->location) + ->status_is(200) + ->json_schema_is('Organization') + ->json_cmp_deeply({ + id => re(Conch::UUID::UUID_FORMAT), + name => 'my first organization', + description => undef, + created => re(qr/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3,9}Z$/), + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + ], + workspaces => [], + }); +my $organization = $t->tx->res->json; + +$t->get_ok('/organization/my first organization') + ->status_is(200) + ->json_schema_is('Organization') + ->json_is($organization); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization ]); + +$t->delete_ok('/organization/my first organization/user/'.$admin_user->email) + ->status_is(409) + ->json_is({ error => 'organizations must have an admin' }); + +$t->post_ok('/organization', json => { name => 'my first organization', admins => [ { email => $admin_user->email } ] }) + ->status_is(409) + ->json_is({ error => 'an organization already exists with that name' }); + +$t->post_ok('/organization', json => { name => 'our second organization', description => 'funky', admins => [ { email => $admin_user->email } ] }) + ->status_is(303) + ->location_like(qr!^/organization/${\Conch::UUID::UUID_FORMAT}!) + ->log_info_like(qr/^created organization ${\Conch::UUID::UUID_FORMAT} \(our second organization\)$/); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_cmp_deeply([ + $organization, + { + id => re(Conch::UUID::UUID_FORMAT), + name => 'our second organization', + description => 'funky', + created => re(qr/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3,9}Z$/), + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + ], + workspaces => [], + }, + ]); +my $organization2 = $t->tx->res->json->[1]; + +my $new_user = $t->generate_fixtures('user_account'); + +my $t2 = Test::Conch->new(pg => $t->pg); +$t2->authenticate(email => $new_user->email); + +$t2->post_ok('/organization', json => { name => 'another organization' }) + ->status_is(403); + +$t2->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([]); + +$t2->get_ok('/organization/'.$organization->{id}) + ->status_is(403); + +$t2->get_ok('/organization/my first organization') + ->status_is(403); + +$t2->delete_ok('/organization/foo') + ->status_is(404); + +$t2->delete_ok('/organization/my first organization') + ->status_is(403); + + +$t->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + ]); + +$t->post_ok('/organization/'.$organization->{id}.'/user', json => { role => 'ro' }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', bag(map +{ path => $_, message => re(qr/missing property/i) }, qw(/user_id /email))); + +$t->post_ok('/organization/my first organization/user', json => { email => $new_user->email }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/role', message => re(qr/missing property/i) } ]); + +$t->post_ok('/organization/my first organization/user', json => { + email => $new_user->email, + role => 'ro', + }) + ->status_is(204) + ->log_info_is('Added user '.$new_user->id.' ('.$new_user->name.') to organization my first organization with the ro role') + ->email_cmp_deeply([ + { + To => '"'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^You have been added to the "my first organization" organization at Joyent Conch with the "ro" role\./m), + }, + { + To => '"'.${\$super_user->name}.'" <'.${\$super_user->email}.'>, "'.${\$admin_user->name}.'" <'.${\$admin_user->email}.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We added a user to your organization', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) added ${\$new_user->name} \(${\$new_user->email}\) to the\R"my first organization" organization at Joyent Conch with the "ro" role\./m), + }, + ]); + +$t->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'ro' }, + ]); + +# non-admin user can only see the organization(s) he is a member of +$t2->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization ]); + +$t->get_ok('/organization/my first organization') + ->status_is(200) + ->json_schema_is('Organization') + ->json_is($organization); + +$t2->delete_ok('/organization/my first organization') + ->status_is(403); + +$t2->get_ok('/organization/my first organization/user') + ->status_is(403); + +my $new_user2 = $t->generate_fixtures('user_account'); +$t2->post_ok('/organization/'.$organization->{id}.'/user', json => { + email => $new_user2->email, + role => 'ro', + }) + ->status_is(403); + +$t2->delete_ok('/organization/my first organization/user/'.$new_user->email) + ->status_is(403); + + +$t->post_ok('/organization/my first organization/user', json => { + email => $new_user->email, + role => 'rw', + }) + ->status_is(204) + ->log_info_is('Updated access for user '.$new_user->id.' ('.$new_user->name.') in organization my first organization to the rw role') + ->email_cmp_deeply([ + { + To => '"'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^Your access to the "my first organization" organization at Joyent Conch has been adjusted to "rw"\./m), + }, + { + To => '"'.${\$super_user->name}.'" <'.${\$super_user->email}.'>, "'.${\$admin_user->name}.'" <'.${\$admin_user->email}.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We modified a user\'s access to your organization', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) modified a user's access to your organization "my first organization" at Joyent Conch\.\R${\$new_user->name} \(${\$new_user->email}\) now has the "rw" role\./m), + }, + ]); + +$t->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'rw' }, + ]); + +$t2->get_ok('/organization/my first organization/user') + ->status_is(403); + +$t2->post_ok('/organization/'.$organization->{id}.'/user', json => { + email => $new_user2->email, + role => 'ro', + }) + ->status_is(403); + +$t2->delete_ok('/organization/my first organization/user/'.$new_user->email) + ->status_is(403); + + +$t->post_ok('/organization/'.$organization->{id}.'/user', json => { + email => $new_user->email, + role => 'admin', + }) + ->status_is(204) + ->log_info_is('Updated access for user '.$new_user->id.' ('.$new_user->name.') in organization '.$organization->{id}.' to the admin role') + ->email_cmp_deeply([ + { + To => '"'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^Your access to the "my first organization" organization at Joyent Conch has been adjusted to "admin"\./m), + }, + { + To => '"'.${\$super_user->name}.'" <'.${\$super_user->email}.'>, "'.${\$admin_user->name}.'" <'.${\$admin_user->email}.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We modified a user\'s access to your organization', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) modified a user's access to your organization "my first organization" at Joyent Conch\.\R${\$new_user->name} \(${\$new_user->email}\) now has the "admin" role\./m), + } + ]); + +$t->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'admin' }, + ]); +push $organization->{admins}->@*, +{ $t->tx->res->json->[1]->%{qw(id name email)} }; + +$t2->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'admin' }, + ]); + +$t2->post_ok('/organization/'.$organization->{id}.'/user', json => { + email => $new_user2->email, + role => 'ro', + }) + ->status_is(204) + ->log_info_is('Added user '.$new_user2->id.' ('.$new_user2->name.') to organization '.$organization->{id}.' with the ro role') + ->email_cmp_deeply([ + { + To => '"'.$new_user2->name.'" <'.$new_user2->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^You have been added to the "my first organization" organization at Joyent Conch with the "ro" role\./m), + }, + { + To => '"'.${\$super_user->name}.'" <'.${\$super_user->email}.'>, "'.${\$admin_user->name}.'" <'.${\$admin_user->email}.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We added a user to your organization', + body => re(qr/^${\$new_user->name} \(${\$new_user->email}\) added ${\$new_user2->name} \(${\$new_user2->email}\) to the\R"my first organization" organization at Joyent Conch with the "ro" role\./m), + }, + ]); + +$t2->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user2->$_), qw(id name email)), role => 'ro' }, + ]); + +$t2->delete_ok('/organization/my first organization/user/'.$new_user2->email) + ->status_is(204) + ->log_info_is('removing user '.$new_user2->id.' ('.$new_user2->name.') from organization my first organization') + ->email_cmp_deeply([ + { + To => '"'.$new_user2->name.'" <'.$new_user2->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch organizations have been updated', + body => re(qr/^You have been removed from the "my first organization" organization at Joyent Conch\./m), + }, + { + To => '"'.${\$super_user->name}.'" <'.${\$super_user->email}.'>, "'.${\$admin_user->name}.'" <'.${\$admin_user->email}.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We removed a user from your organization', + body => re(qr/^${\$new_user->name} \(${\$new_user->email}\) removed ${\$new_user2->name} \(${\$new_user2->email}\) from the\R"my first organization" organization at Joyent Conch\./m), + }, + ]); + +$t2->get_ok('/organization/my first organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + { (map +($_ => $new_user->$_), qw(id name email)), role => 'admin' }, + ]); + +$admin_user->discard_changes; +$t->delete_ok('/user/'.$admin_user->id) + ->status_is(409) + ->json_is({ + error => 'user is the only admin of the "our second organization" organization ('.$organization2->{id}.')', + user => { map +($_ => $admin_user->$_), qw(id email name created deactivated) }, + }); + +$t2->delete_ok('/organization/my first organization/user/'.$new_user->email) + ->status_is(403); + + +$t->delete_ok('/organization/my first organization/user/foo@bar.com') + ->status_is(404); + +$t->get_ok('/organization/our second organization/user') + ->status_is(200) + ->json_schema_is('OrganizationUsers') + ->json_is([ + { (map +($_ => $admin_user->$_), qw(id name email)), role => 'admin' }, + ]); + +$t->delete_ok('/organization/foo') + ->status_is(404); + +$t->delete_ok('/organization/our second organization') + ->status_is(204) + ->log_debug_is('Deactivated organization our second organization, removing 1 user memberships and removing from 0 workspaces'); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization ]); + +$t->delete_ok('/organization/my first organization') + ->status_is(204) + ->log_debug_is('Deactivated organization my first organization, removing 2 user memberships and removing from 0 workspaces'); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([]); + +done_testing; diff --git a/templates/email/organization_user_add_admins.txt.ep b/templates/email/organization_user_add_admins.txt.ep new file mode 100644 index 000000000..e0d8e047a --- /dev/null +++ b/templates/email/organization_user_add_admins.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) added <%= $target_user->name %> (<%= $target_user->email %>) to the +"<%= $organization %>" organization at Joyent Conch with the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/organization_user_add_user.txt.ep b/templates/email/organization_user_add_user.txt.ep new file mode 100644 index 000000000..79b1fe47e --- /dev/null +++ b/templates/email/organization_user_add_user.txt.ep @@ -0,0 +1,7 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +You have been added to the "<%= $organization %>" organization at Joyent Conch with the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/organization_user_remove_admins.txt.ep b/templates/email/organization_user_remove_admins.txt.ep new file mode 100644 index 000000000..bce42ae5c --- /dev/null +++ b/templates/email/organization_user_remove_admins.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) removed <%= $target_user->name %> (<%= $target_user->email %>) from the +"<%= $organization %>" organization at Joyent Conch. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/organization_user_remove_user.txt.ep b/templates/email/organization_user_remove_user.txt.ep new file mode 100644 index 000000000..cf5c94d95 --- /dev/null +++ b/templates/email/organization_user_remove_user.txt.ep @@ -0,0 +1,7 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +You have been removed from the "<%= $organization %>" organization at Joyent Conch. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/organization_user_update_admins.txt.ep b/templates/email/organization_user_update_admins.txt.ep new file mode 100644 index 000000000..532219b5f --- /dev/null +++ b/templates/email/organization_user_update_admins.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) modified a user's access to your organization "<%= $organization %>" at Joyent Conch. +<%= $target_user->name %> (<%= $target_user->email %>) now has the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/organization_user_update_user.txt.ep b/templates/email/organization_user_update_user.txt.ep new file mode 100644 index 000000000..29847a4bb --- /dev/null +++ b/templates/email/organization_user_update_user.txt.ep @@ -0,0 +1,7 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +Your access to the "<%= $organization %>" organization at Joyent Conch has been adjusted to "<%= $role %>". + +Thank you, +Joyent Build Ops Team From 9b8dba479c8ee1c22ae7a2d56d2420349b45d08d Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Tue, 23 Jul 2019 16:35:07 -0700 Subject: [PATCH 6/8] add organizations to UserDetailed GET /user GET /user/me GET /user/:target_user --- .../modules/Conch::DB::Result::UserAccount.md | 2 +- json-schema/response.yaml | 23 +++++++++++++++++++ lib/Conch/Controller/User.pm | 20 ++++++++++------ lib/Conch/DB/Result/UserAccount.pm | 23 +++++++++++++++---- lib/Test/Conch/Fixtures.pm | 15 ++++++++++++ t/integration/users.t | 7 ++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docs/modules/Conch::DB::Result::UserAccount.md b/docs/modules/Conch::DB::Result::UserAccount.md index 421d34f2a..12dce747f 100644 --- a/docs/modules/Conch::DB::Result::UserAccount.md +++ b/docs/modules/Conch::DB::Result::UserAccount.md @@ -155,7 +155,7 @@ Because hard cryptography is used, this is \*not\* a fast call! ## TO\_JSON -Include information about the user's workspaces, if available. +Include information about the user's workspaces and organizations, if available. # LICENSING diff --git a/json-schema/response.yaml b/json-schema/response.yaml index c5c905501..34e3ca1cd 100644 --- a/json-schema/response.yaml +++ b/json-schema/response.yaml @@ -1489,6 +1489,7 @@ definitions: - force_password_change - is_admin - workspaces + - organizations properties: id: $ref: common.yaml#/definitions/uuid @@ -1517,6 +1518,28 @@ definitions: type: boolean workspaces: $ref: /definitions/WorkspacesAndRoles + organizations: + type: array + uniqueItems: true + items: + type: object + additionalProperties: false + required: + - id + - name + - description + - role + properties: + id: + $ref: common.yaml#/definitions/uuid + name: + $ref: common.yaml#/definitions/mojo_relaxed_placeholder + description: + oneOf: + - type: 'null' + - type: string + role: + $ref: common.yaml#/definitions/role UsersDetailed: type: array uniqueItems: true diff --git a/lib/Conch/Controller/User.pm b/lib/Conch/Controller/User.pm index b14d07d14..8a49b02d4 100644 --- a/lib/Conch/Controller/User.pm +++ b/lib/Conch/Controller/User.pm @@ -326,11 +326,14 @@ Response uses the UserDetailed json schema. =cut sub get ($c) { - my $user = $c->stash('target_user') - ->discard_changes({ - prefetch => { user_workspace_roles => 'workspace' }, - order_by => ['workspace.name'], - }); + my ($user) = $c->db_user_accounts + ->search({ 'user_account.id' => $c->stash('target_user')->id }) + ->prefetch({ + user_workspace_roles => 'workspace', + user_organization_roles => 'organization', + }) + ->order_by([qw(workspace.name organization.name)]) + ->all; return $c->status(200, $user) if $c->is_system_admin; @@ -406,8 +409,11 @@ Response uses the UsersDetailed json schema. sub list ($c) { my $user_rs = $c->db_user_accounts ->active - ->prefetch({ user_workspace_roles => 'workspace' }) - ->order_by([qw(user_account.name workspace.name)]); + ->prefetch({ + user_workspace_roles => 'workspace', + user_organization_roles => 'organization', + }) + ->order_by([qw(user_account.name workspace.name organization.name)]); return $c->status(200, [ $user_rs->all ]); } diff --git a/lib/Conch/DB/Result/UserAccount.pm b/lib/Conch/DB/Result/UserAccount.pm index 1c0a038e2..c93d78736 100644 --- a/lib/Conch/DB/Result/UserAccount.pm +++ b/lib/Conch/DB/Result/UserAccount.pm @@ -278,7 +278,7 @@ Because hard cryptography is used, this is *not* a fast call! =head2 TO_JSON -Include information about the user's workspaces, if available. +Include information about the user's workspaces and organizations, if available. =cut @@ -288,8 +288,22 @@ sub TO_JSON ($self) { # Mojo::JSON renders \0, \1 as json booleans $data->{$_} = \(0+$data->{$_}) for qw(refuse_session_auth force_password_change is_admin); - # add workspace data, if it has been prefetched - if (my $cached_uwrs = $self->related_resultset('user_workspace_roles')->get_cache) { + # add organization and workspace data, if they have been prefetched + # (we expect neither or both) + # (see also Conch::DB::Result::Organization::TO_JSON) + if (my $cached_uwrs = $self->related_resultset('user_workspace_roles')->get_cache + and my $cached_uors = $self->related_resultset('user_organization_roles')->get_cache) { + + $data->{organizations} = [ + map { + my $organization = $_->organization; + +{ + (map +($_ => $organization->$_), qw(id name description)), + role => $_->role, + } + } $cached_uors->@*, + ]; + my %seen_workspaces; $data->{workspaces} = [ # we process the direct uwr+workspace entries first so we do not produce redundant rows @@ -298,9 +312,10 @@ sub TO_JSON ($self) { +{ $_->workspace->TO_JSON->%*, role => $_->role, - }, + } } $cached_uwrs->@*), + # all the workspaces the user can reach indirectly (map +( map +( # $_ is a workspace where the user inherits a role diff --git a/lib/Test/Conch/Fixtures.pm b/lib/Test/Conch/Fixtures.pm index 56db4acc2..18bf17360 100644 --- a/lib/Test/Conch/Fixtures.pm +++ b/lib/Test/Conch/Fixtures.pm @@ -130,6 +130,21 @@ my %canned_definitions = ( }, }, + ro_user_organization => { + new => 'user_organization_role', + using => { role => 'admin' }, + requires => { + ro_user => { our => 'user_id', their => 'id' }, + main_organization => { our => 'organization_id', their => 'id' }, + }, + }, + main_organization => { + new => 'organization', + using => { + name => 'our first organization', + }, + }, + hardware_vendor_0 => { new => 'hardware_vendor', using => { diff --git a/t/integration/users.t b/t/integration/users.t index 45bbc3df3..6c914a230 100644 --- a/t/integration/users.t +++ b/t/integration/users.t @@ -19,6 +19,7 @@ my $super_user = $t->load_fixture('super_user'); my $ro_user = $t->load_fixture('ro_user'); my $global_ws = $t->load_fixture('global_workspace'); my $child_ws = $global_ws->create_related('workspaces', { name => 'child_ws', user_workspace_roles => [{ role => 'ro', user_id => $ro_user->id }] }); +my $organization = $t->load_fixture('ro_user_organization')->organization; $t->post_ok('/login', json => { email => 'a', password => 'b' }) ->status_is(400) @@ -176,6 +177,10 @@ subtest 'User' => sub { refuse_session_auth => JSON::PP::false, force_password_change => JSON::PP::false, is_admin => JSON::PP::false, + organizations => [ { + (map +($_ => $organization->$_), qw(id name description)), + role => 'admin', + } ], workspaces => [ { (map +($_ => $child_ws->$_), qw(id name description)), parent_workspace_id => undef, # user does not have the role to see GLOBAL @@ -203,6 +208,7 @@ subtest 'User' => sub { refuse_session_auth => JSON::PP::false, force_password_change => JSON::PP::false, is_admin => JSON::PP::true, + organizations => [], workspaces => [], }); $super_user_data = $t_super->tx->res->json; @@ -486,6 +492,7 @@ subtest 'modify another user' => sub { refuse_session_auth => JSON::PP::false, force_password_change => JSON::PP::false, is_admin => JSON::PP::false, + organizations => [], workspaces => [], }, 'returned all the right fields (and not the password)'); From ef519aaa802c6f87f7272af5928a36e87c11d42c Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Tue, 9 Jul 2019 16:53:17 -0700 Subject: [PATCH 7/8] make workspaces and organizations aware of each other - added endpoints: - GET /workspace/:workspace_id_or_name/organization - POST /workspace/:workspace_id_or_name/organization?send_mail=<1|0> - DELETE /workspace/:workspace_id_or_name/organization/:organization_id_or_name?send_mail=<1|0> - role_via_for_user (for calculating workspace payload data) now returns user_workspace_role or organization_workspace_role row - updated auth checks everywhere given workspace->org perms - GET /organization and and GET/organization/:id drop parent_workspace_id as needed --- .../Conch::Controller::Organization.md | 10 + ...onch::Controller::WorkspaceOrganization.md | 41 ++ .../Conch::DB::Helper::ResultSet::WithRole.md | 28 ++ .../Conch::DB::Helper::Row::WithRole.md | 34 ++ .../Conch::DB::Result::UserWorkspaceRole.md | 8 - ...Conch::DB::ResultSet::UserWorkspaceRole.md | 22 - .../Conch::DB::ResultSet::Workspace.md | 30 +- docs/modules/Conch::Route::Workspace.md | 22 + docs/modules/index.md | 4 +- json-schema/request.yaml | 11 + json-schema/response.yaml | 47 ++- lib/Conch/Controller/Organization.pm | 67 ++- lib/Conch/Controller/Workspace.pm | 14 +- lib/Conch/Controller/WorkspaceOrganization.pm | 265 ++++++++++++ lib/Conch/Controller/WorkspaceUser.pm | 33 +- lib/Conch/DB/Helper/ResultSet/WithRole.pm | 56 +++ lib/Conch/DB/Helper/Row/WithRole.pm | 64 +++ .../DB/Result/OrganizationWorkspaceRole.pm | 1 + lib/Conch/DB/Result/UserOrganizationRole.pm | 1 + lib/Conch/DB/Result/UserWorkspaceRole.pm | 25 +- lib/Conch/DB/Result/Workspace.pm | 16 +- lib/Conch/DB/ResultSet.pm | 1 + lib/Conch/DB/ResultSet/UserWorkspaceRole.pm | 49 --- lib/Conch/DB/ResultSet/Workspace.pm | 107 ++++- lib/Conch/Route/Workspace.pm | 49 +++ lib/Test/Conch/Fixtures.pm | 22 + t/integration/crud/devices.t | 30 ++ t/integration/crud/organization.t | 396 +++++++++++++++++- t/integration/crud/workspace.t | 30 +- t/workspace-role.t | 173 +++++++- .../workspace_organization_add_admins.txt.ep | 9 + .../workspace_organization_add_members.txt.ep | 8 + ...orkspace_organization_remove_admins.txt.ep | 8 + ...rkspace_organization_remove_members.txt.ep | 8 + ...orkspace_organization_update_admins.txt.ep | 9 + ...rkspace_organization_update_members.txt.ep | 8 + 36 files changed, 1538 insertions(+), 168 deletions(-) create mode 100644 docs/modules/Conch::Controller::WorkspaceOrganization.md create mode 100644 docs/modules/Conch::DB::Helper::ResultSet::WithRole.md create mode 100644 docs/modules/Conch::DB::Helper::Row::WithRole.md delete mode 100644 docs/modules/Conch::DB::ResultSet::UserWorkspaceRole.md create mode 100644 lib/Conch/Controller/WorkspaceOrganization.pm create mode 100644 lib/Conch/DB/Helper/ResultSet/WithRole.pm create mode 100644 lib/Conch/DB/Helper/Row/WithRole.pm delete mode 100644 lib/Conch/DB/ResultSet/UserWorkspaceRole.pm create mode 100644 templates/email/workspace_organization_add_admins.txt.ep create mode 100644 templates/email/workspace_organization_add_members.txt.ep create mode 100644 templates/email/workspace_organization_remove_admins.txt.ep create mode 100644 templates/email/workspace_organization_remove_members.txt.ep create mode 100644 templates/email/workspace_organization_update_admins.txt.ep create mode 100644 templates/email/workspace_organization_update_members.txt.ep diff --git a/docs/modules/Conch::Controller::Organization.md b/docs/modules/Conch::Controller::Organization.md index 0d90b3f5b..b7e4c64c2 100644 --- a/docs/modules/Conch::Controller::Organization.md +++ b/docs/modules/Conch::Controller::Organization.md @@ -9,6 +9,11 @@ Conch::Controller::Organization If the user is a system admin, retrieve a list of all active organizations in the database; otherwise, limits the list to those organizations of which the user is a member. +Note: the only workspaces and roles listed are those reachable via the organization, even if +the user might have direct access to the workspace at a greater role. For comprehensive +information about what workspaces the user can access, and at what role, please use `GET +/workspace` or `GET /user/me`. + Response uses the Organizations json schema. ## create @@ -29,6 +34,11 @@ Requires the 'admin' role on the organization (or the user to be a system admin) Get the details of a single organization. Requires the 'admin' role on the organization. +Note: the only workspaces and roles listed are those reachable via the organization, even if +the user might have direct access to the workspace at a greater role. For comprehensive +information about what workspaces the user can access, and at what role, please use +`GET /workspace` or `GET /user/me`. + Response uses the Organization json schema. ## delete diff --git a/docs/modules/Conch::Controller::WorkspaceOrganization.md b/docs/modules/Conch::Controller::WorkspaceOrganization.md new file mode 100644 index 000000000..8c004ec6d --- /dev/null +++ b/docs/modules/Conch::Controller::WorkspaceOrganization.md @@ -0,0 +1,41 @@ +# NAME + +Conch::Controller::WorkspaceOrganization + +# METHODS + +## list\_workspace\_organizations + +Get a list of organizations for the current workspace. +Requires the 'admin' role on the workspace. + +Response uses the WorkspaceOrganizations json schema. + +## add\_workspace\_organization + +Adds a organization to the current workspace, or upgrades an existing role entry to access the +workspace. +Requires the 'admin' role on the workspace. + +Optionally takes a query parameter `send_mail` (defaulting to true), to send an email +to all organization members and all workspace admins. + +## remove\_workspace\_organization + +Removes the indicated organization from the workspace, as well as all sub-workspaces. +Requires the 'admin' role on the workspace. + +Note this may not have the desired effect if the organization is getting access to the +workspace via a parent workspace. When in doubt, check at `GET +/workspace/:workspace_id/organization`. + +Optionally takes a query parameter `send_mail` (defaulting to true), to send an email +to all organization members and to all workspace admins. + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Helper::ResultSet::WithRole.md b/docs/modules/Conch::DB::Helper::ResultSet::WithRole.md new file mode 100644 index 000000000..fd7827ff6 --- /dev/null +++ b/docs/modules/Conch::DB::Helper::ResultSet::WithRole.md @@ -0,0 +1,28 @@ +# NAME + +Conch::DB::Helper::ResultSet::WithRole + +# DESCRIPTION + +A component for [Conch::DB::ResultSet](../modules/Conch::DB::ResultSet) classes for database tables with a `role` +column, to provide common query functionality. + +# USAGE + +``` +__PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::WithRole'); +``` + +# METHODS + +## with\_role + +Constrains the resultset to those rows that grants (at least) the specified role. + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Helper::Row::WithRole.md b/docs/modules/Conch::DB::Helper::Row::WithRole.md new file mode 100644 index 000000000..08c1adf8f --- /dev/null +++ b/docs/modules/Conch::DB::Helper::Row::WithRole.md @@ -0,0 +1,34 @@ +# NAME + +Conch::DB::Helper::Row::WithRole + +# DESCRIPTION + +A component for [Conch::DB::Result](../modules/Conch::DB::Result) classes for database tables with a `role` +column, to provide common functionality. + +# USAGE + +``` +__PACKAGE__->load_components('+Conch::DB::Helper::Row::WithRole'); +``` + +# METHODS + +## role\_cmp + +Acts like the `cmp` operator, returning -1, 0 or 1 depending on whether the first role is less +than, the same as, or greater than the second role. + +If only one role argument is passed, the role in the current row is compared to the passed-in +role. + +Accepts undef for one or both roles, which always compare as less than a defined role. + +# LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::Result::UserWorkspaceRole.md b/docs/modules/Conch::DB::Result::UserWorkspaceRole.md index ca4b5f4b8..dd41bea8a 100644 --- a/docs/modules/Conch::DB::Result::UserWorkspaceRole.md +++ b/docs/modules/Conch::DB::Result::UserWorkspaceRole.md @@ -54,14 +54,6 @@ Type: belongs\_to Related object: [Conch::DB::Result::Workspace](../modules/Conch::DB::Result::Workspace) -## role\_cmp - -Acts like the `cmp` operator, returning -1, 0 or 1 depending on whether the first role is less -than, the same as, or greater than the second role. - -If only one role argument is passed, the role in the current row is compared to the passed-in -role. - # LICENSING Copyright Joyent, Inc. diff --git a/docs/modules/Conch::DB::ResultSet::UserWorkspaceRole.md b/docs/modules/Conch::DB::ResultSet::UserWorkspaceRole.md deleted file mode 100644 index c79abf1c3..000000000 --- a/docs/modules/Conch::DB::ResultSet::UserWorkspaceRole.md +++ /dev/null @@ -1,22 +0,0 @@ -# NAME - -Conch::DB::ResultSet::UserWorkspaceRole - -# DESCRIPTION - -Interface to queries involving user/workspace roles. - -# METHODS - -## with\_role - -Constrains the resultset to those user\_workspace\_role rows that grants (at least) the specified -role. - -# LICENSING - -Copyright Joyent, Inc. - -This Source Code Form is subject to the terms of the Mozilla Public License, -v.2.0. If a copy of the MPL was not distributed with this file, You can obtain -one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). diff --git a/docs/modules/Conch::DB::ResultSet::Workspace.md b/docs/modules/Conch::DB::ResultSet::Workspace.md index f4b4cf728..136554dec 100644 --- a/docs/modules/Conch::DB::ResultSet::Workspace.md +++ b/docs/modules/Conch::DB::ResultSet::Workspace.md @@ -55,8 +55,9 @@ will not appear in the serialized data). This is intended to be used in prefere ## with\_role\_via\_data\_for\_user Query for workspace(s) with an extra field attached to the query which will signal the -workspace serializer to include the "role" and "role\_via" columns, containing information about -the effective role the user has for the workspace. +workspace serializer to include the "role", "role\_via\_workspace\_id" and +"role\_via\_organization\_id" columns, containing information about the effective role the user +has for the workspace. Only one user\_id can be calculated at a time. If you need to generate workspace-and-role data for multiple users at once, you can manually do: @@ -69,9 +70,25 @@ before serializing the workspace object. ## role\_via\_for\_user -For a given workspace\_id and user\_id, find the user\_workspace\_role row that is responsible for -providing the user access to the workspace (the user\_workspace\_role with the greatest -role that is attached to an ancestor workspace). +For a given workspace\_id and user\_id, find the user\_workspace\_role or +organization\_workspace\_role row that is responsible for providing the user access to the +workspace (the row with the greatest role that is attached to an ancestor workspace). + +How the role is calculated: + +- The role on the user\_organization\_role role is **not** used. +- The number of workspaces between `$workspace_id` and the workspace attached to the +user\_workspace\_role or organization\_workspace\_role row is **not** used. +- When both a user\_workspace\_role and organization\_workspace\_role row are found with the same +role, the record directly associated with the workspace (if there is one) is preferred; +otherwise, the user\_workspace\_role row is preferred. + +## role\_via\_for\_organization + +For a given workspace\_id and organization\_id, find the organization\_workspace\_role row that is +responsible for providing the organization access to the workspace (the +organization\_workspace\_role with the greatest role that is attached to an ancestor +workspace). ## admins @@ -90,6 +107,9 @@ Checks that the provided user\_id has (at least) the specified role in at least the resultset. (Does not search recursively; add `->and_workspaces_above($workspace_id)` to your resultset first, if this is what you want.) +Both direct `user_workspace_role` entries and joined +`user_organization_role` -> `organization_workspace_role` entries are checked. + Returns a boolean. ## \_workspaces\_subquery diff --git a/docs/modules/Conch::Route::Workspace.md b/docs/modules/Conch::Route::Workspace.md index edb3a19ef..58501482f 100644 --- a/docs/modules/Conch::Route::Workspace.md +++ b/docs/modules/Conch::Route::Workspace.md @@ -108,6 +108,28 @@ an email to the user and workspace admins. - User requires the admin role - Returns `204 NO CONTENT` +### `GET /workspace/:workspace_id_or_name/organization` + +- User requires the admin role +- Response: response.yaml#/WorkspaceOrganizations + +### `POST /workspace/:workspace_id_or_name/organization?send_mail=<1|0>` + +Takes one optional query parameter `send_mail=<1|0>` (defaults to 1) to send +an email to the organization members and workspace admins. + +- User requires the admin role +- Request: request.yaml#/WorkspaceAddOrganization +- Response: `204 NO CONTENT` + +### `DELETE /workspace/:workspace_id_or_name/organization/:organization_id_or_name?send_mail=<1|0>` + +Takes one optional query parameter `send_mail=<1|0>` (defaults to 1) to send +an email to the organization members and workspace admins. + +- User requires the admin role +- Returns `204 NO CONTENT` + # LICENSING Copyright Joyent, Inc. diff --git a/docs/modules/index.md b/docs/modules/index.md index 07ca5ff30..a2f08db21 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -34,6 +34,7 @@ * [Conch::Controller::ValidationState](../modules/Conch::Controller::ValidationState) * [Conch::Controller::Workspace](../modules/Conch::Controller::Workspace) * [Conch::Controller::WorkspaceDevice](../modules/Conch::Controller::WorkspaceDevice) +* [Conch::Controller::WorkspaceOrganization](../modules/Conch::Controller::WorkspaceOrganization) * [Conch::Controller::WorkspaceRack](../modules/Conch::Controller::WorkspaceRack) * [Conch::Controller::WorkspaceRelay](../modules/Conch::Controller::WorkspaceRelay) * [Conch::Controller::WorkspaceUser](../modules/Conch::Controller::WorkspaceUser) @@ -41,7 +42,9 @@ * [Conch::DB::Helper::ResultSet::AsEpoch](../modules/Conch::DB::Helper::ResultSet::AsEpoch) * [Conch::DB::Helper::ResultSet::Deactivatable](../modules/Conch::DB::Helper::ResultSet::Deactivatable) * [Conch::DB::Helper::ResultSet::ResultsExist](../modules/Conch::DB::Helper::ResultSet::ResultsExist) +* [Conch::DB::Helper::ResultSet::WithRole](../modules/Conch::DB::Helper::ResultSet::WithRole) * [Conch::DB::Helper::Row::ToJSON](../modules/Conch::DB::Helper::Row::ToJSON) +* [Conch::DB::Helper::Row::WithRole](../modules/Conch::DB::Helper::Row::WithRole) * [Conch::DB::InflateColumn::Time](../modules/Conch::DB::InflateColumn::Time) * [Conch::DB::Result](../modules/Conch::DB::Result) * [Conch::DB::Result::Datacenter](../modules/Conch::DB::Result::Datacenter) @@ -88,7 +91,6 @@ * [Conch::DB::ResultSet::RackLayout](../modules/Conch::DB::ResultSet::RackLayout) * [Conch::DB::ResultSet::UserAccount](../modules/Conch::DB::ResultSet::UserAccount) * [Conch::DB::ResultSet::UserSessionToken](../modules/Conch::DB::ResultSet::UserSessionToken) -* [Conch::DB::ResultSet::UserWorkspaceRole](../modules/Conch::DB::ResultSet::UserWorkspaceRole) * [Conch::DB::ResultSet::ValidationState](../modules/Conch::DB::ResultSet::ValidationState) * [Conch::DB::ResultSet::Workspace](../modules/Conch::DB::ResultSet::Workspace) * [Conch::DB::Util](../modules/Conch::DB::Util) diff --git a/json-schema/request.yaml b/json-schema/request.yaml index 87e00981b..210fb58be 100644 --- a/json-schema/request.yaml +++ b/json-schema/request.yaml @@ -592,5 +592,16 @@ definitions: $ref: common.yaml#/definitions/email_address role: $ref: common.yaml#/definitions/role + WorkspaceAddOrganization: + type: object + additionalProperties: false + required: + - organization_id + - role + properties: + organization_id: + $ref: common.yaml#/definitions/uuid + role: + $ref: common.yaml#/definitions/role # vim: set sts=2 sw=2 et : diff --git a/json-schema/response.yaml b/json-schema/response.yaml index 34e3ca1cd..e988b4c02 100644 --- a/json-schema/response.yaml +++ b/json-schema/response.yaml @@ -1137,10 +1137,10 @@ definitions: - type: 'null' role: $ref: common.yaml#/definitions/role - role_via: - allOf: - - description: the id of the workspace where the role comes from - - $ref: common.yaml#/definitions/uuid + role_via_workspace_id: + $ref: common.yaml#/definitions/uuid + role_via_organization_id: + $ref: common.yaml#/definitions/uuid WorkspacesAndRoles: type: array uniqueItems: true @@ -1166,10 +1166,10 @@ definitions: $ref: common.yaml#/definitions/email_address role: $ref: common.yaml#/definitions/role - role_via: - allOf: - - description: this is the id of the workspace where the role comes from - - $ref: common.yaml#/definitions/uuid + role_via_workspace_id: + $ref: common.yaml#/definitions/uuid + role_via_organization_id: + $ref: common.yaml#/definitions/uuid Datacenters: type: array uniqueItems: true @@ -1789,5 +1789,36 @@ definitions: $ref: common.yaml#/definitions/email_address role: $ref: common.yaml#/definitions/role + WorkspaceOrganizations: + type: array + uniqueItems: true + items: + type: object + additionalProperties: false + required: + - id + - name + - description + - role + - admins + properties: + id: + $ref: common.yaml#/definitions/uuid + name: + type: string + description: + oneOf: + - type: 'null' + - type: string + role: + $ref: common.yaml#/definitions/role + role_via_workspace_id: + $ref: common.yaml#/definitions/uuid + admins: + type: array + uniqueItems: true + minItems: 1 + items: + $ref: /definitions/UserTerse # vim: set sts=2 sw=2 et : diff --git a/lib/Conch/Controller/Organization.pm b/lib/Conch/Controller/Organization.pm index 7dbafc572..ffd5eae95 100644 --- a/lib/Conch/Controller/Organization.pm +++ b/lib/Conch/Controller/Organization.pm @@ -17,6 +17,11 @@ Conch::Controller::Organization If the user is a system admin, retrieve a list of all active organizations in the database; otherwise, limits the list to those organizations of which the user is a member. +Note: the only workspaces and roles listed are those reachable via the organization, even if +the user might have direct access to the workspace at a greater role. For comprehensive +information about what workspaces the user can access, and at what role, please use C or C. + Response uses the Organizations json schema. =cut @@ -37,10 +42,25 @@ sub list ($c) { $rs = $rs->search({ 'organization.id' => { -in => $c->db_user_organization_roles->search({ user_id => $c->stash('user_id') }) ->get_column('organization_id')->as_query - } }) - if not $c->is_system_admin; + } }); + + my @data = map $_->TO_JSON, $rs->all; + my %workspace_ids; + @workspace_ids{map $_->{id}, $_->{workspaces}->@*} = () foreach @data; + + foreach my $org (@data) { + foreach my $ws ($org->{workspaces}->@*) { + undef $ws->{parent_workspace_id} + if $ws->{parent_workspace_id} + and not exists $workspace_ids{$ws->{parent_workspace_id}} + and not $c->db_workspaces + ->and_workspaces_above($ws->{parent_workspace_id}) + ->related_resultset('user_workspace_roles') + ->exists; + } + } - $c->status(200, [ $rs->all ]); + $c->status(200, \@data); } =head2 create @@ -114,20 +134,39 @@ sub find_organization ($c) { Get the details of a single organization. Requires the 'admin' role on the organization. +Note: the only workspaces and roles listed are those reachable via the organization, even if +the user might have direct access to the workspace at a greater role. For comprehensive +information about what workspaces the user can access, and at what role, please use +C or C. + Response uses the Organization json schema. =cut sub get ($c) { - my ($organization) = $c->stash('organization_rs') + my $rs = $c->stash('organization_rs') ->search({ 'user_organization_roles.role' => 'admin' }) ->prefetch({ user_organization_roles => 'user_account', organization_workspace_roles => 'workspace', }) - ->order_by('user_account.name') - ->all; - $c->status(200, $organization); + ->order_by('user_account.name'); + + return $c->status(200, ($rs->all)[0]) if $c->is_system_admin; + + my $org_data = ($rs->all)[0]->TO_JSON; + my %workspace_ids; @workspace_ids{map $_->{id}, $org_data->{workspaces}->@*} = (); + foreach my $ws ($org_data->{workspaces}->@*) { + undef $ws->{parent_workspace_id} + if $ws->{parent_workspace_id} + and not exists $workspace_ids{$ws->{parent_workspace_id}} + and not $c->db_workspaces + ->and_workspaces_above($ws->{parent_workspace_id}) + ->related_resultset('user_workspace_roles') + ->exists; + } + + return $c->status(200, $org_data); } =head2 delete @@ -139,10 +178,20 @@ User must have system admin privileges. =cut sub delete ($c) { - my $user_count = 0+$c->stash('organization_rs')->related_resultset('user_organization_roles')->delete; - my $workspace_count = 0+$c->stash('organization_rs')->related_resultset('organization_workspace_roles')->delete; + my $user_count = 0+$c->stash('organization_rs') + ->related_resultset('user_organization_roles') + ->delete; + my $direct_workspaces_rs = $c->stash('organization_rs') + ->related_resultset('organization_workspace_roles') + ->get_column('workspace_id'); + my $workspace_count = $c->db_workspaces + ->and_workspaces_beneath($direct_workspaces_rs->as_query) + ->count; + + $c->stash('organization_rs')->related_resultset('organization_workspace_roles')->delete; $c->stash('organization_rs')->deactivate; + $c->log->debug('Deactivated organization '.$c->stash('organization_id_or_name') .', removing '.$user_count.' user memberships and removing from ' .$workspace_count.' workspaces'); diff --git a/lib/Conch/Controller/Workspace.pm b/lib/Conch/Controller/Workspace.pm index 9d5333925..7232f5754 100644 --- a/lib/Conch/Controller/Workspace.pm +++ b/lib/Conch/Controller/Workspace.pm @@ -83,10 +83,20 @@ sub list ($c) { return $c->status(200, [ $rs->all ]); } - my $direct_workspace_ids_rs = $c->stash('user') + my $user_workspaces_rs = $c->stash('user') ->related_resultset('user_workspace_roles') + ->related_resultset('workspace'); + + my $organization_workspaces_rs = $c->stash('user') + ->related_resultset('user_organization_roles') + ->related_resultset('organization') + ->related_resultset('organization_workspace_roles') + ->related_resultset('workspace'); + + my $direct_workspace_ids_rs = $user_workspaces_rs->union_all($organization_workspaces_rs) ->distinct - ->get_column('workspace_id'); + ->get_column('id'); + my @data = $c->db_workspaces ->and_workspaces_beneath($direct_workspace_ids_rs) ->with_role_via_data_for_user($c->stash('user_id')) diff --git a/lib/Conch/Controller/WorkspaceOrganization.pm b/lib/Conch/Controller/WorkspaceOrganization.pm new file mode 100644 index 000000000..73c62d228 --- /dev/null +++ b/lib/Conch/Controller/WorkspaceOrganization.pm @@ -0,0 +1,265 @@ +package Conch::Controller::WorkspaceOrganization; + +use Mojo::Base 'Mojolicious::Controller', -signatures; + +=pod + +=head1 NAME + +Conch::Controller::WorkspaceOrganization + +=head1 METHODS + +=head2 list_workspace_organizations + +Get a list of organizations for the current workspace. +Requires the 'admin' role on the workspace. + +Response uses the WorkspaceOrganizations json schema. + +=cut + +sub list_workspace_organizations ($c) { + my $workspace_id = $c->stash('workspace_id'); + + # organizations which can access any ancestor of this workspace + my $rs = $c->db_workspaces + ->and_workspaces_above($workspace_id) + ->related_resultset('organization_workspace_roles') + ->search_related('organization', + { 'user_organization_roles.role' => 'admin' }, + { join => { user_organization_roles => 'user_account' }, collapse => 1 }, + ) + ->active + ->columns([ + (map 'organization.'.$_, qw(id name description)), + (map 'user_organization_roles.'.$_, qw(organization_id user_id)), + { map +('user_organization_roles.user_account.'.$_ => 'user_account.'.$_), qw(id name email) }, + ]) + ->order_by(['organization.name', 'user_account.name']) + ->hri; + + my $org_data = [ + map { + my $org = $_; + my $role_via = $c->db_workspaces->role_via_for_organization($workspace_id, $org->{id}); + +{ + role => $role_via->role, + $role_via->workspace_id ne $workspace_id ? ( role_via_workspace_id => $role_via->workspace_id ) : (), + admins => [ map $_->{user_account}, (delete $org->{user_organization_roles})->@* ], + $org->%*, + } + } + $rs->all + ]; + + $c->log->debug('Found '.scalar($org_data->@*).' organizations'); + $c->status(200, $org_data); +} + +=head2 add_workspace_organization + +Adds a organization to the current workspace, or upgrades an existing role entry to access the +workspace. +Requires the 'admin' role on the workspace. + +Optionally takes a query parameter C (defaulting to true), to send an email +to all organization members and all workspace admins. + +=cut + +sub add_workspace_organization ($c) { + # Note: this method is very similar to Conch::Controller::WorkspaceUser::add_user + + my $params = $c->validate_query_params('NotifyUsers'); + return if not $params; + + my $input = $c->validate_request('WorkspaceAddOrganization'); + return if not $input; + + my $organization = $c->db_organizations->active->find($input->{organization_id}); + return $c->status(404) if not $organization; + + my $workspace_id = $c->stash('workspace_id'); + + # check if the organization already has access to this workspace + if (my $existing_role_via = $c->db_workspaces + ->role_via_for_organization($workspace_id, $organization->id)) { + if ((my $role_cmp = $existing_role_via->role_cmp($input->{role})) >= 0) { + my $str = 'organization "'.$organization->name.'" already has '.$existing_role_via->role + .' access to workspace '.$workspace_id + .($existing_role_via->workspace_id ne $workspace_id + ? (' via workspace '.$existing_role_via->workspace_id) : ''); + + $c->log->debug($str.': nothing to do'), return $c->status(204) + if $role_cmp == 0; + + return $c->status(409, { error => $str.': cannot downgrade role to '.$input->{role} }) + if $role_cmp > 0; + } + + my $rs = $organization->search_related('organization_workspace_roles', + { workspace_id => $workspace_id }); + if ($rs->exists) { + $rs->update({ role => $input->{role} }); + } + else { + $organization->create_related('organization_workspace_roles', { + workspace_id => $workspace_id, + role => $input->{role}, + }); + } + + $c->log->info('Upgraded organization '.$organization->id.' in workspace '.$workspace_id.' to '.$input->{role}); + + my $workspace_name = $c->stash('workspace_name') // $c->stash('workspace_rs')->get_column('name')->single; + if ($params->{send_mail} // 1) { + $c->send_mail( + template_file => 'workspace_organization_update_members', + To => $c->construct_address_list($organization->user_accounts->order_by('user_account.name')), + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + organization => $organization->name, + workspace => $workspace_name, + role => $input->{role}, + ); + my @workspace_admins = $c->db_workspaces + ->and_workspaces_above($workspace_id) + ->admins('with_sysadmins') + ->search({ + 'user_account.id' => { -not_in => $organization + ->related_resultset('user_organization_roles') + ->get_column('user_id') + ->as_query }, + }); + $c->send_mail( + template_file => 'workspace_organization_update_admins', + To => $c->construct_address_list(@workspace_admins), + From => 'noreply@conch.joyent.us', + Subject => 'We modified an organization\'s access to your workspace', + organization => $organization->name, + workspace => $workspace_name, + role => $input->{role}, + ) if @workspace_admins; + } + + return $c->status(204); + } + + $organization->create_related('organization_workspace_roles', { + workspace_id => $workspace_id, + role => $input->{role}, + }); + $c->log->info('Added organization '.$organization->id.' to workspace '.$workspace_id.' with the '.$input->{role}.' role'); + + if ($params->{send_mail} // 1) { + my $workspace_name = $c->stash('workspace_name') // $c->stash('workspace_rs')->get_column('name')->single; + $c->send_mail( + template_file => 'workspace_organization_add_members', + To => $c->construct_address_list($organization->user_accounts->order_by('user_account.name')), + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + organization => $organization->name, + workspace => $workspace_name, + role => $input->{role}, + ); + my @workspace_admins = $c->db_workspaces + ->and_workspaces_above($workspace_id) + ->admins('with_sysadmins') + ->search({ + 'user_account.id' => { -not_in => $organization + ->related_resultset('user_organization_roles') + ->get_column('user_id') + ->as_query }, + }); + $c->send_mail( + template_file => 'workspace_organization_add_admins', + To => $c->construct_address_list(@workspace_admins), + From => 'noreply@conch.joyent.us', + Subject => 'We added an organization to your workspace', + organization => $organization->name, + workspace => $workspace_name, + role => $input->{role}, + ) if @workspace_admins; + } + + $c->status(204); +} + +=head2 remove_workspace_organization + +Removes the indicated organization from the workspace, as well as all sub-workspaces. +Requires the 'admin' role on the workspace. + +Note this may not have the desired effect if the organization is getting access to the +workspace via a parent workspace. When in doubt, check at C<< GET +/workspace/:workspace_id/organization >>. + +Optionally takes a query parameter C (defaulting to true), to send an email +to all organization members and to all workspace admins. + +=cut + +sub remove_workspace_organization ($c) { + # Note: this method is very similar to Conch::Controller::WorkspaceUser::remove + + my $params = $c->validate_query_params('NotifyUsers'); + return if not $params; + + my $organization = $c->stash('organization_rs')->single; + + my $rs = $c->db_workspaces + ->and_workspaces_beneath($c->stash('workspace_id')) + ->search_related('organization_workspace_roles', { organization_id => $organization->id }); + + my $num_rows = $rs->count; + return $c->status(204) if not $num_rows; + + my $workspace_name = $c->stash('workspace_name') // $c->stash('workspace_rs')->get_column('name')->single; + + $c->log->debug('removing organization '.$organization->name.' from workspace ' + .$workspace_name.' and all sub-workspaces ('.$num_rows.'rows in total)'); + + my $deleted = $rs->delete; + + if ($deleted > 0 and $params->{send_mail} // 1) { + $c->send_mail( + template_file => 'workspace_organization_remove_members', + To => $c->construct_address_list($organization->user_accounts->order_by('user_account.name')), + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch workspaces have been updated', + organization => $organization->name, + workspace => $workspace_name, + ); + my @workspace_admins = $c->db_workspaces + ->and_workspaces_above($c->stash('workspace_id')) + ->admins('with_sysadmins') + ->search({ 'user_account.id' => { -not_in => $organization->user_accounts->get_column('id')->as_query } }); + $c->send_mail( + template_file => 'workspace_organization_remove_admins', + To => $c->construct_address_list(@workspace_admins), + From => 'noreply@conch.joyent.us', + Subject => 'We removed an organization from your workspace', + organization => $organization->name, + workspace => $workspace_name, + ) if @workspace_admins; + } + + return $c->status(204); +} + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/Controller/WorkspaceUser.pm b/lib/Conch/Controller/WorkspaceUser.pm index 4f38f6127..5333b727c 100644 --- a/lib/Conch/Controller/WorkspaceUser.pm +++ b/lib/Conch/Controller/WorkspaceUser.pm @@ -22,11 +22,21 @@ Response uses the WorkspaceUsers json schema. sub list ($c) { my $workspace_id = $c->stash('workspace_id'); - # users who can access any ancestor of this workspace - my $users_rs = $c->db_workspaces + # users who can access any ancestor of this workspace (directly) + my $direct_users_rs = $c->db_workspaces ->and_workspaces_above($workspace_id) ->related_resultset('user_workspace_roles') - ->related_resultset('user_account') + ->related_resultset('user_account'); + + # users who can access any ancestor of this workspace (through an organization) + my $organization_users_rs = $c->db_workspaces + ->and_workspaces_above($workspace_id) + ->related_resultset('organization_workspace_roles') + ->related_resultset('organization') + ->related_resultset('user_organization_roles') + ->related_resultset('user_account'); + + my $users_rs = $direct_users_rs->union_all($organization_users_rs) ->active ->distinct ->order_by('user_account.name') @@ -39,7 +49,9 @@ sub list ($c) { +{ $_->%*, # user.id, name, email role => $role_via->role, - $role_via->workspace_id ne $workspace_id ? ( role_via => $role_via->workspace_id ) : (), + $role_via->workspace_id ne $workspace_id + ? ( role_via_workspace_id => $role_via->workspace_id ) : (), + $role_via->can('organization_id') ? ( role_via_organization_id => $role_via->organization_id ) : (), } } $users_rs->all @@ -78,14 +90,19 @@ sub add_user ($c) { $c->stash('target_user', $user); my $workspace_id = $c->stash('workspace_id'); - # check if the user already has access to this workspace + # check if the user already has access to this workspace (whether directly, through a + # parent workspace, through an organization etc) if (my $existing_role_via = $c->db_workspaces ->role_via_for_user($workspace_id, $user->id)) { if ((my $role_cmp = $existing_role_via->role_cmp($input->{role})) >= 0) { my $str = 'user '.$user->name.' already has '.$existing_role_via->role - .' access to workspace '.$workspace_id - .($existing_role_via->workspace_id ne $workspace_id - ? (' via workspace '.$existing_role_via->workspace_id) : ''); + .' access to workspace '.$workspace_id; + my $str2 = join(' and', + ($existing_role_via->workspace_id ne $workspace_id + ? (' workspace '.$existing_role_via->workspace_id) : ()), + ($existing_role_via->can('organization_id') + ? (' organization '.$existing_role_via->organization_id) : ())); + $str .= ' via'.$str2 if $str2; $c->log->debug($str.': nothing to do'), return $c->status(204) if $role_cmp == 0; diff --git a/lib/Conch/DB/Helper/ResultSet/WithRole.pm b/lib/Conch/DB/Helper/ResultSet/WithRole.pm new file mode 100644 index 000000000..28d8a7e3f --- /dev/null +++ b/lib/Conch/DB/Helper/ResultSet/WithRole.pm @@ -0,0 +1,56 @@ +package Conch::DB::Helper::ResultSet::WithRole; +use v5.26; +use warnings; + +use experimental 'signatures'; +use Carp (); +use List::Util 'none'; + +=head1 NAME + +Conch::DB::Helper::ResultSet::WithRole + +=head1 DESCRIPTION + +A component for L classes for database tables with a C +column, to provide common query functionality. + +=head1 USAGE + + __PACKAGE__->load_components('+Conch::DB::Helper::ResultSet::WithRole'); + +=head1 METHODS + +=head2 with_role + +Constrains the resultset to those rows that grants (at least) the specified role. + +=cut + +sub with_role ($self, $role) { + Carp::croak('role must be one of: ro, rw, admin') + if !$ENV{MOJO_MODE} and none { $role eq $_ } qw(ro rw admin); + + Carp::croak($self->result_source->result_class->table, + ' does not have a \'role\' column') + if !$ENV{MOJO_MODE} and not $self->result_source->has_column('role'); + + return $self->search if $role eq 'ro'; + $self->search({ $self->current_source_alias.'.role' => { '>=' => \[ '?::role_enum', $role ] } }); +} + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/Helper/Row/WithRole.pm b/lib/Conch/DB/Helper/Row/WithRole.pm new file mode 100644 index 000000000..f4c32fd46 --- /dev/null +++ b/lib/Conch/DB/Helper/Row/WithRole.pm @@ -0,0 +1,64 @@ +package Conch::DB::Helper::Row::WithRole; +use v5.26; +use warnings; + +use parent 'DBIx::Class::Core'; + +=head1 NAME + +Conch::DB::Helper::Row::WithRole + +=head1 DESCRIPTION + +A component for L classes for database tables with a C +column, to provide common functionality. + +=head1 USAGE + + __PACKAGE__->load_components('+Conch::DB::Helper::Row::WithRole'); + +=head1 METHODS + +=head2 role_cmp + +Acts like the C operator, returning -1, 0 or 1 depending on whether the first role is less +than, the same as, or greater than the second role. + +If only one role argument is passed, the role in the current row is compared to the passed-in +role. + +Accepts undef for one or both roles, which always compare as less than a defined role. + +=cut + +sub role_cmp { + my $self = shift; + + state $role_to_int = do { + my $i = 0; + +{ map +($_ => ++$i), $self->column_info('role')->{extra}{list}->@* }; + }; + + my ($role1, $role2) = + @_ == 2 ? (shift, shift) + : @_ == 1 ? ($self->role, shift) + : die 'insufficient arguments'; + + (defined $role1 ? $role_to_int->{$role1} : 0) <=> (defined $role2 ? $role_to_int->{$role2} : 0); +} + +1; +__END__ + +=pod + +=head1 LICENSING + +Copyright Joyent, Inc. + +This Source Code Form is subject to the terms of the Mozilla Public License, +v.2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at L. + +=cut +# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm b/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm index 98fa87052..2548b80d6 100644 --- a/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm +++ b/lib/Conch/DB/Result/OrganizationWorkspaceRole.pm @@ -115,6 +115,7 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07049 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZZaSreTpDXoTMxhiWIz9zQ +__PACKAGE__->load_components('+Conch::DB::Helper::Row::WithRole'); 1; __END__ diff --git a/lib/Conch/DB/Result/UserOrganizationRole.pm b/lib/Conch/DB/Result/UserOrganizationRole.pm index 4f944ef34..ddc09b99d 100644 --- a/lib/Conch/DB/Result/UserOrganizationRole.pm +++ b/lib/Conch/DB/Result/UserOrganizationRole.pm @@ -115,6 +115,7 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07049 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:elBmld/kFpmlmPYd88GVlQ +__PACKAGE__->load_components('+Conch::DB::Helper::Row::WithRole'); 1; __END__ diff --git a/lib/Conch/DB/Result/UserWorkspaceRole.pm b/lib/Conch/DB/Result/UserWorkspaceRole.pm index c4a099ef4..5edf3a8f1 100644 --- a/lib/Conch/DB/Result/UserWorkspaceRole.pm +++ b/lib/Conch/DB/Result/UserWorkspaceRole.pm @@ -115,30 +115,7 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07049 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:hztQ0pH4Hj4A+sMl+Ih55A -=head2 role_cmp - -Acts like the C operator, returning -1, 0 or 1 depending on whether the first role is less -than, the same as, or greater than the second role. - -If only one role argument is passed, the role in the current row is compared to the passed-in -role. - -=cut - -{ - my $i = 0; - my %role_to_int = map +($_ => ++$i), __PACKAGE__->column_info('role')->{extra}{list}->@*; - - sub role_cmp { - my $self = shift; - my ($role1, $role2) = - @_ == 2 ? (shift, shift) - : @_ == 1 ? ($self->role, shift) - : die 'insufficient arguments'; - - $role_to_int{$role1} <=> $role_to_int{$role2}; - } -} +__PACKAGE__->load_components('+Conch::DB::Helper::Row::WithRole'); 1; __END__ diff --git a/lib/Conch/DB/Result/Workspace.pm b/lib/Conch/DB/Result/Workspace.pm index a19619670..42451c34c 100644 --- a/lib/Conch/DB/Result/Workspace.pm +++ b/lib/Conch/DB/Result/Workspace.pm @@ -228,18 +228,28 @@ Include information about the user's role, if available. sub TO_JSON ($self) { my $data = $self->next::method(@_); - # check for column that would have been added via - # Conch::DB::ResultSet::Workspace::add_role_column or + # check for column that would have been added via any of: + # Conch::DB::ResultSet::Workspace::add_role_column # Conch::DB::ResultSet::Workspace::with_role_via_data_for_user if (my $role = $self->role) { $data->{role} = $role; } + # we are fetching workspace data from the perspective of a particular user elsif (my $user_id = $self->user_id_for_role) { my $role_via = $self->result_source->resultset->role_via_for_user($self->id, $user_id); Carp::croak('tried to get role data for a user that has no role for this workspace: workspace_id ', $self->id, ', user_id ', $user_id) if not $role_via; $data->{role} = $role_via->role; - $data->{role_via} = $role_via->workspace_id if $role_via->workspace_id ne $self->id; + $data->{role_via_workspace_id} = $role_via->workspace_id if $role_via->workspace_id ne $self->id; + $data->{role_via_organization_id} = $role_via->organization_id if $role_via->can('organization_id'); + } + # we are fetching workspace data from the perspective of a particular organization + elsif (my $organization_id = $self->organization_id_for_role) { + my $role_via = $self->result_source->resultset->role_via_for_organization($self->id, $organization_id); + Carp::croak('tried to get role data for an organization that has no role for this workspace: workspace_id ', $self->id, ', organization_id ', $organization_id) if not $role_via; + + $data->{role} = $role_via->role; + $data->{role_via_workspace_id} = $role_via->workspace_id if $role_via->workspace_id ne $self->id; } return $data; diff --git a/lib/Conch/DB/ResultSet.pm b/lib/Conch/DB/ResultSet.pm index 2cf514371..dbc7ba115 100644 --- a/lib/Conch/DB/ResultSet.pm +++ b/lib/Conch/DB/ResultSet.pm @@ -31,6 +31,7 @@ __PACKAGE__->load_components( '+Conch::DB::Helper::ResultSet::AsEpoch', # provides as_epoch 'Helper::ResultSet::SetOperations', # provides union, intersect, except, and *_all 'Helper::ResultSet::Shortcut::GroupBy', # provides group_by + '+Conch::DB::Helper::ResultSet::WithRole', # provides with_role ); 1; diff --git a/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm b/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm deleted file mode 100644 index 9db3d13e0..000000000 --- a/lib/Conch/DB/ResultSet/UserWorkspaceRole.pm +++ /dev/null @@ -1,49 +0,0 @@ -package Conch::DB::ResultSet::UserWorkspaceRole; -use v5.26; -use warnings; -use parent 'Conch::DB::ResultSet'; - -use experimental 'signatures'; -use Carp (); -use List::Util 'none'; - -=head1 NAME - -Conch::DB::ResultSet::UserWorkspaceRole - -=head1 DESCRIPTION - -Interface to queries involving user/workspace roles. - -=head1 METHODS - -=head2 with_role - -Constrains the resultset to those user_workspace_role rows that grants (at least) the specified -role. - -=cut - -sub with_role ($self, $role) { - Carp::croak('role must be one of: ro, rw, admin') - if none { $role eq $_ } qw(ro rw admin); - - return $self->search if $role eq 'ro'; - $self->search({ role => { '>=' => \[ '?::role_enum', $role ] } }); -} - -1; -__END__ - -=pod - -=head1 LICENSING - -Copyright Joyent, Inc. - -This Source Code Form is subject to the terms of the Mozilla Public License, -v.2.0. If a copy of the MPL was not distributed with this file, You can obtain -one at L. - -=cut -# vim: set ts=4 sts=4 sw=4 et : diff --git a/lib/Conch/DB/ResultSet/Workspace.pm b/lib/Conch/DB/ResultSet/Workspace.pm index 39e036b88..11ba5897b 100644 --- a/lib/Conch/DB/ResultSet/Workspace.pm +++ b/lib/Conch/DB/ResultSet/Workspace.pm @@ -166,8 +166,9 @@ sub add_role_column ($self, $role) { =head2 with_role_via_data_for_user Query for workspace(s) with an extra field attached to the query which will signal the -workspace serializer to include the "role" and "role_via" columns, containing information about -the effective role the user has for the workspace. +workspace serializer to include the "role", "role_via_workspace_id" and +"role_via_organization_id" columns, containing information about the effective role the user +has for the workspace. Only one user_id can be calculated at a time. If you need to generate workspace-and-role data for multiple users at once, you can manually do: @@ -188,9 +189,24 @@ sub with_role_via_data_for_user ($self, $user_id) { =head2 role_via_for_user -For a given workspace_id and user_id, find the user_workspace_role row that is responsible for -providing the user access to the workspace (the user_workspace_role with the greatest -role that is attached to an ancestor workspace). +For a given workspace_id and user_id, find the user_workspace_role or +organization_workspace_role row that is responsible for providing the user access to the +workspace (the row with the greatest role that is attached to an ancestor workspace). + +How the role is calculated: + +=over 4 + +=item * The role on the user_organization_role role is B used. + +=item * The number of workspaces between C<$workspace_id> and the workspace attached to the +user_workspace_role or organization_workspace_role row is B used. + +=item * When both a user_workspace_role and organization_workspace_role row are found with the same +role, the record directly associated with the workspace (if there is one) is preferred; +otherwise, the user_workspace_role row is preferred. + +=back =cut @@ -199,8 +215,50 @@ sub role_via_for_user ($self, $workspace_id, $user_id) { # because we check for duplicate role entries when creating user_workspace_role rows, # we "should" only have *one* row with the greatest role in the entire hierarchy... + my $uwr = $self->and_workspaces_above($workspace_id) + ->search_related('user_workspace_roles', { user_id => $user_id }) + ->order_by({ -desc => 'role' }) + ->rows(1) + ->single; + + return $uwr if $uwr and $uwr->workspace_id eq $workspace_id and $uwr->role eq 'admin'; + + # there could be more than one organization that grants the user this role, but it + # shouldn't matter which one we single out in the result. + my $owr = $self->and_workspaces_above($workspace_id) + ->search_related('organization_workspace_roles', + { user_id => $user_id }, { join => { organization => 'user_organization_roles' } }) + ->order_by({ -desc => 'organization_workspace_roles.role' }) + ->rows(1) + ->single; + + my (undef, $role_via) = sort { + (!defined $a ? -1 : !defined $b ? 1 : 0) + || $a->role_cmp($b->role) + || ($a->workspace_id eq $workspace_id ? 1 : 0) + || ($b->workspace_id eq $workspace_id ? -1 : 0) + || 1 # give up; go with $a ($uwr) + } ($uwr, $owr); + + return $role_via; +} + +=head2 role_via_for_organization + +For a given workspace_id and organization_id, find the organization_workspace_role row that is +responsible for providing the organization access to the workspace (the +organization_workspace_role with the greatest role that is attached to an ancestor +workspace). + +=cut + +sub role_via_for_organization ($self, $workspace_id, $organization_id) { + Carp::croak('resultset should not have conditions') if $self->{cond}; + + # because we check for duplicate role entries when creating organization_workspace_role rows, + # we "should" only have *one* row with the greatest role in the entire hierarchy... $self->and_workspaces_above($workspace_id) - ->search_related('user_workspace_roles', { 'user_workspace_roles.user_id' => $user_id }) + ->search_related('organization_workspace_roles', { organization_id => $organization_id }) ->order_by({ -desc => 'role' }) ->rows(1) ->single; @@ -214,9 +272,18 @@ system admin users in the result. =cut sub admins ($self, $include_sysadmins = undef) { - my $rs = $self->search_related('user_workspace_roles', { role => 'admin' }) + my $direct_users_rs = $self->search_related('user_workspace_roles', + { 'user_workspace_roles.role' => 'admin' }) + ->related_resultset('user_account'); + + my $organization_users_rs = $self->search_related('organization_workspace_roles', + { 'organization_workspace_roles.role' => 'admin' }) + ->related_resultset('organization') + ->related_resultset('user_organization_roles') ->related_resultset('user_account'); + my $rs = $direct_users_rs->union_all($organization_users_rs); + $rs = $rs->union_all($self->result_source->schema->resultset('user_account')->search_rs({ is_admin => 1 })) if $include_sysadmins; @@ -238,13 +305,22 @@ sub with_user_role ($self, $user_id, $role) { Carp::croak('role must be one of: ro, rw, admin') if !$ENV{MOJO_MODE} and none { $role eq $_ } qw(ro rw admin); - $self->search( + my $via_user_rs = $self->search( { $role ne 'ro' ? ('user_workspace_roles.role' => { '>=' => \[ '?::role_enum', $role ] } ) : (), 'user_workspace_roles.user_id' => $user_id, }, { join => 'user_workspace_roles' }, ); + + my $via_org_rs = $self->search( + { + $role ne 'ro' ? ('organization_workspace_roles.role' => { '>=' => \[ '?::role_enum', $role ] }) : (), + 'user_organization_roles.user_id' => $user_id, + }, + { join => { organization_workspace_roles => { organization => 'user_organization_roles' } } } ); + + return $via_user_rs->union_all($via_org_rs)->distinct; } =head2 user_has_role @@ -253,6 +329,9 @@ Checks that the provided user_id has (at least) the specified role in at least o the resultset. (Does not search recursively; add C<< ->and_workspaces_above($workspace_id) >> to your resultset first, if this is what you want.) +Both direct C entries and joined +C -> C entries are checked. + Returns a boolean. =cut @@ -261,9 +340,17 @@ sub user_has_role ($self, $user_id, $role) { Carp::croak('role must be one of: ro, rw, admin') if !$ENV{MOJO_MODE} and none { $role eq $_ } qw(ro rw admin); - $self->search_related('user_workspace_roles', { user_id => $user_id }) + my $via_user_rs = $self->search_related('user_workspace_roles', { user_id => $user_id }) ->with_role($role) - ->exists; + ->related_resultset('user_account'); + + my $via_org_rs = $self->related_resultset('organization_workspace_roles') + ->with_role($role) + ->related_resultset('organization') + ->search_related('user_organization_roles', { user_id => $user_id }) + ->related_resultset('user_account'); + + return $via_user_rs->union_all($via_org_rs)->exists; } =head2 _workspaces_subquery diff --git a/lib/Conch/Route/Workspace.pm b/lib/Conch/Route/Workspace.pm index 5d4123a7f..3b62eaf99 100644 --- a/lib/Conch/Route/Workspace.pm +++ b/lib/Conch/Route/Workspace.pm @@ -74,6 +74,17 @@ sub routes { # DELETE /workspace/:workspace_id_or_name/user/#target_user_id_or_email?send_mail=<1|0> $with_workspace_admin->under('/user/#target_user_id_or_email')->to('user#find_user') ->delete('/')->to('workspace_user#remove'); + + # GET /workspace/:workspace_id_or_name/organization + $with_workspace_admin->get('/organization')->to('workspace_organization#list_workspace_organizations'); + + # POST /workspace/:workspace_id_or_name/organization?send_mail=<1|0> + $with_workspace_admin->post('/organization')->to('workspace_organization#add_workspace_organization'); + + # DELETE /workspace/:workspace_id_or_name/organization/:organization_id_or_name?send_mail=<1|0> + $with_workspace_admin->under('/organization/:organization_id_or_name') + ->to('organization#find_organization') + ->delete('/')->to('workspace_organization#remove_workspace_organization'); } } @@ -259,6 +270,44 @@ an email to the user and workspace admins. =back +=head3 C + +=over 4 + +=item * User requires the admin role + +=item * Response: response.yaml#/WorkspaceOrganizations + +=back + +=head3 C<< POST /workspace/:workspace_id_or_name/organization?send_mail=<1|0> >> + +Takes one optional query parameter C<< send_mail=<1|0> >> (defaults to 1) to send +an email to the organization members and workspace admins. + +=over 4 + +=item * User requires the admin role + +=item * Request: request.yaml#/WorkspaceAddOrganization + +=item * Response: C<204 NO CONTENT> + +=back + +=head3 C<< DELETE /workspace/:workspace_id_or_name/organization/:organization_id_or_name?send_mail=<1|0> >> + +Takes one optional query parameter C<< send_mail=<1|0> >> (defaults to 1) to send +an email to the organization members and workspace admins. + +=over 4 + +=item * User requires the admin role + +=item * Returns C<204 NO CONTENT> + +=back + =head1 LICENSING Copyright Joyent, Inc. diff --git a/lib/Test/Conch/Fixtures.pm b/lib/Test/Conch/Fixtures.pm index 18bf17360..de49be92f 100644 --- a/lib/Test/Conch/Fixtures.pm +++ b/lib/Test/Conch/Fixtures.pm @@ -666,6 +666,28 @@ sub _generate_definition ($self, $fixture_type, $num, $specification) { }, }; } + elsif ($fixture_type eq 'workspace') { + return +{ + "workspace_$num" => { + new => 'workspace', + using => { + name => "workspace_$num", + ($specification // {})->%*, + }, + }, + }; + } + elsif ($fixture_type eq 'organization') { + return +{ + "organization_$num" => { + new => 'organization', + using => { + name => "organization_$num", + ($specification // {})->%*, + }, + }, + }; + } else { die 'unrecognized fixture type '.$fixture_type; } diff --git a/t/integration/crud/devices.t b/t/integration/crud/devices.t index e02c6a601..3c77a19cb 100644 --- a/t/integration/crud/devices.t +++ b/t/integration/crud/devices.t @@ -21,6 +21,7 @@ $t->load_validation_plans([{ my $rack = $t->load_fixture('rack_0a'); my $rack_id = $rack->id; my $hardware_product_id = $t->load_fixture('hardware_product_compute')->id; +my $global_ws = $t->load_fixture('global_workspace'); # perform most tests as a user with read only access to the GLOBAL workspace my $null_user = $t->generate_fixtures('user_account'); @@ -309,6 +310,35 @@ subtest 'located device' => sub { nics => [], disks => [], }); + my $device_data = $t->tx->res->json; + + $t->app->db_devices->search({ id => $located_device_id })->update({ hostname => 'located_host' }); + $device_data->{hostname} = 'located_host'; + + { + my $t = Test::Conch->new(pg => $t->pg); + my $null_user = $t->generate_fixtures('user_account'); + $t->authenticate(email => $null_user->email); + $t->get_ok('/device/'.$located_device_id) + ->status_is(403); + + $t->get_ok('/device?hostname=located_host') + ->status_is(403); + + my $org = $t->generate_fixtures('organization'); + $org->create_related('user_organization_roles', { user_id => $null_user->id, role => 'ro' }); + $global_ws->create_related('organization_workspace_roles', { organization_id => $org->id, role => 'ro' }); + + $t->get_ok('/device/'.$located_device_id) + ->status_is(200) + ->json_schema_is('DetailedDevice') + ->json_cmp_deeply($device_data); + + $t->get_ok('/device?hostname=located_host') + ->status_is(200) + ->json_schema_is('Devices') + ->json_cmp_deeply([ superhashof({ id => $located_device_id }) ]); + } $t->txn_local('remove device from its workspace', sub ($t) { $t->app->db_workspace_racks->delete; diff --git a/t/integration/crud/organization.t b/t/integration/crud/organization.t index e4683ed16..c59b439e7 100644 --- a/t/integration/crud/organization.t +++ b/t/integration/crud/organization.t @@ -361,9 +361,401 @@ $t->delete_ok('/user/'.$admin_user->id) user => { map +($_ => $admin_user->$_), qw(id email name created deactivated) }, }); -$t2->delete_ok('/organization/my first organization/user/'.$new_user->email) + +my $global_ws = $t->load_fixture('global_workspace'); +my $sub_ws = $t->generate_fixtures('workspace', { parent_workspace_id => $global_ws->id, name => 'sub ws' }); + +$t->get_ok('/workspace/'.$sub_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([]); + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { role => 'ro' }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/organization_id', message => re(qr/missing property/i) } ]); + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { organization_id => $organization->{id} }) + ->status_is(400) + ->json_schema_is('RequestValidationError') + ->json_cmp_deeply('/details', [ { path => '/role', message => re(qr/missing property/i) } ]); + +$t2->get_ok('/workspace/'.$sub_ws->id.'/organization') + ->status_is(403) + ->log_debug_is('User lacks the required role (admin) for workspace '.$sub_ws->id); + +$t2->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'ro', + }) + ->status_is(403) + ->log_debug_is('User lacks the required role (admin) for workspace '.$sub_ws->id); + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'ro', + }) + ->status_is(204) + ->email_cmp_deeply([ + { + To => '"'.$admin_user->name.'" <'.$admin_user->email.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^Your "my first organization" organization has been added to the\R"${\$sub_ws->name}" workspace at Joyent Conch with the "ro" role\./m), + }, + { + To => '"'.$super_user->name.'" <'.$super_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We added an organization to your workspace', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) added the "my first organization" organization to the\R"${\$sub_ws->name}" workspace at Joyent Conch with the "ro" role\./m), + }, + ]); + +$t->get_ok('/workspace/'.$sub_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'ro', + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +push $organization->{workspaces}->@*, +{ (map +($_ => $sub_ws->$_), qw(id parent_workspace_id name description)), role => 'ro' }; + +$t->get_ok('/organization/my first organization') + ->status_is(200) + ->json_schema_is('Organization') + ->json_is($organization); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization, $organization2 ]); + +$t2->get_ok('/organization/my first organization') + ->status_is(200) + ->json_schema_is('Organization') + ->json_is({ + $organization->%*, + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description)), + parent_workspace_id => undef, # user does not have the role to see GLOBAL + role => 'ro', + }, + ], + }); + +$t2->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ + { + $organization->%*, + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description)), + parent_workspace_id => undef, # user does not have the role to see GLOBAL + role => 'ro', + }, + ], + }, + # user is not a member of organization2 + ]); + +my $grandchild_ws = $t->generate_fixtures('workspace', { parent_workspace_id => $sub_ws->id, name => 'grandchild ws' }); + +push $organization->{workspaces}->@*, +{ (map +($_ => $grandchild_ws->$_), qw(id parent_workspace_id name description)), role => 'ro', role_via_workspace_id => $sub_ws->id }; + +$t->get_ok('/workspace/'.$grandchild_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'ro', + role_via_workspace_id => $sub_ws->id, + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +$t->get_ok('/workspace/'.$sub_ws->id.'/user') + ->status_is(200) + ->json_schema_is('WorkspaceUsers') + ->json_is([ + { + (map +($_ => $admin_user->$_), qw(id name email)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $new_user->$_), qw(id name email)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + ]); + +$t->get_ok('/workspace/'.$grandchild_ws->id.'/user') + ->status_is(200) + ->json_schema_is('WorkspaceUsers') + ->json_is([ + { + (map +($_ => $admin_user->$_), qw(id name email)), + role => 'ro', + role_via_workspace_id => $sub_ws->id, + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $new_user->$_), qw(id name email)), + role => 'ro', + role_via_workspace_id => $sub_ws->id, + role_via_organization_id => $organization->{id}, + }, + ]); + +$t2->get_ok('/workspace') + ->status_is(200) + ->json_schema_is('WorkspacesAndRoles') + ->json_is([ + # $new_user cannot see GLOBAL + { + (map +($_ => $sub_ws->$_), qw(id name description)), + parent_workspace_id => undef, + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $grandchild_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_workspace_id => $sub_ws->id, + role_via_organization_id => $organization->{id}, + }, + ]); + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'rw', + }) + ->status_is(204) + ->email_cmp_deeply([ + { + To => '"'.$admin_user->name.'" <'.$admin_user->email.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^Your access to the "${\$sub_ws->name}" workspace at Joyent Conch\Rvia the "my first organization" organization has been adjusted to the "rw" role\./m), + }, + { + To => '"'.$super_user->name.'" <'.$super_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We modified an organization\'s access to your workspace', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) modified the "my first organization" organization's\Raccess to the "${\$sub_ws->name}" workspace at Joyent Conch to the "rw" role\./m), + }, + ]); + +$t->get_ok('/workspace/'.$sub_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'rw', + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +$_->{role} = 'rw' foreach $organization->{workspaces}->@*; + +$t->get_ok('/organization/my first organization') + ->status_is(200) + ->json_schema_is('Organization') + ->json_is($organization); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization, $organization2 ]); + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'rw', + }) + ->status_is(204) + ->log_debug_is('organization "my first organization" already has rw access to workspace '.$sub_ws->id.': nothing to do') + ->email_not_sent; + +$t->post_ok('/workspace/'.$grandchild_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'rw', + }) + ->status_is(204) + ->log_debug_is('organization "my first organization" already has rw access to workspace '.$grandchild_ws->id.' via workspace '.$sub_ws->id.': nothing to do') + ->email_not_sent; + +$t->post_ok('/workspace/'.$sub_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'ro', + }) + ->status_is(409) + ->json_is({ error => 'organization "my first organization" already has rw access to workspace '.$sub_ws->id.': cannot downgrade role to ro' }) + ->email_not_sent; + +$t->post_ok('/workspace/'.$grandchild_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'ro', + }) + ->status_is(409) + ->json_is({ error => 'organization "my first organization" already has rw access to workspace '.$grandchild_ws->id.' via workspace '.$sub_ws->id.': cannot downgrade role to ro' }) + ->email_not_sent; + +$t->post_ok('/workspace/'.$sub_ws->id.'/user', json => { + user_id => $new_user->id, + role => 'rw', + }) + ->log_debug_is('user '.$new_user->name.' already has rw access to workspace '.$sub_ws->id.' via organization '.$organization->{id}.': nothing to do') + ->status_is(204) + ->email_not_sent; + +$t->post_ok('/workspace/'.$grandchild_ws->id.'/user', json => { + user_id => $new_user->id, + role => 'rw', + }) + ->log_debug_is('user '.$new_user->name.' already has rw access to workspace '.$grandchild_ws->id.' via workspace '.$sub_ws->id.' and organization '.$organization->{id}.': nothing to do') + ->status_is(204) + ->email_not_sent; + +$t->post_ok('/workspace/'.$sub_ws->id.'/user', json => { + user_id => $new_user->id, + role => 'ro', + }) + ->status_is(409) + ->json_is({ error => 'user '.$new_user->name.' already has rw access to workspace '.$sub_ws->id.' via organization '.$organization->{id}.': cannot downgrade role to ro' }) + ->email_not_sent; + +$t->post_ok('/workspace/'.$grandchild_ws->id.'/user', json => { + user_id => $new_user->id, + role => 'ro', + }) + ->status_is(409) + ->json_is({ error => 'user '.$new_user->name.' already has rw access to workspace '.$grandchild_ws->id.' via workspace '.$sub_ws->id.' and organization '.$organization->{id}.': cannot downgrade role to ro' }) + ->email_not_sent; + +$t2->delete_ok('/workspace/grandchild ws/organization/my first organization') + ->status_is(403); + + +my $t3 = Test::Conch->new(pg => $t->pg); +$t3->authenticate(email => $admin_user->email); + +$t3->delete_ok('/workspace/'.$grandchild_ws->id.'/organization/'.$organization->{id}) ->status_is(403); +$t3->delete_ok('/workspace/'.$sub_ws->id.'/organization/'.$organization->{id}) + ->status_is(403); + +$t->delete_ok('/workspace/'.$grandchild_ws->id.'/organization/'.$organization->{id}) + ->status_is(204) + ->email_not_sent; + + +$t->get_ok('/workspace/'.$grandchild_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'rw', + role_via_workspace_id => $sub_ws->id, + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +$t->post_ok('/workspace/'.$grandchild_ws->id.'/organization', json => { + organization_id => $organization->{id}, + role => 'admin', + }) + ->status_is(204) + ->email_cmp_deeply([ + { + To => '"'.$admin_user->name.'" <'.$admin_user->email.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch access has changed', + body => re(qr/^Your access to the "${\$grandchild_ws->name}" workspace at Joyent Conch\Rvia the "my first organization" organization has been adjusted to the "admin" role\./m), + }, + { + To => '"'.$super_user->name.'" <'.$super_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We modified an organization\'s access to your workspace', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) modified the "my first organization" organization's\Raccess to the "${\$grandchild_ws->name}" workspace at Joyent Conch to the "admin" role\./m), + }, + ]); + +$t->get_ok('/workspace/'.$grandchild_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'admin', + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +$t->delete_ok('/workspace/'.$grandchild_ws->id.'/organization/'.$organization->{id}) + ->status_is(204) + ->email_cmp_deeply([ + { + To => '"'.$admin_user->name.'" <'.$admin_user->email.'>, "'.$new_user->name.'" <'.$new_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'Your Conch workspaces have been updated', + body => re(qr/^Your "my first organization" organization has been removed from the\R"grandchild ws" workspace at Joyent Conch\./m), + }, + { + To => '"'.$super_user->name.'" <'.$super_user->email.'>', + From => 'noreply@conch.joyent.us', + Subject => 'We removed an organization from your workspace', + body => re(qr/^${\$super_user->name} \(${\$super_user->email}\) removed the "my first organization"\Rorganization from the "grandchild ws" workspace at Joyent Conch\./m), + }, + ]); + +$t->get_ok('/workspace/'.$grandchild_ws->id.'/organization') + ->status_is(200) + ->json_schema_is('WorkspaceOrganizations') + ->json_is([ + { + $organization->%{qw(id name description)}, + role => 'rw', + role_via_workspace_id => $sub_ws->id, + admins => [ + { map +($_ => $admin_user->$_), qw(id name email) }, + { map +($_ => $new_user->$_), qw(id name email) }, + ], + }, + ]); + +$t->get_ok('/organization') + ->status_is(200) + ->json_schema_is('Organizations') + ->json_is([ $organization, $organization2 ]); + +$t2->delete_ok('/organization/my first organization/user/'.$new_user->email) + ->status_is(403); $t->delete_ok('/organization/my first organization/user/foo@bar.com') ->status_is(404); @@ -389,7 +781,7 @@ $t->get_ok('/organization') $t->delete_ok('/organization/my first organization') ->status_is(204) - ->log_debug_is('Deactivated organization my first organization, removing 2 user memberships and removing from 0 workspaces'); + ->log_debug_is('Deactivated organization my first organization, removing 2 user memberships and removing from 2 workspaces'); $t->get_ok('/organization') ->status_is(200) diff --git a/t/integration/crud/workspace.t b/t/integration/crud/workspace.t index cc31ec5b7..f447fbc8d 100644 --- a/t/integration/crud/workspace.t +++ b/t/integration/crud/workspace.t @@ -217,7 +217,7 @@ subtest 'Sub-Workspace' => sub { $t->location_is('/workspace/'.(my $child_ws_id = $t->tx->res->json->{id})); $workspace_data{conch}[1] = $t->tx->res->json; - $workspace_data{admin_user}[1] = { $t->tx->res->json->%*, role_via => $global_ws_id }; + $workspace_data{admin_user}[1] = { $t->tx->res->json->%*, role_via_workspace_id => $global_ws_id }; $t->authenticate(email => $admin_user->email); $t->get_ok('/workspace/'.$child_ws_id) @@ -225,7 +225,7 @@ subtest 'Sub-Workspace' => sub { ->json_schema_is('WorkspaceAndRole') ->json_cmp_deeply($workspace_data{admin_user}[1]); - push $users{child_ws}->@*, map +{ $_->%*, role_via => $global_ws_id }, $users{GLOBAL}->@*; + push $users{child_ws}->@*, map +{ $_->%*, role_via_workspace_id => $global_ws_id }, $users{GLOBAL}->@*; $t->get_ok("/workspace/$global_ws_id/child") ->status_is(200) @@ -272,7 +272,7 @@ subtest 'Sub-Workspace' => sub { }, ]); - delete $users{child_ws}->[1]{role_via}; + delete $users{child_ws}->[1]{role_via_workspace_id}; $users{child_ws}->[1]{role} = 'rw'; $t->get_ok("/workspace/$child_ws_id/user") @@ -306,7 +306,7 @@ subtest 'Sub-Workspace' => sub { description => 'two levels of subworkspaces', parent_workspace_id => $child_ws_id, role => 'admin', - role_via => $global_ws_id, + role_via_workspace_id => $global_ws_id, }) ->email_cmp_deeply([ { @@ -373,7 +373,7 @@ subtest 'Sub-Workspace' => sub { description => 'two levels of subworkspaces', parent_workspace_id => $child_ws_id, role => 'rw', - role_via => $child_ws_id, + role_via_workspace_id => $child_ws_id, }, ], 'new user has access to all workspaces via GLOBAL'); @@ -390,7 +390,7 @@ subtest 'Sub-Workspace' => sub { ->json_is('/2/email' => 'test_user@conch.joyent.us') ->json_cmp_deeply('/2/workspaces' => bag($workspace_data{test_user}->@*)); - push $users{grandchild_ws}->@*, map +{ role_via => $child_ws_id, $_->%* }, $users{child_ws}->@*; + push $users{grandchild_ws}->@*, map +{ role_via_workspace_id => $child_ws_id, $_->%* }, $users{child_ws}->@*; $t->get_ok("/workspace/$child_ws_id/user") ->status_is(200) @@ -449,7 +449,7 @@ subtest 'Sub-Workspace' => sub { }, ]); - delete $users{grandchild_ws}->[1]{role_via}; + delete $users{grandchild_ws}->[1]{role_via_workspace_id}; $users{grandchild_ws}->[1]{role} = 'admin'; $t->get_ok("/workspace/$grandchild_ws_id/user") @@ -486,9 +486,9 @@ subtest 'Sub-Workspace' => sub { # update our idea of what all the roles should look like: $workspace_data{test_user}[1]{role} = 'admin'; - delete $workspace_data{test_user}[1]{role_via}; + delete $workspace_data{test_user}[1]{role_via_workspace_id}; $workspace_data{test_user}[2]{role} = 'admin'; - delete $workspace_data{test_user}[2]{role_via}; + delete $workspace_data{test_user}[2]{role_via_workspace_id}; $t_super->get_ok('/user/'.$admin_user->email) ->status_is(200) @@ -529,13 +529,13 @@ subtest 'Sub-Workspace' => sub { }, ]); - $users{child_ws}->[1]{role_via} = $global_ws_id; + $users{child_ws}->[1]{role_via_workspace_id} = $global_ws_id; $users{child_ws}->[1]{role} = $users{GLOBAL}->[1]{role}; - $users{grandchild_ws}->[1]{role_via} = $global_ws_id; + $users{grandchild_ws}->[1]{role_via_workspace_id} = $global_ws_id; $users{grandchild_ws}->[1]{role} = $users{GLOBAL}->[1]{role}; - $workspace_data{test_user}[1]->@{qw(role role_via)} = ('ro', $global_ws_id); - $workspace_data{test_user}[2]->@{qw(role role_via)} = ('ro', $global_ws_id); + $workspace_data{test_user}[1]->@{qw(role role_via_workspace_id)} = ('ro', $global_ws_id); + $workspace_data{test_user}[2]->@{qw(role role_via_workspace_id)} = ('ro', $global_ws_id); $t->get_ok("/workspace/$child_ws_id/user") ->status_is(200) @@ -593,7 +593,7 @@ subtest 'Sub-Workspace' => sub { }; push $users{grandchild_ws}->@*, { $users{child_ws}[2]->%*, - role_via => $child_ws_id, + role_via_workspace_id => $child_ws_id, }; $t->get_ok('/workspace/child_ws/user') @@ -614,7 +614,7 @@ subtest 'Sub-Workspace' => sub { { $workspace_data{admin_user}[2]->%{qw(id name description parent_workspace_id)}, role => 'ro', - role_via => $child_ws_id, + role_via_workspace_id => $child_ws_id, }, ]; diff --git a/t/workspace-role.t b/t/workspace-role.t index 7271f6211..86e63824f 100644 --- a/t/workspace-role.t +++ b/t/workspace-role.t @@ -11,12 +11,21 @@ subtest 'user-role access' => sub { my $null_user = $t->generate_fixtures('user_account', { name => 'user with no access' }); my $ws_user = $t->generate_fixtures('user_account', { name => 'user with direct workspace access' }); + my $org_user = $t->generate_fixtures('user_account', { name => 'user with access via organization' }); + my $both_user = $t->generate_fixtures('user_account', { name => 'user with access both ways' }); + my $org = $t->generate_fixtures('organization'); my $global_ws = $t->load_fixture('global_workspace'); my $child1_ws = $global_ws->create_related('workspaces', { name => 'child 1' }); $ws_user->create_related('user_workspace_roles', { workspace_id => $global_ws->id, role => 'ro' }); $ws_user->create_related('user_workspace_roles', { workspace_id => $child1_ws->id, role => 'rw' }); + $org_user->create_related('user_organization_roles', { organization_id => $org->id, role => 'admin' }); + $org->create_related('organization_workspace_roles', { workspace_id => $global_ws->id, role => 'rw' }); + $org->create_related('organization_workspace_roles', { workspace_id => $child1_ws->id, role => 'admin' }); + + $both_user->create_related('user_workspace_roles', { workspace_id => $global_ws->id, role => 'ro' }); + $both_user->create_related('user_organization_roles', { organization_id => $org->id, role => 'admin' }); my @racks = map { first { $_->isa('Conch::DB::Result::Rack') } $t->generate_fixtures('rack') } 0..1; $racks[1]->create_related('workspace_racks', { workspace_id => $child1_ws->id }); @@ -34,19 +43,31 @@ subtest 'user-role access' => sub { # [ set name, user, role, expected value ] my @tests = ( [ 'GLOBAL', $null_user, 'ro', 0 ], - [ 'GLOBAL', $ws_user, 'ro', 1 ], # direct access on GLOBAL + [ 'GLOBAL', $ws_user, 'ro', 1 ], # direct user access on GLOBAL + [ 'GLOBAL', $org_user, 'ro', 1 ], # via org on GLOBAL + [ 'GLOBAL', $both_user, 'ro', 1 ], [ 'child1', $null_user, 'ro', 0 ], - [ 'child1', $ws_user, 'ro', 1 ], # indirect access via GLOBAL, and direct on child 1 + [ 'child1', $ws_user, 'ro', 1 ], # indirect user access via GLOBAL, and direct user access on child 1 + [ 'child1', $org_user, 'ro', 1 ], + [ 'child1', $both_user, 'ro', 1 ], [ 'GLOBAL', $null_user, 'rw', 0 ], [ 'GLOBAL', $ws_user, 'rw', 0 ], + [ 'GLOBAL', $org_user, 'rw', 1 ], + [ 'GLOBAL', $both_user, 'rw', 1 ], [ 'child1', $null_user, 'rw', 0 ], [ 'child1', $ws_user, 'rw', 1 ], # direct access on child 1 + [ 'child1', $org_user, 'rw', 1 ], + [ 'child1', $both_user, 'rw', 1 ], [ 'GLOBAL', $null_user, 'admin', 0 ], [ 'GLOBAL', $ws_user, 'admin', 0 ], + [ 'GLOBAL', $org_user, 'admin', 0 ], + [ 'GLOBAL', $both_user, 'admin', 0 ], [ 'child1', $null_user, 'admin', 0 ], [ 'child1', $ws_user, 'admin', 0 ], + [ 'child1', $org_user, 'admin', 1 ], # via org on child 1 + [ 'child1', $both_user, 'admin', 1 ], # "" ); # remember, you can set DBIC_TRACE=1 while you run the tests to see @@ -79,5 +100,153 @@ subtest 'user-role access' => sub { } }; +subtest 'user_workspace_role and organization_workspace_role role_via_for_user' => sub { + my $t = Test::Conch->new; + my $user = $t->generate_fixtures('user_account'); + my $org1 = $t->generate_fixtures('organization'); + my $org2 = $t->generate_fixtures('organization'); + my $global_ws = $t->load_fixture('global_workspace'); + my $child_ws = $global_ws->create_related('workspaces', { name => 'child of GLOBAL' }); + my $grandchild_ws = $child_ws->create_related('workspaces', { name => 'grandchild of GLOBAL' }); + + # user is a member of both organizations, at different roles (this role never matters) + $org1->create_related('user_organization_roles', { user_id => $user->id, role => 'rw' }); + $org2->create_related('user_organization_roles', { user_id => $user->id, role => 'ro' }); + + # [ data to populate, expected result (for cmp_deeply), test name ] + my @permutations = ( + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $grandchild_ws->id, role => 'admin' } ], + }, + methods( + user_id => $user->id, + workspace_id => $grandchild_ws->id, + role => 'admin', + ), + 'direct user access to the workspace, at admin', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $child_ws->id, role => 'admin' } ], + }, + methods( + user_id => $user->id, + workspace_id => $child_ws->id, + role => 'admin', + ), + 'direct user access via parent workspace, at admin', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $global_ws->id, role => 'rw' } ], + }, + methods( + user_id => $user->id, + workspace_id => $global_ws->id, + role => 'rw', + ), + 'direct user access via grandparent workspace, no organization', + ], + [ + { + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $grandchild_ws->id, role => 'admin' } ], + }, + methods( + organization_id => $org1->id, + workspace_id => $grandchild_ws->id, + role => 'admin', + ), + 'direct organization access to the workspace, at admin', + ], + [ + { + organization_workspace_role => [ { organization_id => $org2->id, workspace_id => $child_ws->id, role => 'rw' } ], + }, + methods( + organization_id => $org2->id, + workspace_id => $child_ws->id, + role => 'rw', + ), + 'organization access via the parent workspace', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $child_ws->id, role => 'ro' } ], + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $global_ws->id, role => 'rw' } ], + }, + methods( + organization_id => $org1->id, + workspace_id => $global_ws->id, + role => 'rw', + ), + 'organization access via grandparent workspace prevails over a lower role granted directly to the user on the workspace', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $global_ws->id, role => 'rw' } ], + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $grandchild_ws->id, role => 'ro' } ], + }, + methods( + user_id => $user->id, + workspace_id => $global_ws->id, + role => 'rw', + ), + 'user access via GLOBAL prevails over a lower role granted to the organization right on the workspace', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $grandchild_ws->id, role => 'rw' } ], + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $grandchild_ws->id, role => 'rw' } ], + }, + methods( + user_id => $user->id, + workspace_id => $grandchild_ws->id, + role => 'rw', + ), + 'tied roles: user access to the workspace prevails over organization access to the workspace', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $grandchild_ws->id, role => 'rw' } ], + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $child_ws->id, role => 'rw' } ], + }, + methods( + user_id => $user->id, + workspace_id => $grandchild_ws->id, + role => 'rw', + ), + 'tied roles: user access to the workspace prevails over organization access to the parent workspace', + ], + [ + { + user_workspace_role => [ { user_id => $user->id, workspace_id => $child_ws->id, role => 'rw' } ], + organization_workspace_role => [ { organization_id => $org1->id, workspace_id => $grandchild_ws->id, role => 'rw' } ], + }, + methods( + organization_id => $org1->id, + workspace_id => $grandchild_ws->id, + role => 'rw', + ), + 'tied roles: organization access directly to the workspace prevails over user access to the parent workspace', + ], + ); + + foreach my $test_data (@permutations) { + $t->app->schema->txn_begin; + + $t->app->schema->resultset($_)->populate($test_data->[0]{$_}) + foreach keys $test_data->[0]->%*; + + cmp_deeply( + $t->app->db_workspaces->role_via_for_user($grandchild_ws->id, $user->id), + $test_data->[1], + $test_data->[2], + ); + + $t->app->schema->txn_rollback; + } +}; + done_testing; # vim: set ts=4 sts=4 sw=4 et : diff --git a/templates/email/workspace_organization_add_admins.txt.ep b/templates/email/workspace_organization_add_admins.txt.ep new file mode 100644 index 000000000..b5615deb3 --- /dev/null +++ b/templates/email/workspace_organization_add_admins.txt.ep @@ -0,0 +1,9 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) added the "<%= $organization %>" organization to the +"<%= $workspace %>" workspace at Joyent Conch with the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team + diff --git a/templates/email/workspace_organization_add_members.txt.ep b/templates/email/workspace_organization_add_members.txt.ep new file mode 100644 index 000000000..758e9e8bb --- /dev/null +++ b/templates/email/workspace_organization_add_members.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +Your "<%= $organization %>" organization has been added to the +"<%= $workspace %>" workspace at Joyent Conch with the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/workspace_organization_remove_admins.txt.ep b/templates/email/workspace_organization_remove_admins.txt.ep new file mode 100644 index 000000000..39bdf7017 --- /dev/null +++ b/templates/email/workspace_organization_remove_admins.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) removed the "<%= $organization %>" +organization from the "<%= $workspace %>" workspace at Joyent Conch. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/workspace_organization_remove_members.txt.ep b/templates/email/workspace_organization_remove_members.txt.ep new file mode 100644 index 000000000..629c0a26e --- /dev/null +++ b/templates/email/workspace_organization_remove_members.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +Your "<%= $organization %>" organization has been removed from the +"<%= $workspace %>" workspace at Joyent Conch. + +Thank you, +Joyent Build Ops Team diff --git a/templates/email/workspace_organization_update_admins.txt.ep b/templates/email/workspace_organization_update_admins.txt.ep new file mode 100644 index 000000000..8edb44cb4 --- /dev/null +++ b/templates/email/workspace_organization_update_admins.txt.ep @@ -0,0 +1,9 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +<%= $user->name %> (<%= $user->email %>) modified the "<%= $organization %>" organization's +access to the "<%= $workspace %>" workspace at Joyent Conch to the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team + diff --git a/templates/email/workspace_organization_update_members.txt.ep b/templates/email/workspace_organization_update_members.txt.ep new file mode 100644 index 000000000..5e769d0ce --- /dev/null +++ b/templates/email/workspace_organization_update_members.txt.ep @@ -0,0 +1,8 @@ +Hello, + +<%# TODO: we should specify the conch URL here %>\ +Your access to the "<%= $workspace %>" workspace at Joyent Conch +via the "<%= $organization %>" organization has been adjusted to the "<%= $role %>" role. + +Thank you, +Joyent Build Ops Team From cba78ad54f15b7d55f0ae8f2415e343acaab62b6 Mon Sep 17 00:00:00 2001 From: Karen Etheridge Date: Thu, 18 Jul 2019 12:43:57 -0700 Subject: [PATCH 8/8] add workspaces reachable by orgs to UserDetailed --- lib/Conch/Controller/User.pm | 4 +- lib/Conch/DB/Result/UserAccount.pm | 35 ++++++++ t/integration/crud/organization.t | 124 +++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/lib/Conch/Controller/User.pm b/lib/Conch/Controller/User.pm index 8a49b02d4..31af94f62 100644 --- a/lib/Conch/Controller/User.pm +++ b/lib/Conch/Controller/User.pm @@ -330,7 +330,7 @@ sub get ($c) { ->search({ 'user_account.id' => $c->stash('target_user')->id }) ->prefetch({ user_workspace_roles => 'workspace', - user_organization_roles => 'organization', + user_organization_roles => { organization => { organization_workspace_roles => 'workspace' } }, }) ->order_by([qw(workspace.name organization.name)]) ->all; @@ -411,7 +411,7 @@ sub list ($c) { ->active ->prefetch({ user_workspace_roles => 'workspace', - user_organization_roles => 'organization', + user_organization_roles => { organization => { organization_workspace_roles => 'workspace' } }, }) ->order_by([qw(user_account.name workspace.name organization.name)]); diff --git a/lib/Conch/DB/Result/UserAccount.pm b/lib/Conch/DB/Result/UserAccount.pm index c93d78736..8ce5b476a 100644 --- a/lib/Conch/DB/Result/UserAccount.pm +++ b/lib/Conch/DB/Result/UserAccount.pm @@ -304,6 +304,12 @@ sub TO_JSON ($self) { } $cached_uors->@*, ]; + # we assume we prefetched our user(s) with: + # { user_organization_roles => { organization => { organization_workspace_roles => 'workspace' } } } + my @cached_owrs = map + $_->organization->related_resultset('organization_workspace_roles')->get_cache->@*, + $cached_uors->@*; + my %seen_workspaces; $data->{workspaces} = [ # we process the direct uwr+workspace entries first so we do not produce redundant rows @@ -315,6 +321,18 @@ sub TO_JSON ($self) { } } $cached_uwrs->@*), + # direct owr_workspace entries + (map +( + $seen_workspaces{$_->id}++ ? () : do { + my $workspace = $_->workspace; + +{ + $workspace->TO_JSON->%*, + role => $_->role, + role_via_organization_id => $_->organization_id, + } + } + ), @cached_owrs), + # all the workspaces the user can reach indirectly (map +( map +( @@ -327,6 +345,23 @@ sub TO_JSON ($self) { ), $self->result_source->schema->resultset('workspace') ->workspaces_beneath($_->workspace_id) ), $cached_uwrs->@*), + + # all the workspaces the user's organization(s) can reach indirectly + (map { + my $owr = $_; + map +( + # $_ is a workspace where the organization inherits a role + $seen_workspaces{$_->id}++ ? () : do { + # instruct the workspace serializer to fill in the role fields + $self->is_admin ? $_->role('admin') : $_->organization_id_for_role($owr->organization_id); + +{ + $_->TO_JSON->%*, + role_via_organization_id => $owr->organization_id, + } + } + ), $self->result_source->schema->resultset('workspace') + ->workspaces_beneath($_->workspace_id) + } @cached_owrs), ]; } diff --git a/t/integration/crud/organization.t b/t/integration/crud/organization.t index c59b439e7..fcc474cbc 100644 --- a/t/integration/crud/organization.t +++ b/t/integration/crud/organization.t @@ -468,6 +468,59 @@ $t2->get_ok('/organization') # user is not a member of organization2 ]); +$t->get_ok('/user/'.$admin_user->email) + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $admin_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + { $organization2->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + ], + })); + +$t->get_ok('/user/'.$new_user->email) + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $new_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + ], + })); + +$t2->get_ok('/user/me') + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $new_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description)), + parent_workspace_id => undef, # user does not have the role to see GLOBAL + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + ], + })); + my $grandchild_ws = $t->generate_fixtures('workspace', { parent_workspace_id => $sub_ws->id, name => 'grandchild ws' }); push $organization->{workspaces}->@*, +{ (map +($_ => $grandchild_ws->$_), qw(id parent_workspace_id name description)), role => 'ro', role_via_workspace_id => $sub_ws->id }; @@ -487,6 +540,77 @@ $t->get_ok('/workspace/'.$grandchild_ws->id.'/organization') }, ]); +$t->get_ok('/user/'.$admin_user->email) + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $admin_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + { $organization2->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $grandchild_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + role_via_workspace_id => $sub_ws->id, + }, + ], + })); + +$t->get_ok('/user/'.$new_user->email) + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $new_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $grandchild_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + role_via_workspace_id => $sub_ws->id, + }, + ], + })); + +$t2->get_ok('/user/me') + ->status_is(200) + ->json_schema_is('UserDetailed') + ->json_cmp_deeply(superhashof({ + id => $new_user->id, + organizations => [ + { $organization->%{qw(id name description)}, role => 'admin' }, + ], + workspaces => [ + { + (map +($_ => $sub_ws->$_), qw(id name description)), + parent_workspace_id => undef, # user does not have the role to see GLOBAL + role => 'ro', + role_via_organization_id => $organization->{id}, + }, + { + (map +($_ => $grandchild_ws->$_), qw(id name description parent_workspace_id)), + role => 'ro', + role_via_organization_id => $organization->{id}, + role_via_workspace_id => $sub_ws->id, + }, + ], + })); + $t->get_ok('/workspace/'.$sub_ws->id.'/user') ->status_is(200) ->json_schema_is('WorkspaceUsers')