This is an automated email from the ASF dual-hosted git repository. awong pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/kudu.git
commit 66f4bb136e1bc42e8c031548b56ee1927002ac09 Author: Andrew Wong <[email protected]> AuthorDate: Wed Dec 19 23:11:45 2018 -0800 authz: verify tokens on scans Adds privilege checking to enforce the following authorization requirements are met when scan-like requests are received by tablet servers: Scans or checksum scans require: if no projected columns || projected columns has virtual column: foreach (column): SCAN ON COLUMN else: if uses pk: foreach(primary key column): SCAN ON COLUMN foreach(projected column): SCAN ON COLUMN foreach(predicated column): SCAN ON COLUMN Split-key requests require: if uses pk: foreach(primary key column): SCAN ON COLUMN foreach(requested column): SCAN ON COLUMN Notes: Empty projections - Kudu uses this to implement counting rows, which is semantically equivalent to counting rows with a projection on all columns. Primary keys - Scans in ORDERED mode (i.e. fault-tolerant scans) pass around primary keys to keep track of scan progress. - Scans that include a start or stop primary key will use the bounds as a range predicate on the primary key columns. Split-key requests use similar fields. Virtual columns - Diff scans are implemented by having users supply a column in the projection that doesn't exist in the tablet schema. As an example, in a table where the column "deleted" does not exist, a diff scan looks like: projection: ("col0", type:string), ("deleted", type:is_deleted) whereas a projection with a column that doesn't exist might look like the following (note the only difference is in the type): projection: ("col0", type:string), ("deleted", type:string) - In the latter case, in order to prevent leaking the existence (or lack there of, in this case) of the column "deleted", we send back an authorization error instead of a "not found" error. - In the former case, we actually want the request to proceed. Even though the column doesn't exist, we don't expect it to because it has a virtual type. Therein lies room for a vulnerability -- if a malicious user were to replace the types in the request with a virtual column type, there wouldn't be a good way to distinguish between these two cases. - To reconcile this, we apply the most conservative policy we can and require that virtual columns require privileges on all columns. All of the listed requests are also permitted if SCAN ON TABLE (i.e. full scan privileges) are given. Change-Id: I7a5d81cf215a5d936f8853feba05778038764905 Reviewed-on: http://gerrit.cloudera.org:8080/11753 Tested-by: Kudu Jenkins Reviewed-by: Adar Dembo <[email protected]> Reviewed-by: Andrew Wong <[email protected]> --- src/kudu/common/schema.h | 17 +- src/kudu/tserver/CMakeLists.txt | 2 +- .../tserver/tablet_server_authorization-test.cc | 772 ++++++++++++++++++++- src/kudu/tserver/tablet_service.cc | 313 +++++++-- 4 files changed, 1047 insertions(+), 57 deletions(-) diff --git a/src/kudu/common/schema.h b/src/kudu/common/schema.h index faf83f1..bc4cefd 100644 --- a/src/kudu/common/schema.h +++ b/src/kudu/common/schema.h @@ -529,25 +529,30 @@ class Schema { } // Return the ColumnSchema corresponding to the given column index. - inline const ColumnSchema &column(size_t idx) const { + const ColumnSchema &column(size_t idx) const { DCHECK_LT(idx, cols_.size()); return cols_[idx]; } // Return the ColumnSchema corresponding to the given column ID. - inline const ColumnSchema& column_by_id(ColumnId id) const { + const ColumnSchema& column_by_id(ColumnId id) const { int idx = find_column_by_id(id); DCHECK_GE(idx, 0); return cols_[idx]; } - // Return the column ID corresponding to the given column index + // Return the column ID corresponding to the given column index. ColumnId column_id(size_t idx) const { DCHECK(has_column_ids()); DCHECK_LT(idx, cols_.size()); return col_ids_[idx]; } + // Return the column IDs, ordered by column index. + const std::vector<ColumnId>& column_ids() const { + return col_ids_; + } + // Return true if the schema contains an ID mapping for its columns. // In the case of an empty schema, this is false. bool has_column_ids() const { @@ -584,6 +589,12 @@ class Schema { return idx < num_key_columns_; } + // Returns the list of primary key column IDs. + std::vector<ColumnId> get_key_column_ids() const { + return std::vector<ColumnId>( + col_ids_.begin(), col_ids_.begin() + num_key_columns_); + } + // Return true if this Schema is initialized and valid. bool initialized() const { return !col_offsets_.empty(); diff --git a/src/kudu/tserver/CMakeLists.txt b/src/kudu/tserver/CMakeLists.txt index 1bc6398..810a86d 100644 --- a/src/kudu/tserver/CMakeLists.txt +++ b/src/kudu/tserver/CMakeLists.txt @@ -177,6 +177,6 @@ ADD_KUDU_TEST(tablet_copy_source_session-test) ADD_KUDU_TEST(tablet_copy_service-test) ADD_KUDU_TEST(tablet_server-test PROCESSORS 3) ADD_KUDU_TEST(tablet_server-stress-test RUN_SERIAL true) -ADD_KUDU_TEST(tablet_server_authorization-test) +ADD_KUDU_TEST(tablet_server_authorization-test NUM_SHARDS 2) ADD_KUDU_TEST(scanners-test) ADD_KUDU_TEST(ts_tablet_manager-test) diff --git a/src/kudu/tserver/tablet_server_authorization-test.cc b/src/kudu/tserver/tablet_server_authorization-test.cc index 43594eb..f3b788e 100644 --- a/src/kudu/tserver/tablet_server_authorization-test.cc +++ b/src/kudu/tserver/tablet_server_authorization-test.cc @@ -15,21 +15,37 @@ // specific language governing permissions and limitations // under the License. +#include <stdint.h> +#include <stdlib.h> + +#include <algorithm> #include <functional> #include <memory> #include <ostream> +#include <set> #include <string> -#include <utility> +#include <unordered_map> +#include <unordered_set> #include <vector> +#include <boost/optional/optional.hpp> #include <gflags/gflags.h> #include <gflags/gflags_declare.h> #include <glog/logging.h> +#include <google/protobuf/stubs/port.h> #include <gtest/gtest.h> +#include "kudu/common/common.pb.h" +#include "kudu/common/encoded_key.h" +#include "kudu/common/schema.h" +#include "kudu/common/types.h" #include "kudu/common/wire_protocol-test-util.h" #include "kudu/common/wire_protocol.h" #include "kudu/common/wire_protocol.pb.h" +#include "kudu/gutil/map-util.h" +#include "kudu/gutil/ref_counted.h" +#include "kudu/gutil/strings/join.h" +#include "kudu/gutil/strings/substitute.h" #include "kudu/gutil/walltime.h" #include "kudu/rpc/rpc_controller.h" #include "kudu/rpc/rpc_header.pb.h" @@ -39,31 +55,40 @@ #include "kudu/security/token.pb.h" #include "kudu/security/token_signer.h" #include "kudu/security/token_verifier.h" +#include "kudu/tablet/tablet_replica.h" #include "kudu/tserver/mini_tablet_server.h" #include "kudu/tserver/tablet_server-test-base.h" #include "kudu/tserver/tablet_server.h" +#include "kudu/tserver/ts_tablet_manager.h" #include "kudu/tserver/tserver.pb.h" #include "kudu/tserver/tserver_service.pb.h" #include "kudu/tserver/tserver_service.proxy.h" #include "kudu/util/monotime.h" #include "kudu/util/pb_util.h" +#include "kudu/util/random.h" +#include "kudu/util/slice.h" #include "kudu/util/status.h" #include "kudu/util/test_macros.h" +#include "kudu/util/test_util.h" +using std::set; using std::shared_ptr; using std::string; +using std::unique_ptr; +using std::unordered_map; +using std::unordered_set; using std::vector; +using strings::Substitute; DECLARE_bool(tserver_enforce_access_control); DECLARE_double(tserver_inject_invalid_authz_token_ratio); namespace kudu { -class Schema; - using pb_util::SecureShortDebugString; using rpc::ErrorStatusPB; using rpc::RpcController; +using security::ColumnPrivilegePB; using security::PrivateKey; using security::SignedTokenPB; using security::TablePrivilegePB; @@ -71,6 +96,7 @@ using security::TokenSigner; using security::TokenSigningPrivateKeyPB; using security::TokenSigningPublicKeyPB; using security::TokenVerifier; +using tablet::TabletReplica; namespace tserver { @@ -269,6 +295,7 @@ TEST_P(AuthzTabletServerTest, TestInvalidAuthzTokens) { LOG(INFO) << "Generating request with no privileges"; SignedTokenPB token; TablePrivilegePB empty; + empty.set_table_id(kTableId); ASSERT_OK(signer.GenerateAuthzToken(kUser, empty, &token)); RpcController rpc; Status s = send_req(schema_, &token, proxy_.get(), &rpc); @@ -304,5 +331,744 @@ INSTANTIATE_TEST_CASE_P(RequestorFuncs, AuthzTabletServerTest, ::testing::Values(&WriteGenerator, &ScanGenerator, &SplitKeyRangeGenerator, &ChecksumGenerator)); +namespace { + +// Boolean to indicate the expected result of authorization. +enum class ExpectedAuthz { + ALLOWED, + DENIED +}; + +// Boolean to indicate usage of deprecated fields. +enum class DeprecatedField { + USE, + DONT_USE +}; + +// Enum indicating different non-standard scenarios we need to make sure are +// handled appropriately. +enum class SpecialColumn { + // A malicious user may try to discover the presence of columns by misnaming + // columns. + MISNAMED, + + // A user may want to perform a scan on a virtual column, i.e. a valid column + // that does not exist in the tablet but exists in the projection. + VIRTUAL, + + NONE, +}; + +// Encapsulates entities that describe a scan that are relevant to +// authorization, used for easier composability of tests. With a schema, this +// can be used to generate scan requests. +struct ScanDescriptor { + // Whether this describes a scan using the primary key (e.g. for ordering). + bool use_pk; + + // The column names to project. + unordered_set<string> projected_cols; + + // The column names to predicate on. + unordered_set<string> predicated_cols; + + string ToString() const { + set<string> sorted_projected(projected_cols.begin(), projected_cols.end()); + set<string> sorted_predicated(predicated_cols.begin(), predicated_cols.end()); + return Substitute("use_pk: $0, projected_cols: [$1], predicated_cols: [$2]", use_pk, + JoinStrings(sorted_projected, ", "), JoinStrings(sorted_predicated, ", ")); + } +}; + +// Default variable names for the scan-related tests below. +constexpr char kScanTableId[] = "scan-table-id"; +constexpr char kScanTabletId[] = "scan-tablet-id"; +constexpr char kScanUser[] = "good-guy"; +constexpr char kDummyColumn[] = "not-my-column"; + +// Mapping of column names to column IDs. +typedef unordered_map<string, ColumnId> ColumnNamesToIds; + +// Encapsulates the scan-related privileges that an authz token can contain, +// used for easier composability of tests. +struct ScanPrivileges { + // Whether the privilege has full scan privileges. + bool full_privileges; + + // The column names that are allowed to be scanned. + unordered_set<string> col_privileges; + + // Table ID that these privileges are associated with. If empty, a default + // table ID will be used. + string table_id; + + // Translates the privileges into a TablePrivilegePB for use in a token, + // using the column IDs in 'name_to_id'. + TablePrivilegePB ToPB(const ColumnNamesToIds& name_to_id) const { + TablePrivilegePB pb; + if (full_privileges) { + pb.set_scan_privilege(true); + } + ColumnPrivilegePB col_privilege; + col_privilege.set_scan_privilege(true); + for (const auto& col_name : col_privileges) { + const auto& col_id = FindOrDie(name_to_id, col_name); + InsertOrDie(pb.mutable_column_privileges(), col_id, col_privilege); + } + pb.set_table_id(table_id.empty() ? kScanTableId : table_id); + return pb; + } + + string ToString() const { + set<string> sorted_cols(col_privileges.begin(), col_privileges.end()); + return Substitute("full_privileges: $0, col_privileges: [$1]", + full_privileges, JoinStrings(sorted_cols, ", ")); + } +}; + +// Utility function to unwrap RPC response errors. +template<class Resp> +Status CheckNoErrors(const Resp& resp) { + if (resp.has_error()) { + return StatusFromPB(resp.error().status()); + } + return Status::OK(); +} + +// Generates an encoded key of the given value for the given schema. +string GenerateEncodedKey(int32_t val, const Schema& schema) { + EncodedKeyBuilder builder(&schema); + for (int i = 0; i < schema.num_key_columns(); i++) { + DCHECK_EQ(INT32, schema.column(i).type_info()->physical_type()); + builder.AddColumnKey(&val); + } + unique_ptr<EncodedKey> key(builder.BuildEncodedKey()); + Slice slice = key->encoded_key(); + return slice.ToString(); +} + +// Returns a column schema PB that matches 'col', but has a different name. +void MisnamedColumnSchemaToPB(const ColumnSchema& col, ColumnSchemaPB* pb) { + ColumnSchemaToPB(ColumnSchema(kDummyColumn, col.type_info()->physical_type(), col.is_nullable(), + col.read_default_value(), col.write_default_value(), col.attributes(), + col.type_attributes()), pb); +} + +// Utility to select a string at random. +string SelectStringAtRandom(const unordered_set<string>& s) { + Random r(SeedRandom()); + vector<string> random; + r.ReservoirSample(s, 1, set<string>{}, &random); + CHECK_EQ(1, random.size()); + return random[0]; +} + +} // anonymous namespace + +// Functor to parameterize tests with that generates scan-like requests (e.g. +// Scans, Checksums) given a ScanDescriptor (for the request contents) and +// ScanPrivileges (for an attached authz token). +class ScanPrivilegeAuthzTest; + +typedef std::function<Status(ScanPrivilegeAuthzTest*, + const ScanDescriptor&, + const ScanPrivileges&)> ScanFunc; + +// Parameterized based on the scan request function and whether or not the scan +// request should use the primary key. +class ScanPrivilegeAuthzTest : public TabletServerTestBase, + public ::testing::WithParamInterface<std::tuple<ScanFunc, bool>> { + public: + static constexpr int kNumKeys = 5; + static constexpr int kNumVals = 5; + + void SetUp() override { + NO_FATALS(TabletServerTestBase::SetUp()); + NO_FATALS(StartTabletServer(/*num_data_dirs=*/1)); + + FLAGS_tserver_enforce_access_control = true; + SchemaBuilder schema_builder; + for (int i = 0; i < kNumKeys; i++) { + const string key = Substitute("key$0", i); + schema_builder.AddKeyColumn(key, DataType::INT32); + col_names_.emplace_back(key); + } + for (int i = 0; i < kNumVals; i++) { + const string val = Substitute("val$0", kNumKeys + i); + schema_builder.AddColumn(ColumnSchema(val, DataType::INT32), + /*is_key=*/false); + col_names_.emplace_back(val); + } + schema_ = schema_builder.Build(); + + rpc::UserCredentials user; + user.set_real_user(kScanUser); + proxy_->set_user_credentials(user); + + // We're not testing expiration, so pass in arbitrary expiration values. + shared_ptr<TokenVerifier> verifier(new TokenVerifier()); + signer_.reset(new TokenSigner(3600, 3600, 3600, verifier)); + TokenSigningPrivateKeyPB tsk = GetTokenSigningPrivateKey(1); + ASSERT_OK(signer_->ImportKeys({ tsk })); + vector<TokenSigningPublicKeyPB> public_keys = verifier->ExportKeys(); + ASSERT_OK(mini_server_->server()->mutable_token_verifier()->ImportKeys(public_keys)); + + // Put together a map from column name to ID so we can put together + // ID-based tokens based on column names. + for (int i = 0; i < schema_.num_columns(); i++) { + ColumnId column_id = schema_.column_id(i); + EmplaceOrDie(&name_to_id_, schema_.column_by_id(column_id).name(), column_id); + } + ASSERT_OK(mini_server_->AddTestTablet(kScanTableId, kScanTabletId, schema_)); + scoped_refptr<TabletReplica> replica; + ASSERT_TRUE(mini_server_->server()->tablet_manager()->LookupTablet(kScanTabletId, &replica)); + ASSERT_OK(WaitForTabletRunning(kScanTabletId)); + } + + // Returns a signed token for the given scan privileges. + Status GenerateAuthzToken(const ScanPrivileges& privilege, SignedTokenPB* authz_token) const { + TablePrivilegePB privilege_pb = privilege.ToPB(name_to_id_); + return signer_->GenerateAuthzToken(kScanUser, std::move(privilege_pb), authz_token); + } + + // Populates fields of a NewScanRequestPB based on the scan descriptor, + // including an authz token based on 'privilege'. + NewScanRequestPB GenerateScanRequest(const ScanDescriptor& scan, + const ScanPrivileges& privilege, + DeprecatedField range_predicate, + SpecialColumn special_col) const { + NewScanRequestPB pb; + pb.set_tablet_id(kScanTabletId); + Schema client_schema = schema_.CopyWithoutColumnIds(); + if (scan.use_pk) { + pb.set_order_mode(ORDERED); + // Ordered scans must be snapshot scans. + pb.set_read_mode(READ_AT_SNAPSHOT); + } else { + pb.set_order_mode(UNORDERED); + } + // Set some arbitrary bounds; the values don't matter for authorization. + int32_t inclusive_lower_bound = 0; + int32_t exclusive_upper_bound = 10; + int32_t inclusive_upper_bound = exclusive_upper_bound - 1; + for (const auto& col_name : scan.predicated_cols) { + // Also test our deprecated predicate API and our new one; the deprecated + // API is still available for backwards compatability and is thus fair + // game for authorization. + if (range_predicate == DeprecatedField::USE) { + ColumnRangePredicatePB* range = pb.add_deprecated_range_predicates(); + int col_idx = schema_.find_column(col_name); + ColumnSchemaToPB(client_schema.column(col_idx), range->mutable_column()); + range->mutable_lower_bound()->append( + reinterpret_cast<char*>(&inclusive_lower_bound), sizeof(inclusive_lower_bound)); + range->mutable_inclusive_upper_bound()->append( + reinterpret_cast<char*>(&inclusive_upper_bound), sizeof(inclusive_upper_bound)); + } else { + ColumnPredicatePB* pred = pb.add_column_predicates(); + pred->set_column(col_name); + ColumnPredicatePB::Range* range = pred->mutable_range(); + range->mutable_lower()->append( + reinterpret_cast<char*>(&inclusive_lower_bound), sizeof(inclusive_lower_bound)); + range->mutable_upper()->append( + reinterpret_cast<char*>(&exclusive_upper_bound), sizeof(exclusive_upper_bound)); + } + } + // Determine which column to sabotage if needed. + boost::optional<string> misnamed_col; + if (special_col == SpecialColumn::MISNAMED) { + misnamed_col = SelectStringAtRandom(scan.projected_cols); + } + for (const auto& col_name : scan.projected_cols) { + int col_idx = schema_.find_column(col_name); + auto* projected_column = pb.add_projected_columns(); + if (misnamed_col && col_name == *misnamed_col) { + CHECK(special_col == SpecialColumn::MISNAMED); + MisnamedColumnSchemaToPB(client_schema.column(col_idx), projected_column); + } else { + ColumnSchemaToPB(client_schema.column(col_idx), projected_column); + } + } + if (special_col == SpecialColumn::VIRTUAL) { + auto* projected_column = pb.add_projected_columns(); + bool default_bool = false; + ColumnSchemaToPB(ColumnSchema("is_deleted", DataType::IS_DELETED, /*is_nullable=*/false, + /*read_default=*/&default_bool, nullptr), projected_column); + } + CHECK_OK(GenerateAuthzToken(privilege, pb.mutable_authz_token())); + return pb; + } + + // Populates fields of a split-key request based on the scan descriptor. + SplitKeyRangeRequestPB GenerateSplitKeyRequest(const ScanDescriptor& scan, + SpecialColumn special_col) const { + // Split key requests have no projections and therefore can't use virtual + // columns that don't exist in the tablet schema (e.g. IS_DELETED columns). + CHECK(special_col == SpecialColumn::MISNAMED || special_col == SpecialColumn::NONE); + + // Split-key requests are special in that they are really just projecting + // and predicating on the same set of columns. Since that's the case, just + // create a request that has the union of the described scan. + unordered_set<string> cols = scan.projected_cols; + cols.insert(scan.predicated_cols.begin(), scan.predicated_cols.end()); + SplitKeyRangeRequestPB split_pb; + split_pb.set_tablet_id(kScanTabletId); + Schema client_schema = schema_.CopyWithoutColumnIds(); + + // Determine which column to sabotage if needed. + boost::optional<string> misnamed_col; + if (special_col == SpecialColumn::MISNAMED) { + misnamed_col = SelectStringAtRandom(cols); + } + for (const auto& col_name : cols) { + int col_idx = client_schema.find_column(col_name); + if (misnamed_col && col_name == *misnamed_col) { + MisnamedColumnSchemaToPB(client_schema.column(col_idx), split_pb.add_columns()); + } else { + ColumnSchemaToPB(client_schema.column(col_idx), split_pb.add_columns()); + } + } + // Set an arbitrary chunk size. + split_pb.set_target_chunk_size_bytes(100); + + // Set arbitrary primary key bounds if needed. + if (scan.use_pk) { + *split_pb.mutable_start_primary_key() = GenerateEncodedKey(0, schema_); + *split_pb.mutable_stop_primary_key() = GenerateEncodedKey(100, schema_); + } + return split_pb; + } + + // Sends a scan based on 'scan' with a token described by 'privilege'. + Status SendNewScan(const ScanDescriptor& scan, const ScanPrivileges& privilege, + DeprecatedField range_predicate, SpecialColumn special_col) const { + ScanResponsePB resp; + RpcController rpc; + ScanRequestPB req; + *req.mutable_new_scan_request() = GenerateScanRequest(scan, privilege, + range_predicate, special_col); + req.set_call_seq_id(0); + RETURN_NOT_OK(proxy_->Scan(req, &resp, &rpc)); + return CheckNoErrors(resp); + } + + // Sends a checksum scan based on 'scan' with a token described by + // 'privilege'. + Status SendChecksum(const ScanDescriptor& scan, const ScanPrivileges& privilege, + DeprecatedField range_predicate, SpecialColumn special_col) const { + ChecksumResponsePB resp; + RpcController rpc; + ChecksumRequestPB req; + NewScanRequestPB* new_scan_req = req.mutable_new_request(); + *new_scan_req = GenerateScanRequest(scan, privilege, range_predicate, special_col); + req.set_call_seq_id(0); + RETURN_NOT_OK(proxy_->Checksum(req, &resp, &rpc)); + return CheckNoErrors(resp); + } + + // Sends a split-key request based on 'scan' with a token described by + // 'privilege'. + Status SendSplitKey(const ScanDescriptor& scan, const ScanPrivileges& privilege, + SpecialColumn special_col) const { + SplitKeyRangeResponsePB resp; + RpcController rpc; + SplitKeyRangeRequestPB req = GenerateSplitKeyRequest(scan, special_col); + RETURN_NOT_OK(GenerateAuthzToken(privilege, req.mutable_authz_token())); + RETURN_NOT_OK(proxy_->SplitKeyRange(req, &resp, &rpc)); + return CheckNoErrors(resp); + } + + // Sends a scan request and checks that the response matches the expected + // output based on 'is_authorized'. + void CheckPrivileges(const ScanFunc& send_req, const ScanDescriptor& scan, + const ScanPrivileges& privileges, ExpectedAuthz is_authorized, + const char* error = "not authorized") { + Status s = send_req(this, scan, privileges); + if (is_authorized == ExpectedAuthz::ALLOWED) { + ASSERT_OK(s); + } else { + ASSERT_TRUE(s.IsRemoteError()) << s.ToString(); + ASSERT_STR_CONTAINS(s.ToString(), error); + } + } + + // Returns a randomly selected set of column names, of at least size + // 'min_returned'. + unordered_set<string> RandomColumnNames(int min_returned = 0) const { + CHECK_LE(min_returned, col_names_.size()); + int num_cols_to_return = min_returned + rand() % (col_names_.size() - min_returned); + vector<string> rand_privileges = col_names_; + std::random_shuffle(rand_privileges.begin(), rand_privileges.end()); + rand_privileges.resize(num_cols_to_return); + unordered_set<string> rand_set(rand_privileges.begin(), rand_privileges.end()); + return rand_set; + } + + protected: + Schema schema_; + + // The column names, the first `kNumKeys` of which are keys. + vector<string> col_names_; + + // Mapping from column names to column ID, useful for building tokens (which + // are ID-based) from client-side info (name-based). + ColumnNamesToIds name_to_id_; + + // Signer used to create authz tokens. + unique_ptr<TokenSigner> signer_; +}; + +namespace { + +// Functors for performing scan-like requests with which to parameterize tests. +template<DeprecatedField d, SpecialColumn c> +Status ScanRequestor(ScanPrivilegeAuthzTest* test, const ScanDescriptor& scan, + const ScanPrivileges& privileges) { + return test->SendNewScan(scan, privileges, d, c); +} +template<DeprecatedField d, SpecialColumn c> +Status ChecksumRequestor(ScanPrivilegeAuthzTest* test, const ScanDescriptor& scan, + const ScanPrivileges& privileges) { + return test->SendChecksum(scan, privileges, d, c); +} +template<DeprecatedField d, SpecialColumn c> +Status SplitKeyRangeRequestor(ScanPrivilegeAuthzTest* test, const ScanDescriptor& scan, + const ScanPrivileges& privileges) { + return test->SendSplitKey(scan, privileges, c); +} + +// Removes a column at random from 'privilege' out of those in 'candidates'. +// Populates 'removed' with the column name that was removed, and returns +// whether anything was actually removed. +bool RemovePrivilege(const unordered_set<string>& candidates, + ScanPrivileges* privilege, string* removed) { + if (candidates.empty()) { + return false; + } + vector<string> candidates_list(candidates.begin(), candidates.end()); + int index_to_remove = rand() % candidates.size(); + string to_remove = candidates_list[index_to_remove]; + const auto& col_privileges = privilege->col_privileges; + const auto& iter_to_remove = col_privileges.find(to_remove); + if (iter_to_remove == col_privileges.end()) { + return false; + } + privilege->col_privileges.erase(iter_to_remove); + *removed = to_remove; + return true; +} + +// Removes a column privilege at random from 'privilege'. +void RemoveColumnPrivilege(ScanPrivileges* privilege) { + string removed; + CHECK(RemovePrivilege(privilege->col_privileges, privilege, &removed)); + LOG(INFO) << Substitute("Removed privilege for column $0", removed); +} + +} // anonymous namespace + +// Test scan privileges when not authorized with full scan privileges. +TEST_P(ScanPrivilegeAuthzTest, TestPartialScanPrivileges) { + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + // Put together a scan that projects and predicates on some columns. + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = { "key1", "key2", "val5", "val6" }, + .predicated_cols = { "key3", "val7" }, + }; + ScanPrivileges privileges; + if (!use_pk) { + // For a scan that doesn't use the primary key, we only need the privileges + // on the union of the projected columns and the predicate columns. + privileges = { + .full_privileges = false, + .col_privileges = { "key1", "key2", "key3", "val5", "val6", "val7" } + }; + } else { + // For a scan that does use the primary key, we also need to include the + // full list of columns that comprise the primary key. + privileges = { + .full_privileges = false, + .col_privileges = { "key0", "key1", "key2", "key3", "key4", "val5", "val6", "val7" } + }; + } + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + RemoveColumnPrivilege(&privileges); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); +} + +// Similar to the above test, but randomized. +TEST_P(ScanPrivilegeAuthzTest, TestPartialScanPrivilegesRandomized) { + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + ScanDescriptor scan = { + .use_pk = use_pk, + // Some scan-like requests treat 0 projected columns specially (e.g. this + // is a count operation projecting all columns). For the purposes of + // checking all other cases, enforce that we project at least one column. + .projected_cols = RandomColumnNames(/*min_returned=*/1), + .predicated_cols = RandomColumnNames(), + }; + // We'll start with all column privileges and widdle our way down, avoiding + // removal of columns that we need to perform our scan. + ScanPrivileges privileges = { + .full_privileges = false, + .col_privileges = unordered_set<string>(col_names_.begin(), col_names_.end()) + }; + // Keep track of the columns we need -- the projected columns, predicated + // columns, and primary keys if the scan calls for it. + unordered_set<string> required_columns(scan.projected_cols.begin(), scan.projected_cols.end()); + required_columns.insert(scan.predicated_cols.begin(), scan.predicated_cols.end()); + if (use_pk) { + for (int i = 0; i < kNumKeys; i++) { + required_columns.insert(col_names_[i]); + } + } + unordered_set<string> unneeded_cols(col_names_.begin(), col_names_.end()); + for (const string& col : required_columns) { + unneeded_cols.erase(col); + } + // Remove a bunch of unneeded columns first. We should continue to be + // authorized to scan. + int unneeded_cols_to_remove = unneeded_cols.empty() ? 0 : rand() % unneeded_cols.size(); + string removed; + for (int i = 0; i < unneeded_cols_to_remove; i++) { + CHECK(RemovePrivilege(unneeded_cols, &privileges, &removed)); + unneeded_cols.erase(removed); + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + } + // The moment we remove a required column, we should be denied access. + ASSERT_TRUE(RemovePrivilege(required_columns, &privileges, &removed)); + LOG(INFO) << Substitute("Removed privilege for column $0", removed); + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); +} + +// Test that we can scan anything when granted full scan privileges. +TEST_P(ScanPrivilegeAuthzTest, TestFullScanPrivileges) { + const int kNumRequests = 10; + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + for (int i = 0; i < kNumRequests; i++) { + ScanPrivileges privileges = { + .full_privileges = true, + }; + // Add privileges at random. Since we have full scan privileges, these + // shouldn't affect our ability to scan whatsoever, but let's do so as a + // sanity check. + privileges.col_privileges = RandomColumnNames(); + + // Randomly generate a scan. Whatever it is, we should be able to scan it. + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = RandomColumnNames(), + .predicated_cols = RandomColumnNames() + }; + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + } +} + +// Test that we get something sensible when using a token that doesn't match +// the request's table ID. +TEST_P(ScanPrivilegeAuthzTest, TestWrongTableId) { + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + // Set up a scan that we are authorized to do, but generate a token with the + // wrong table ID for it. + ScanPrivileges privileges = { + .full_privileges = true, + .col_privileges = unordered_set<string>(), + .table_id = "wrong-table-id", + }; + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = RandomColumnNames(), + .predicated_cols = RandomColumnNames() + }; + const auto check_wrong_table = [&] { + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED, + "authorization token is for the wrong table ID")); + }; + NO_FATALS(check_wrong_table()); + // Do the same for a scan that we aren't authorized to perform. + privileges.full_privileges = false; + NO_FATALS(check_wrong_table()); +} + +INSTANTIATE_TEST_CASE_P(RequestorFuncs, ScanPrivilegeAuthzTest, + ::testing::Combine( + ::testing::ValuesIn(vector<ScanFunc>({ + &ScanRequestor<DeprecatedField::DONT_USE, SpecialColumn::NONE>, + &ScanRequestor<DeprecatedField::USE, SpecialColumn::NONE>, + &ChecksumRequestor<DeprecatedField::DONT_USE, SpecialColumn::NONE>, + &ChecksumRequestor<DeprecatedField::USE, SpecialColumn::NONE>, + &SplitKeyRangeRequestor<DeprecatedField::DONT_USE, SpecialColumn::NONE>, + &SplitKeyRangeRequestor<DeprecatedField::USE, SpecialColumn::NONE> + })), + ::testing::Bool())); + +class ScanPrivilegeNoProjectionAuthzTest : public ScanPrivilegeAuthzTest {}; + +// Test that for scans and checksums that have no projection, we require +// privileges on all columns. +TEST_P(ScanPrivilegeNoProjectionAuthzTest, TestNoProjection) { + const int kNumRequests = 10; + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + for (int i = 0; i < kNumRequests; i++) { + ScanPrivileges privileges = { + .full_privileges = false, + .col_privileges = unordered_set<string>(col_names_.begin(), col_names_.end()), + }; + // Randomly generate a scan with no projected columns. + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = unordered_set<string>(), + .predicated_cols = RandomColumnNames() + }; + { + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + } + RemoveColumnPrivilege(&privileges); + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); + } +} +INSTANTIATE_TEST_CASE_P(RequestorFuncs, ScanPrivilegeNoProjectionAuthzTest, + ::testing::Combine( + ::testing::ValuesIn(vector<ScanFunc>({ + &ScanRequestor<DeprecatedField::DONT_USE, SpecialColumn::NONE>, + &ScanRequestor<DeprecatedField::USE, SpecialColumn::NONE>, + &ChecksumRequestor<DeprecatedField::DONT_USE, SpecialColumn::NONE>, + &ChecksumRequestor<DeprecatedField::USE, SpecialColumn::NONE>, + })), + ::testing::Bool())); + +class ScanPrivilegeVirtualColumnsTest : public ScanPrivilegeAuthzTest {}; + +TEST_P(ScanPrivilegeVirtualColumnsTest, TestNoProjection) { + const int kNumRequests = 10; + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + for (int i = 0; i < kNumRequests; i++) { + ScanPrivileges privileges = { + .full_privileges = false, + .col_privileges = unordered_set<string>(col_names_.begin(), col_names_.end()), + }; + // Randomly generate a scan with no projected columns. + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = RandomColumnNames(/*min_returned=*/1), + .predicated_cols = RandomColumnNames() + }; + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + RemoveColumnPrivilege(&privileges); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); + } +} + +class ScanPrivilegeWithBadNamesTest: public ScanPrivilegeAuthzTest {}; + +// Send a request with a projection on a column that don't exist. Unless the +// user has full scan privileges, the client should just get back a +// non-authorized error, rather than a more information-rich one. +TEST_P(ScanPrivilegeWithBadNamesTest, TestColumnNotFound) { + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = RandomColumnNames(/*min_returned=*/1), + .predicated_cols = RandomColumnNames(), + }; + ScanPrivileges privileges = { + .full_privileges = false, + .col_privileges = unordered_set<string>(col_names_.begin(), col_names_.end()) + }; + { + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); + } + privileges = { + .full_privileges = true, + }; + // Now send the request with full scan privileges. We should be able to see + // the bad column name. + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + Status s = req_func(this, scan, privileges); + ASSERT_TRUE(s.IsInvalidArgument()); + ASSERT_STR_CONTAINS(s.ToString(), kDummyColumn); +} +INSTANTIATE_TEST_CASE_P(RequestorFuncs, ScanPrivilegeWithBadNamesTest, + ::testing::Combine( + ::testing::ValuesIn(vector<ScanFunc>({ + &ScanRequestor<DeprecatedField::DONT_USE, SpecialColumn::MISNAMED>, + &ScanRequestor<DeprecatedField::USE, SpecialColumn::MISNAMED>, + &ChecksumRequestor<DeprecatedField::DONT_USE, SpecialColumn::MISNAMED>, + &ChecksumRequestor<DeprecatedField::USE, SpecialColumn::MISNAMED>, + &SplitKeyRangeRequestor<DeprecatedField::DONT_USE, SpecialColumn::MISNAMED>, + &SplitKeyRangeRequestor<DeprecatedField::USE, SpecialColumn::MISNAMED> + })), + ::testing::Bool())); + +class ScanPrivilegeWithVirtualColumnsTest: public ScanPrivilegeAuthzTest {}; + +TEST_P(ScanPrivilegeWithVirtualColumnsTest, TestIsDeletedColumn) { + const ScanFunc& req_func = std::get<0>(GetParam()); + bool use_pk = std::get<1>(GetParam()); + ScanDescriptor scan = { + .use_pk = use_pk, + .projected_cols = RandomColumnNames(/*min_returned=*/1), + .predicated_cols = RandomColumnNames(), + }; + + // Send out the request with full scan privileges. + ScanPrivileges privileges = { + .full_privileges = true, + }; + { + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + } + privileges = { + .full_privileges = false, + .col_privileges = unordered_set<string>(col_names_.begin(), col_names_.end()) + }; + { + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::ALLOWED)); + } + + // Generate privileges that don't have all column privileges. The presence + // of a virtual column should require all privileges, so the request should + // be denied. + RemoveColumnPrivilege(&privileges); + SCOPED_TRACE(privileges.ToString()); + SCOPED_TRACE(scan.ToString()); + NO_FATALS(CheckPrivileges(req_func, scan, privileges, ExpectedAuthz::DENIED)); +} +INSTANTIATE_TEST_CASE_P(RequestorFuncs, ScanPrivilegeWithVirtualColumnsTest, + ::testing::Combine( + ::testing::ValuesIn(vector<ScanFunc>({ + &ScanRequestor<DeprecatedField::DONT_USE, SpecialColumn::VIRTUAL>, + &ScanRequestor<DeprecatedField::USE, SpecialColumn::VIRTUAL>, + &ChecksumRequestor<DeprecatedField::DONT_USE, SpecialColumn::VIRTUAL>, + &ChecksumRequestor<DeprecatedField::USE, SpecialColumn::VIRTUAL>, + })), + ::testing::Bool())); + } // namespace tserver } // namespace kudu diff --git a/src/kudu/tserver/tablet_service.cc b/src/kudu/tserver/tablet_service.cc index 010b35b..916c4c5 100644 --- a/src/kudu/tserver/tablet_service.cc +++ b/src/kudu/tserver/tablet_service.cc @@ -392,19 +392,164 @@ static StdStatusCallback BindHandleResponse( typedef ListTabletsResponsePB::StatusAndSchemaPB StatusAndSchemaPB; -// If the privilege has neither full scan privileges nor column-level scan -// privileges, the user is definitely not authorized to perform a scan. -bool MayHaveScanPrivileges(const security::TablePrivilegePB& privilege) { - if (privilege.scan_privilege()) { +// Populates 'required_column_privileges' with the column-level privileges +// required to perform the scan specified by 'scan_pb', consulting the column +// IDs found in 'schema'. +// +// Users of NewScanRequestPB (e.g. Scans and Checksums) require the following +// privileges: +// if no projected columns (i.e. a "counting" scan) || +// projected columns has virtual column (e.g. "diff" scan): +// SCAN ON TABLE || foreach (column): SCAN ON COLUMN +// else: +// if uses pk (e.g. ORDERED scan, or primary key fields set): +// foreach(primary key column): SCAN ON COLUMN +// foreach(projected column): SCAN ON COLUMN +// foreach(predicated column): SCAN ON COLUMN +// +// Returns false if the request is malformed (e.g. unknown non-virtual column +// name), and sends an error response via 'context' if so. 'req_type' is used +// to add context in logs. +static bool GetScanPrivilegesOrRespond(const NewScanRequestPB& scan_pb, const Schema& schema, + const string& req_type, + unordered_set<ColumnId>* required_column_privileges, + RpcContext* context) { + const auto respond_not_authorized = [&] (const string& col_name) { + LOG(WARNING) << Substitute("rejecting $0 request from $1: no column named '$2'", + req_type, context->requestor_string(), col_name); + context->RespondRpcFailure(rpc::ErrorStatusPB::FATAL_UNAUTHORIZED, + Status::NotAuthorized(Substitute("not authorized to $0", req_type))); + }; + // If there is no projection (i.e. this is a "counting" scan), the user + // needs full scan privileges on the table. + if (scan_pb.projected_columns_size() == 0) { + *required_column_privileges = unordered_set<ColumnId>(schema.column_ids().begin(), + schema.column_ids().end()); return true; } + unordered_set<ColumnId> required_privileges; + // Determine the scan's projected key column IDs. + for (int i = 0; i < scan_pb.projected_columns_size(); i++) { + const auto& projected_column = ColumnSchemaFromPB(scan_pb.projected_columns(i)); + // A projection may contain virtual columns, which don't exist in the + // tablet schema. If we were to search for a virtual column, we would + // incorrectly get a "not found" error. To reconcile this with the fact + // that we want to return an authorization error if the user has requested + // a non-virtual column that doesn't exist, we require full scan privileges + // for virtual columns. + if (projected_column.type_info()->is_virtual()) { + *required_column_privileges = unordered_set<ColumnId>(schema.column_ids().begin(), + schema.column_ids().end()); + return true; + } + int col_idx = schema.find_column(projected_column.name()); + if (col_idx == Schema::kColumnNotFound) { + respond_not_authorized(scan_pb.projected_columns(i).name()); + return false; + } + EmplaceIfNotPresent(&required_privileges, schema.column_id(col_idx)); + } + // Ordered scans and any scans that make use of the primary key require + // privileges to scan across all primary key columns. + if (scan_pb.order_mode() == ORDERED || + scan_pb.has_start_primary_key() || + scan_pb.has_stop_primary_key() || + scan_pb.has_last_primary_key()) { + const auto& key_cols = schema.get_key_column_ids(); + required_privileges.insert(key_cols.begin(), key_cols.end()); + } + // Determine the scan's predicate column IDs. + for (int i = 0; i < scan_pb.column_predicates_size(); i++) { + int col_idx = schema.find_column(scan_pb.column_predicates(i).column()); + if (col_idx == Schema::kColumnNotFound) { + respond_not_authorized(scan_pb.column_predicates(i).column()); + return false; + } + EmplaceIfNotPresent(&required_privileges, schema.column_id(col_idx)); + } + // Do the same for the DEPRECATED_range_predicates field. Even though this + // field is deprecated, it is still exposed as a part of our public API and + // thus needs to be taken into account. + for (int i = 0; i < scan_pb.deprecated_range_predicates_size(); i++) { + int col_idx = schema.find_column(scan_pb.deprecated_range_predicates(i).column().name()); + if (col_idx == Schema::kColumnNotFound) { + respond_not_authorized(scan_pb.deprecated_range_predicates(i).column().name()); + return false; + } + EmplaceIfNotPresent(&required_privileges, schema.column_id(col_idx)); + } + *required_column_privileges = std::move(required_privileges); + return true; +} + +// Checks the column-level privileges required to perform the scan specified by +// 'scan_pb' against the authorized column IDs listed in +// 'authorized_column_ids', consulting the column IDs found in 'schema'. +// +// Returns false if the scan isn't authorized and uses 'context' to send an +// error response. 'req_type' is used for logging'. +static bool CheckScanPrivilegesOrRespond(const NewScanRequestPB& scan_pb, const Schema& schema, + const unordered_set<ColumnId>& authorized_column_ids, + const string& req_type, RpcContext* context) { + unordered_set<ColumnId> required_column_privileges; + if (!GetScanPrivilegesOrRespond(scan_pb, schema, req_type, + &required_column_privileges, context)) { + return false; + } + for (const auto& required_col_id : required_column_privileges) { + if (!ContainsKey(authorized_column_ids, required_col_id)) { + LOG(WARNING) << Substitute("rejecting $0 request from $1: authz token doesn't " + "authorize column ID $2", req_type, context->requestor_string(), + required_col_id); + context->RespondRpcFailure(rpc::ErrorStatusPB::FATAL_UNAUTHORIZED, + Status::NotAuthorized(Substitute("not authorized to $0", req_type))); + return false; + } + } + return true; +} + +// Returns false if the table ID of 'privilege' doesn't match 'table_id', +// responding with an error via 'context' if so. Otherwise, returns true. +// 'req_type' is used for logging purposes. +static bool CheckMatchingTableId(const security::TablePrivilegePB& privilege, + const string& table_id, const string& req_type, + RpcContext* context) { + if (privilege.table_id() != table_id) { + LOG(WARNING) << Substitute("rejecting $0 request from $1: '$2', expected '$3'", + req_type, context->requestor_string(), + privilege.table_id(), table_id); + context->RespondRpcFailure(rpc::ErrorStatusPB::ERROR_INVALID_AUTHORIZATION_TOKEN, + Status::NotAuthorized("authorization token is for the wrong table ID")); + return false; + } + return true; +} + +// Returns false if the privilege has neither full scan privileges nor any +// column-level scan privileges, in which case any scan-like request should be +// rejected. Otherwise returns true, and returns any column-level scan +// privileges in 'privilege'. +static bool CheckMayHaveScanPrivilegesOrRespond(const security::TablePrivilegePB& privilege, + const string& req_type, + unordered_set<ColumnId>* authorized_column_ids, + RpcContext* context) { + DCHECK(authorized_column_ids); + DCHECK(authorized_column_ids->empty()); if (privilege.column_privileges_size() > 0) { for (const auto& col_id_and_privilege : privilege.column_privileges()) { if (col_id_and_privilege.second.scan_privilege()) { - return true; + EmplaceOrDie(authorized_column_ids, col_id_and_privilege.first); } } } + if (privilege.scan_privilege() || !authorized_column_ids->empty()) { + return true; + } + LOG(WARNING) << Substitute("rejecting $0 request from $1: no column privileges", + req_type, context->requestor_string()); + context->RespondRpcFailure(rpc::ErrorStatusPB::FATAL_UNAUTHORIZED, + Status::NotAuthorized(Substitute("not authorized to $0", req_type))); return false; } @@ -446,29 +591,6 @@ static bool VerifyAuthzTokenOrRespond(const TokenVerifier& token_verifier, return true; } -// Verifies the given scan-like request (e.g. Scan, Checksum, SplitKeyRange) -// 'req', checking for any scan privileges. Returns false if the request's -// authz token is invalid or does not have any scan privileges, in which case, -// 'context' will be used to respond with an error. Otherwise, returns true, -// and the privileges in 'token' should be used to further verify the request. -template <class AuthorizableScanRequest> -static bool VerifyHasAnyScanPrivileges(const TokenVerifier& token_verifier, - const AuthorizableScanRequest& req, - const char* not_authorized_str, - rpc::RpcContext* context, - TokenPB* token) { - if (!VerifyAuthzTokenOrRespond(token_verifier, req, context, token)) { - return false; - } - const auto& privilege = token->authz().table_privilege(); - if (!MayHaveScanPrivileges(privilege)) { - context->RespondRpcFailure(rpc::ErrorStatusPB::FATAL_UNAUTHORIZED, - Status::NotAuthorized(not_authorized_str)); - return false; - } - return true; -} - static void SetupErrorAndRespond(TabletServerErrorPB* error, const Status& s, TabletServerErrorPB::Code code, @@ -1461,17 +1583,35 @@ void TabletServiceImpl::Scan(const ScanRequestPB* req, } // If this is a new scan request, we must enforce the appropriate privileges. - string authorized_table_id; - unordered_set<int> authorized_column_ids; TokenPB token; if (FLAGS_tserver_enforce_access_control && req->has_new_scan_request()) { - if (!VerifyHasAnyScanPrivileges(server_->token_verifier(), req->new_scan_request(), - "not authorized to scan", context, &token)) { + const auto& scan_pb = req->new_scan_request(); + if (!VerifyAuthzTokenOrRespond(server_->token_verifier(), + req->new_scan_request(), context, &token)) { return; } - // TODO(awong): check the privileges required for the contents of the scan - // request by pulling out the columns and checking against individual - // column privileges. + scoped_refptr<TabletReplica> replica; + if (!LookupRunningTabletReplicaOrRespond(server_->tablet_manager(), + req->new_scan_request().tablet_id(), resp, context, &replica)) { + return; + } + const auto& privilege = token.authz().table_privilege(); + if (!CheckMatchingTableId(privilege, replica->tablet_metadata()->table_id(), "Scan", context)) { + return; + } + unordered_set<ColumnId> authorized_column_ids; + if (!CheckMayHaveScanPrivilegesOrRespond(privilege, "Scan", &authorized_column_ids, context)) { + return; + } + // If the token doesn't have full scan privileges for the table, check + // for required privileges based on the scan request. + if (!privilege.scan_privilege()) { + const auto& schema = replica->tablet_metadata()->schema(); + if (!CheckScanPrivilegesOrRespond(scan_pb, schema, authorized_column_ids, + "Scan", context)) { + return; + } + } } size_t batch_size_bytes = GetMaxBatchSizeBytesHint(req); @@ -1575,13 +1715,65 @@ void TabletServiceImpl::SplitKeyRange(const SplitKeyRangeRequestPB* req, DVLOG(3) << "Received SplitKeyRange RPC: " << SecureDebugString(*req); TokenPB token; if (FLAGS_tserver_enforce_access_control) { - if (!VerifyHasAnyScanPrivileges(server_->token_verifier(), *req, - "not authorized to split key range", context, &token)) { + if (!VerifyAuthzTokenOrRespond(server_->token_verifier(), *req, context, &token)) { return; } - // TODO(awong): check the privileges required for the contents of the - // split-key request by pulling out the columns and checking against - // individual column privileges. + const auto& privilege = token.authz().table_privilege(); + scoped_refptr<TabletReplica> replica; + if (!LookupRunningTabletReplicaOrRespond(server_->tablet_manager(), req->tablet_id(), resp, + context, &replica)) { + return; + } + if (!CheckMatchingTableId(privilege, replica->tablet_metadata()->table_id(), + "SplitKeyRange", context)) { + return; + } + // Split-key requests require: + // if uses pk (e.g. primary key fields set): + // foreach(primary key column): SCAN ON COLUMN + // foreach(requested column): SCAN ON COLUMN + // + // If the privilege doesn't have full scan privileges, or column-level scan + // privileges, the user is definitely not authorized to perform a scan. + unordered_set<ColumnId> authorized_column_ids; + if (!CheckMayHaveScanPrivilegesOrRespond(privilege, "SplitKeyRange", + &authorized_column_ids, context)) { + return; + } + if (!privilege.scan_privilege()) { + const auto& schema = replica->tablet_metadata()->schema(); + unordered_set<ColumnId> required_column_privileges; + if (req->has_start_primary_key() || req->has_stop_primary_key()) { + const auto& key_cols = schema.get_key_column_ids(); + required_column_privileges.insert(key_cols.begin(), key_cols.end()); + } + bool is_authorized = true; + const string rejection_prefix = Substitute("rejecting SplitKeyRange request from $0", + context->requestor_string()); + for (int i = 0; i < req->columns_size(); i++) { + const auto& column_name = req->columns(i).name(); + int col_idx = schema.find_column(req->columns(i).name()); + if (col_idx == Schema::kColumnNotFound) { + LOG(WARNING) << Substitute("$0: no column named '$1'", rejection_prefix, column_name); + is_authorized = false; + break; + } + EmplaceIfNotPresent(&required_column_privileges, schema.column_id(col_idx)); + } + for (const auto& required_col_id : required_column_privileges) { + if (!ContainsKey(authorized_column_ids, required_col_id)) { + LOG(WARNING) << Substitute("$0: authz token doesn't authorize column ID $1", + rejection_prefix, required_col_id); + is_authorized = false; + break; + } + } + if (!is_authorized) { + context->RespondRpcFailure(rpc::ErrorStatusPB::FATAL_UNAUTHORIZED, + Status::NotAuthorized("not authorized to SplitKeyRange")); + return; + } + } } scoped_refptr<TabletReplica> replica; @@ -1589,7 +1781,6 @@ void TabletServiceImpl::SplitKeyRange(const SplitKeyRangeRequestPB* req, context, &replica)) { return; } - shared_ptr<Tablet> tablet; TabletServerErrorPB::Code error_code; Status s = GetTabletRef(replica, &tablet, &error_code); @@ -1707,19 +1898,41 @@ void TabletServiceImpl::Checksum(const ChecksumRequestPB* req, ScanResultChecksummer collector; bool has_more = false; TabletServerErrorPB::Code error_code = TabletServerErrorPB::UNKNOWN_ERROR; - if (req->has_new_request()) { - if (FLAGS_tserver_enforce_access_control) { - TokenPB token; - if (!VerifyHasAnyScanPrivileges(server_->token_verifier(), req->new_request(), - "not authorized to checksum", context, &token)) { + if (FLAGS_tserver_enforce_access_control && req->has_new_request()) { + const NewScanRequestPB& new_req = req->new_request(); + TokenPB token; + if (!VerifyAuthzTokenOrRespond(server_->token_verifier(), req->new_request(), + context, &token)) { + return; + } + scoped_refptr<TabletReplica> replica; + if (!LookupRunningTabletReplicaOrRespond(server_->tablet_manager(), new_req.tablet_id(), resp, + context, &replica)) { + return; + } + const auto& privilege = token.authz().table_privilege(); + if (!CheckMatchingTableId(privilege, replica->tablet_metadata()->table_id(), + "Checksum", context)) { + return; + } + unordered_set<ColumnId> authorized_column_ids; + if (!CheckMayHaveScanPrivilegesOrRespond(privilege, "Checksum", + &authorized_column_ids, context)) { + return; + } + // If the token doesn't have full scan privileges for the table, check + // for required privileges based on the checksum request. + if (!privilege.scan_privilege()) { + const auto& schema = replica->tablet_metadata()->schema(); + if (!CheckScanPrivilegesOrRespond(new_req, schema, authorized_column_ids, + "Checksum", context)) { return; } - // TODO(awong): check the privileges required for the contents of the - // checksum request by pulling out the columns and checking against - // individual column privileges. } - scan_req.mutable_new_scan_request()->CopyFrom(req->new_request()); + } + if (req->has_new_request()) { const NewScanRequestPB& new_req = req->new_request(); + scan_req.mutable_new_scan_request()->CopyFrom(req->new_request()); scoped_refptr<TabletReplica> replica; if (!LookupRunningTabletReplicaOrRespond(server_->tablet_manager(), new_req.tablet_id(), resp, context, &replica)) {
