diff --git a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.cc b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.cc index 4df05930b0..f3fd68904a 100644 --- a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.cc +++ b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.cc @@ -66,6 +66,7 @@ std::string AdvanceOptions::private_service_connect_uris_; std::string AdvanceOptions::enable_gcd_; std::string AdvanceOptions::universe_domain_; std::string AdvanceOptions::maximum_bytes_billed_; +std::string AdvanceOptions::allowed_projects_; std::string const kLanguageDialect = "SQLDialect"; std::string const kLargeResultsDatasetId = "LargeResultsDatasetId"; @@ -90,6 +91,7 @@ std::string const kPrivateServiceConnectUris = "PrivateServiceConnectUris"; std::string const kEnableGcd = "EnableGCD"; std::string const kUniverseDomain = "UniverseDomain"; std::string const kMaximumBytesBilled = "MaximumBytesBilled"; +std::string const kAllowedProjects = "AllowedProjects"; // Control dimensions and positions int const kHeight = 20; @@ -383,11 +385,11 @@ void AdvanceOptions::CreateAdditionalControls(HFONT h_font) { // max retries HWND h_max_retries_label = - CreateLabel(adv_hwnd, "Max Retries:", kXAxis, kYAxis + 465, kWidth * 7, + CreateLabel(adv_hwnd, "Max Retries:", kXAxis, kYAxis + 460, kWidth * 7, kHeight, WS_VISIBLE | SS_LEFT); SendMessage(h_max_retries_label, WM_SETFONT, (WPARAM)h_font, TRUE); HWND h_max_retries_edit = - CreateEditBox(adv_hwnd, kinputComboBoxXAxis, kYAxis + 465, kEditBoxWidth, + CreateEditBox(adv_hwnd, kinputComboBoxXAxis, kYAxis + 460, kEditBoxWidth, kEditBoxHeight, kIdcMaxRetriesEdit); SendMessage(h_max_retries_edit, WM_SETFONT, (WPARAM)h_font, TRUE); SetWindowSubclass(GetDlgItem(adv_hwnd, kIdcMaxRetriesEdit), InputSubclassProc, @@ -400,10 +402,10 @@ void AdvanceOptions::CreateAdditionalControls(HFONT h_font) { // maximum bytes billed HWND h_maximum_bytes_billed_label = CreateLabel(adv_hwnd, "Maximum bytes billed (bytes):", kXAxis, - kYAxis + 490, kWidth * 4 + 25, kHeight, WS_VISIBLE | SS_LEFT); + kYAxis + 485, kWidth * 4 + 25, kHeight, WS_VISIBLE | SS_LEFT); SendMessage(h_maximum_bytes_billed_label, WM_SETFONT, (WPARAM)h_font, TRUE); HWND h_maximum_bytes_billed_edit = - CreateEditBox(adv_hwnd, kinputComboBoxXAxis, kYAxis + 490, kEditBoxWidth, + CreateEditBox(adv_hwnd, kinputComboBoxXAxis, kYAxis + 485, kEditBoxWidth, kEditBoxHeight, kIdcMaximumBytesBilledEdit); SendMessage(h_maximum_bytes_billed_edit, WM_SETFONT, (WPARAM)h_font, TRUE); SetWindowSubclass(GetDlgItem(adv_hwnd, kIdcMaximumBytesBilledEdit), @@ -413,6 +415,19 @@ void AdvanceOptions::CreateAdditionalControls(HFONT h_font) { GetWindowLongPtr(h_maximum_bytes_billed_edit, GWL_STYLE) | ES_RIGHT | ES_NUMBER); + // allowed projects + HWND h_allowed_projects_label = + CreateLabel(adv_hwnd, "Allowed projects:", kXAxis, kYAxis + 510, + kWidth * 4 + 25, kHeight, WS_VISIBLE | SS_LEFT); + SendMessage(h_allowed_projects_label, WM_SETFONT, (WPARAM)h_font, TRUE); + HWND h_allowed_projects_edit = + CreateEditBox(adv_hwnd, kinputComboBoxXAxis, kYAxis + 510, kEditBoxWidth, + kEditBoxHeight, kIdcAllowedProjectsEdit); + SendMessage(h_allowed_projects_edit, WM_SETFONT, (WPARAM)h_font, TRUE); + SetWindowSubclass(GetDlgItem(adv_hwnd, kIdcAllowedProjectsEdit), + InputSubclassProc, 0, 0); + SetWindowText(h_allowed_projects_edit, allowed_projects_.c_str()); + // TODO(b/497725655): Enable UI feature after public release // HWND h_variables_checkbox = CreateCheckBox( // adv_hwnd, "Use SQL_WVARCHAR instead of SQL_VARCHAR", kXAxis, kYAxis + @@ -423,12 +438,12 @@ void AdvanceOptions::CreateAdditionalControls(HFONT h_font) { // SetWindowSubclass(GetDlgItem(adv_hwnd, kIdcVariableCheckbox), // CheckboxSubclassProc, 0, 0); HWND h_additional_projects_label = - CreateLabel(adv_hwnd, "Additional projects:", kXAxis, kYAxis + 515, + CreateLabel(adv_hwnd, "Additional projects:", kXAxis, kYAxis + 535, kWidth * 5, kHeight, WS_VISIBLE | SS_LEFT); SendMessage(h_additional_projects_label, WM_SETFONT, (WPARAM)h_font, TRUE); HWND h_additional_projects_edit = - CreateScrollableEditBox(adv_hwnd, kXAxis, kYAxis + 535, kWidth + 445, - kHeight + 16, kIdcAdditionalProjectsEdit); + CreateScrollableEditBox(adv_hwnd, kXAxis, kYAxis + 555, kWidth + 445, + kHeight + 6, kIdcAdditionalProjectsEdit); SendMessage(h_additional_projects_edit, WM_SETFONT, (WPARAM)h_font, TRUE); SetWindowText(h_additional_projects_edit, additional_projects_.c_str()); @@ -436,11 +451,11 @@ void AdvanceOptions::CreateAdditionalControls(HFONT h_font) { InputSubclassProc, 0, 0); HWND h_query_properties_label = - CreateLabel(adv_hwnd, "Query properties:", kXAxis, kYAxis + 575, + CreateLabel(adv_hwnd, "Query properties:", kXAxis, kYAxis + 585, kWidth * 5, kHeight, WS_VISIBLE | SS_LEFT); SendMessage(h_query_properties_label, WM_SETFONT, (WPARAM)h_font, TRUE); HWND h_query_properties_edit = - CreateScrollableEditBox(adv_hwnd, kXAxis, kYAxis + 595, kWidth + 445, + CreateScrollableEditBox(adv_hwnd, kXAxis, kYAxis + 605, kWidth + 445, kHeight + 6, kIdcQueryPropertiesEdit); SendMessage(h_query_properties_edit, WM_SETFONT, (WPARAM)h_font, TRUE); @@ -681,6 +696,13 @@ LRESULT CALLBACK AdvanceOptions::AdvanceOptProc(HWND hwnd, UINT u_msg, sizeof(additional_projects_buffer)); additional_projects_ = additional_projects_buffer; + HWND h_allowed_projects_edit = + GetDlgItem(hwnd, kIdcAllowedProjectsEdit); + char allowed_projects_buffer[1024] = {0}; + GetWindowText(h_allowed_projects_edit, allowed_projects_buffer, + sizeof(allowed_projects_buffer)); + allowed_projects_ = allowed_projects_buffer; + HWND h_query_properties_edit = GetDlgItem(hwnd, kIdcQueryPropertiesEdit); char query_properties_buffer[1024] = {0}; @@ -889,6 +911,7 @@ void AdvanceOptions::SetValues(Section const& attribute_map) { enable_gcd_ = GetValueOrDefault(attribute_map, kEnableGcd); universe_domain_ = GetValueOrDefault(attribute_map, kUniverseDomain); maximum_bytes_billed_ = GetValueOrDefault(attribute_map, kMaximumBytesBilled); + allowed_projects_ = GetValueOrDefault(attribute_map, kAllowedProjects); } void AdvanceOptions::ResetToDefaults() { @@ -914,6 +937,7 @@ void AdvanceOptions::ResetToDefaults() { universe_domain_.clear(); // Unset means no cap, which is the shipped default. maximum_bytes_billed_.clear(); + allowed_projects_.clear(); } void AdvanceOptions::Show(HWND hwnd) { diff --git a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.h b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.h index 35bd3fc785..308305d58b 100644 --- a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.h +++ b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form.h @@ -20,7 +20,7 @@ #pragma comment(lib, "Comctl32.lib") // Link with Comctl32.lib namespace google::cloud::odbc_bq_driver_internal { -// NEXTID:154 +// NEXTID:155 static int const kIdcUseDefaultCheckbox = 128; static int const kIdcDatasetNameEdit = 129; static int const kIdcTempExpirationEdit = 130; @@ -46,6 +46,7 @@ static int const kIdcEnablePscGcdCheckbox = 150; static int const kIdcPrivateServiceNameEdit = 151; static int const kIdcUniverseDomainEdit = 152; static int const kIdcMaximumBytesBilledEdit = 153; +static int const kIdcAllowedProjectsEdit = 154; class AdvanceOptions { public: @@ -109,6 +110,9 @@ class AdvanceOptions { inline std::string const& GetMaximumBytesBilled() const { return maximum_bytes_billed_; } + inline std::string const& GetAllowedProjects() const { + return allowed_projects_; + } void SetValues(Section const& attributes_map); void ResetToDefaults(); @@ -141,6 +145,7 @@ class AdvanceOptions { static std::string enable_gcd_; static std::string universe_domain_; static std::string maximum_bytes_billed_; + static std::string allowed_projects_; static LRESULT CALLBACK AdvanceOptProc(HWND hwnd, UINT uMsg, WPARAM w_param, LPARAM l_param); diff --git a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form_test.cc b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form_test.cc index 7af395755b..a211a4d2b2 100644 --- a/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form_test.cc +++ b/google/cloud/odbc/bq_driver/internal/driver_adv_opt_form_test.cc @@ -72,6 +72,31 @@ TEST_F(AdvanceOptionsTest, ShowWindow) { << "Window should be visible after calling Show."; } +TEST_F(AdvanceOptionsTest, AllowedProjectsRoundTripsThroughOk) { + // A saved value should appear in the field when the dialog opens, and OK + // should save back whatever the field then holds. + advance_options->SetValues({{"AllowedProjects", "project-a,project-b"}}); + + // Show() runs a modal GetMessage loop. Queue WM_QUIT first so it builds the + // controls and returns immediately instead of blocking this test; the window + // itself stays alive for the assertions below. + PostQuitMessage(0); + advance_options->Show(nullptr); + HWND hwnd = advance_options->GetHwnd(); + ASSERT_NE(hwnd, nullptr) << "Window should be created and displayed."; + + HWND h_edit = GetDlgItem(hwnd, kIdcAllowedProjectsEdit); + ASSERT_NE(h_edit, nullptr) << "Allowed projects field should be created."; + char buffer[256] = {0}; + GetWindowText(h_edit, buffer, sizeof(buffer)); + EXPECT_STREQ(buffer, "project-a,project-b"); + + SetWindowText(h_edit, "project-a"); + ClickButton(hwnd, kIdcOKButton); + + EXPECT_EQ(advance_options->GetAllowedProjects(), "project-a"); +} + TEST_F(AdvanceOptionsTest, SetValuesValidinput) { Section attribute_map = {{"SQLDialect", "1"}, {"LargeResultsDatasetId", "dataset1"}, @@ -81,6 +106,7 @@ TEST_F(AdvanceOptionsTest, SetValuesValidinput) { {"LargeResultsTempTableExpirationTime", "3600000"}, {"SessionLocation", "USA"}, {"AdditionalProjects", "projectA,projectB"}, + {"AllowedProjects", "projectC,projectD"}, {"QueryProperties", "property1=value1"}, {"MaxThreads", "10"}, {"MaxRetries", "9"}}; @@ -96,6 +122,7 @@ TEST_F(AdvanceOptionsTest, SetValuesValidinput) { EXPECT_EQ(options.GetTempTableExpiration(), "3600000"); EXPECT_EQ(options.GetSessionLocation(), "USA"); EXPECT_EQ(options.GetAdditionalProjects(), "projectA,projectB"); + EXPECT_EQ(options.GetAllowedProjects(), "projectC,projectD"); EXPECT_EQ(options.GetQueryProperties(), "property1=value1"); EXPECT_EQ(options.GetMaxThreads(), "10"); EXPECT_EQ(options.GetMaxRetries(), "9"); @@ -118,8 +145,19 @@ TEST_F(AdvanceOptionsTest, SetValuesMissingkeys) { EXPECT_EQ(options.GetTempTableExpiration(), "3600000"); EXPECT_EQ(options.GetSessionLocation(), ""); EXPECT_EQ(options.GetAdditionalProjects(), ""); + EXPECT_EQ(options.GetAllowedProjects(), ""); EXPECT_EQ(options.GetQueryProperties(), ""); EXPECT_EQ(options.GetAllowHtapiForLargeResultsCheckbox(), ""); } +TEST_F(AdvanceOptionsTest, ResetToDefaultsClearsAllowedProjects) { + AdvanceOptions options; + options.SetValues({{"AllowedProjects", "projectC,projectD"}}); + ASSERT_EQ(options.GetAllowedProjects(), "projectC,projectD"); + + options.ResetToDefaults(); + + EXPECT_EQ(options.GetAllowedProjects(), ""); +} + } // namespace google::cloud::odbc_bq_driver_internal diff --git a/google/cloud/odbc/bq_driver/internal/driver_form.cc b/google/cloud/odbc/bq_driver/internal/driver_form.cc index 9c521cd6b4..2c7bc8ea25 100644 --- a/google/cloud/odbc/bq_driver/internal/driver_form.cc +++ b/google/cloud/odbc/bq_driver/internal/driver_form.cc @@ -223,6 +223,7 @@ static Section BuildTestConnectionAttributes( attributes_map["SessionLocation"] = adv_form.GetSessionLocation(); attributes_map["MaxThreads"] = adv_form.GetMaxThreads(); attributes_map["AdditionalProjects"] = adv_form.GetAdditionalProjects(); + attributes_map["AllowedProjects"] = adv_form.GetAllowedProjects(); attributes_map["QueryProperties"] = adv_form.GetQueryProperties(); attributes_map["UseWVarChar"] = adv_form.GetUseWchar(); attributes_map["EnableSession"] = adv_form.GetEnableSession(); @@ -242,7 +243,8 @@ static Section BuildTestConnectionAttributes( StatusRecordOr DriverForm::GetCatalogAndDataset( std::string const& action, std::string const& key_file_path, - std::string const& oauth_token, std::string const& catalog_name) { + std::string const& oauth_token, std::string const& catalog_name, + std::string const& trusted_certs, bool use_system_trust_store) { OauthMechanism oauth_value; if (oauth_token == "Service Authentication") { oauth_value = OauthMechanism::kServiceAccount; @@ -259,9 +261,27 @@ StatusRecordOr DriverForm::GetCatalogAndDataset( oauth_value = OauthMechanism::kExternalUser; } + ConnProps conn_props; + conn_props.auth_mechanism = oauth_value; + conn_props.credentials_file_path = key_file_path; + conn_props.ssl_credentials.pem_root_certs = + !trusted_certs.empty() ? trusted_certs : GetDefaultPemFile(); + conn_props.ssl_credentials.use_system_trust_store = use_system_trust_store; + + ProxyOptions proxy_form; + conn_props.proxy_options.hostname = proxy_form.GetProxyHost(); + conn_props.proxy_options.port = proxy_form.GetProxyPort(); + conn_props.proxy_options.username = proxy_form.GetProxyUsername(); + conn_props.proxy_options.password = proxy_form.GetProxyPass(); + + AdvanceOptions adv_form; + conn_props.psc = adv_form.GetPrivateServiceConnectUris(); + conn_props.gcd.enable_gcd = + (adv_form.GetEnableGcd() == "1" || adv_form.GetEnableGcd() == "true"); + conn_props.gcd.universe_domain = adv_form.GetUniverseDomain(); + SQLULEN metadata_id = 0; - auto bq_client_ptr = - ODBCBQClient::CreateBQClient({oauth_value, key_file_path}); + auto bq_client_ptr = ODBCBQClient::CreateBQClient(conn_props); if (!bq_client_ptr) { return bq_client_ptr.GetStatusRecord(); } @@ -765,11 +785,13 @@ void EvaluateFields(HWND hwnd) { void PopulateDropdown(HWND h_dataset_box, std::string text, std::string key_file, std::string conn_props, - std::string catalog) { + std::string catalog, std::string trusted_certs = "", + bool use_system_trust_store = false) { SendMessage(h_dataset_box, CB_RESETCONTENT, 0, 0); StatusRecordOr status_record = - DriverForm::GetCatalogAndDataset(text, key_file, conn_props, catalog); + DriverForm::GetCatalogAndDataset(text, key_file, conn_props, catalog, + trusted_certs, use_system_trust_store); if (!status_record.Ok()) { MessageBox(h_dataset_box, status_record.GetStatusRecord().message.c_str(), @@ -826,8 +848,14 @@ StatusRecord HandleDropdown(HWND hwnd, int control_id, char const* field_type, if (has_auth && has_catalog && (is_adc || has_key)) { char const* key_to_use = is_adc ? "" : key_buffer; + char trusted_cert_buffer[256] = {}; + RetrieveFieldText(hwnd, kIdcTrustedCertEdit, trusted_cert_buffer, + sizeof(trusted_cert_buffer)); + bool use_system_trust_store = + (IsDlgButtonChecked(hwnd, kIdcSystemTrustStoreCheckbox) == BST_CHECKED); PopulateDropdown(h_control, field_type, key_to_use, auth_buffer, - catalog_buffer); + catalog_buffer, trusted_cert_buffer, + use_system_trust_store); return StatusRecord::Ok(); } diff --git a/google/cloud/odbc/bq_driver/internal/driver_form.h b/google/cloud/odbc/bq_driver/internal/driver_form.h index fe0e052deb..67b7bd212c 100644 --- a/google/cloud/odbc/bq_driver/internal/driver_form.h +++ b/google/cloud/odbc/bq_driver/internal/driver_form.h @@ -102,7 +102,9 @@ class DriverForm { static odbc_internal::StatusRecordOr GetCatalogAndDataset( std::string const& action, std::string const& key_file_path, - std::string const& oauth_token, std::string const& catalog_name = ""); + std::string const& oauth_token, std::string const& catalog_name = "", + std::string const& trusted_certs = "", + bool use_system_trust_store = false); inline void SetLogTraceValues(Section const& attributes_map) { log_trace_dialog_.SetValues(attributes_map); diff --git a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.cc b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.cc index 2859ac566e..808cd73b19 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.cc +++ b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.cc @@ -164,6 +164,7 @@ void ConnectionHandle::SetUp(Section& dsn_section, dsn_.kms_key_name = dsn_section["KMSKEYNAME"]; dsn_.session_location = dsn_section["SESSIONLOCATION"]; dsn_.additional_projects = dsn_section["ADDITIONALPROJECTS"]; + dsn_.allowed_projects = dsn_section["ALLOWEDPROJECTS"]; dsn_.psc = dsn_section["PRIVATESERVICECONNECTURIS"]; dsn_.enable_gcd = dsn_section["ENABLEGCD"] == "1" || dsn_section["ENABLEGCD"] == "true"; diff --git a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.h b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.h index f3dc1e4119..a2f1858550 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.h +++ b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle.h @@ -108,6 +108,7 @@ struct Dsn { // Proxy options fields google::cloud::odbc_bigquery_client_interface::ProxyOptions proxy_options; std::string additional_projects; + std::string allowed_projects; std::string psc; bool enable_gcd; std::string universe_domain; diff --git a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle_test.cc b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle_test.cc index 2c7f3c8f65..ef70478f79 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_conn_handle_test.cc +++ b/google/cloud/odbc/bq_driver/internal/odbc_conn_handle_test.cc @@ -358,6 +358,29 @@ TEST(ConnectionHandle, DsnSetupListProjectsParentSet) { EXPECT_EQ(actual.list_projects_parent, kDsnListProjectsParent); } +TEST(ConnectionHandle, DsnSetupAllowedProjectsNotSet) { + ConnectionHandle conn_handle; + Section dsn_section; + + conn_handle.SetUp(dsn_section, kDsnName); + + Dsn actual = conn_handle.GetDsn(); + EXPECT_TRUE(actual.allowed_projects.empty()); +} + +TEST(ConnectionHandle, DsnSetupAllowedProjectsSet) { + ConnectionHandle conn_handle; + Section dsn_section; + + dsn_section["ALLOWEDPROJECTS"] = "project-1,project-2"; + conn_handle.SetUp(dsn_section, kDsnName); + + Dsn actual = conn_handle.GetDsn(); + EXPECT_EQ(actual.allowed_projects, "project-1,project-2"); + // AdditionalProjects and AllowedProjects are independent settings. + EXPECT_TRUE(actual.additional_projects.empty()); +} + TEST(ConnectionHandle, SetAttributeSuccessSQLUInt) { ConnectionHandle conn_handle; diff --git a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.cc b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.cc index d920ab3ccb..9451418d75 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.cc +++ b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.cc @@ -18,6 +18,7 @@ #include "google/cloud/odbc/bq_driver/internal/utils.h" #include #include +#include namespace google::cloud::odbc_bq_driver_internal { @@ -111,9 +112,42 @@ StatusRecord ValidateInputParameters( return StatusRecord::Ok(); } +std::vector FilterAllowedProjects( + std::string const& allowed_projects, std::string const& projects_filter, + SQLULEN metadata_id) { + std::vector project_ids; + // Track seen project IDs to eliminate duplicates and prevent duplicate + // queries across projects, datasets, and tables, while preserving the + // user-specified order of appearance. + std::unordered_set seen; + auto filter_regex = BuildRegex(projects_filter, metadata_id); + std::vector allowed_ids = Split(allowed_projects, ","); + for (auto& project_id : allowed_ids) { + Trim(project_id); + if (project_id.empty() || !seen.insert(project_id).second) { + continue; + } + if ((!metadata_id && projects_filter == kMatchAll) || + re2::RE2::FullMatch(project_id, *filter_regex)) { + project_ids.push_back(std::move(project_id)); + } + } + return project_ids; +} + StatusRecordOr> GetFilteredProjectIds( ODBCBQClient& bq_client, std::string const& projects_filter, - SQLULEN metadata_id) { + SQLULEN metadata_id, std::string const& allowed_projects) { + // An explicit allowlist replaces REST-based project discovery entirely. + // projects.list enumerates every project the authenticated principal can + // reach, which is slow and quota-heavy for accounts with access to many + // projects; when the user has named the projects they care about there is + // nothing to discover. + if (!allowed_projects.empty()) { + return FilterAllowedProjects(allowed_projects, projects_filter, + metadata_id); + } + std::vector project_ids; std::unique_ptr filter_regex = BuildRegex(projects_filter, metadata_id); @@ -399,9 +433,10 @@ ResultSet ProcessStringResults( StatusRecordOr GetResultSetForProjects( ODBCBQClient& bq_client, SQLULEN metadata_id, - std::string const& additional_projects) { - auto project_ids_status = - GetFilteredProjectIds(bq_client, kMatchAll, metadata_id); + std::string const& additional_projects, + std::string const& allowed_projects) { + auto project_ids_status = GetFilteredProjectIds( + bq_client, kMatchAll, metadata_id, allowed_projects); if (!project_ids_status) { LOG(ERROR) << "GetResultSetForProjects::GetFilteredProjectIds:: " << project_ids_status.GetStatusRecord().message; @@ -419,9 +454,10 @@ StatusRecordOr GetResultSetForProjects( StatusRecordOr GetResultSetForDatasets( ODBCBQClient& bq_client, SQLULEN metadata_id, - std::string const& catalog_name, std::string const& additional_projects) { - auto project_ids_status = - GetFilteredProjectIds(bq_client, catalog_name, metadata_id); + std::string const& catalog_name, std::string const& additional_projects, + std::string const& allowed_projects) { + auto project_ids_status = GetFilteredProjectIds( + bq_client, catalog_name, metadata_id, allowed_projects); if (!project_ids_status) { LOG(ERROR) << "GetResultSetForDatasets::GetFilteredProjectIds:: " << project_ids_status.GetStatusRecord().message; @@ -481,6 +517,7 @@ StatusRecordOr GetResultSetForTables( std::string const& project_filter, std::string const& dataset_filter, std::string const& table_filter, std::string const& table_type_filter, SQLULEN metadata_id) { + ConnectionHandle& conn_handle = *(stmt_handle.GetConnectionHandle()); // When the catalog argument is an exact project identifier -- either because // SQL_ATTR_METADATA_ID is true, or because the LIKE pattern contains no // wildcard -- skip enumerating every accessible project via projects.list and @@ -489,10 +526,24 @@ StatusRecordOr GetResultSetForTables( std::vector project_list; if (auto project_literal = LiteralFromOdbcPattern(project_filter, metadata_id)) { - project_list = {std::move(*project_literal)}; + auto const& allowed_projects = conn_handle.GetDsn().allowed_projects; + if (!allowed_projects.empty()) { + // When an explicit allowlist is configured, verify that the literal + // project is present in the allowlist. Since LiteralFromOdbcPattern + // extracts an exact identifier (with escapes resolved), match it as + // an exact identifier (SQL_TRUE) so any '_' or '%' in the project ID + // is treated as a literal character rather than an ODBC wildcard. + // This ensures exact catalog filters cannot bypass the AllowedProjects + // restriction. + project_list = + FilterAllowedProjects(allowed_projects, *project_literal, SQL_TRUE); + } else { + project_list = {std::move(*project_literal)}; + } } else { auto projects_status_record_or = - GetFilteredProjectIds(bq_client, project_filter, metadata_id); + GetFilteredProjectIds(bq_client, project_filter, metadata_id, + conn_handle.GetDsn().allowed_projects); if (!projects_status_record_or) { LOG(ERROR) << "GetResultSetForTables::GetFilteredProjectIds:: " << projects_status_record_or.GetStatusRecord().message; @@ -500,7 +551,6 @@ StatusRecordOr GetResultSetForTables( } project_list = *projects_status_record_or; } - ConnectionHandle& conn_handle = *(stmt_handle.GetConnectionHandle()); // Append additional projects if any project_list = AppendAdditionalProjectsIfMissing( bq_client, metadata_id, std::move(project_list), diff --git a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.h b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.h index 41ce56e93a..71f6df4f99 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.h +++ b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables.h @@ -55,12 +55,23 @@ odbc_internal::StatusRecord ValidateInputParameters( const SQLCHAR* table_name, SQLSMALLINT table_name_len, SQLSMALLINT table_type_len, SQLULEN metadata_id); +// Return the project ids from the comma-separated 'allowed_projects' allowlist +// that match 'projects_filter', using the same LIKE-pattern / +// SQL_ATTR_METADATA_ID semantics as GetFilteredProjectIds. Blank entries are +// ignored, surrounding whitespace is trimmed, and duplicate entries are +// deduplicated while preserving order. Makes no REST calls. +std::vector FilterAllowedProjects( + std::string const& allowed_projects, std::string const& projects_filter, + SQLULEN metadata_id); + // Return a list of project ids depending on SQL_ATTR_METADATA_ID and // 'projects_filter'. Returns all project ids if SQL_ATTR_METADATA_ID == // SQL_FALSE and projects_filter == "%". +// When 'allowed_projects' is non-empty it is used as the set of candidate +// projects and projects.list is not called at all. odbc_internal::StatusRecordOr> GetFilteredProjectIds( ODBCBQClient& bq_client, std::string const& projects_filter, - SQLULEN metadata_id); + SQLULEN metadata_id, std::string const& allowed_projects = ""); // Return a list of dataset ids depending on SQL_ATTR_METADATA_ID and // 'datasets_filter'. Returns all dataset ids if SQL_ATTR_METADATA_ID == @@ -123,13 +134,15 @@ ResultSet ProcessStringResults( // Search for all projects and populate ResultSet for it. odbc_internal::StatusRecordOr GetResultSetForProjects( ODBCBQClient& bq_client, SQLULEN metadata_id, - std::string const& additional_projects = ""); + std::string const& additional_projects = "", + std::string const& allowed_projects = ""); // Search for all datasets in all projects and populate ResultSet for it. odbc_internal::StatusRecordOr GetResultSetForDatasets( ODBCBQClient& bq_client, SQLULEN metadata_id, std::string const& catalog_name = kMatchAll, - std::string const& additional_projects = ""); + std::string const& additional_projects = "", + std::string const& allowed_projects = ""); // Search for tables and populate ResultSet according to ODBC spec odbc_internal::StatusRecordOr GetResultSetForTables( diff --git a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables_test.cc b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables_test.cc index 7e8e31c31d..04477d7cdf 100644 --- a/google/cloud/odbc/bq_driver/internal/odbc_sql_tables_test.cc +++ b/google/cloud/odbc/bq_driver/internal/odbc_sql_tables_test.cc @@ -23,7 +23,9 @@ using ::google::cloud::bigquery_v2_minimal_internal::QueryParameter; using google::cloud::odbc_internal::SQLStates; using google::cloud::odbc_internal::StatusRecord; using google::cloud::odbc_testing_bq_driver_utils::CastToSQLCHAR; +using ::testing::ElementsAre; using ::testing::HasSubstr; +using ::testing::IsEmpty; TEST(ValidateInputParameters, Success) { StatusRecord status = ValidateInputParameters( @@ -238,6 +240,56 @@ TEST(ConstructQuery, ConstructWithTwoClausesEmptystrings) { EXPECT_EQ(2, named_query_params.size()); } +TEST(FilterAllowedProjects, WildcardReturnsWholeAllowlistTrimmed) { + EXPECT_THAT(FilterAllowedProjects(" project-1 ,project-2,\tproject-3 ", "%", + SQL_FALSE), + ElementsAre("project-1", "project-2", "project-3")); +} + +TEST(FilterAllowedProjects, EmptyAllowlistReturnsNothing) { + EXPECT_THAT(FilterAllowedProjects("", "%", SQL_FALSE), IsEmpty()); +} + +TEST(FilterAllowedProjects, BlankEntriesAreIgnored) { + EXPECT_THAT(FilterAllowedProjects(" , project-1 ,, ,", "%", SQL_FALSE), + ElementsAre("project-1")); +} + +TEST(FilterAllowedProjects, DuplicateEntriesAreDeduplicatedPreservingOrder) { + EXPECT_THAT(FilterAllowedProjects( + "project-1, project-2, project-1, project-3, project-2", "%", + SQL_FALSE), + ElementsAre("project-1", "project-2", "project-3")); +} + +TEST(FilterAllowedProjects, LikePatternFiltersAllowlist) { + EXPECT_THAT(FilterAllowedProjects("prod-a,prod-b,dev-c", "prod%", SQL_FALSE), + ElementsAre("prod-a", "prod-b")); +} + +TEST(FilterAllowedProjects, UnderscoreMatchesSingleCharacter) { + EXPECT_THAT(FilterAllowedProjects("pa,pb,pcc", "p_", SQL_FALSE), + ElementsAre("pa", "pb")); +} + +TEST(FilterAllowedProjects, NonMatchingPatternReturnsNothing) { + EXPECT_THAT(FilterAllowedProjects("prod-a,prod-b", "staging%", SQL_FALSE), + IsEmpty()); +} + +TEST(FilterAllowedProjects, MetadataIdMatchesExactIdCaseInsensitively) { + EXPECT_THAT( + FilterAllowedProjects("Project-1,project-2", "PROJECT-1", SQL_TRUE), + ElementsAre("Project-1")); +} + +TEST(FilterAllowedProjects, MetadataIdTreatsWildcardAsLiteral) { + // With SQL_ATTR_METADATA_ID set the catalog argument is an identifier, not a + // pattern, so "%" matches only a project literally named "%". + EXPECT_THAT(FilterAllowedProjects("project-1,project-2", "%", SQL_TRUE), + IsEmpty()); +} + TEST(CreateResultSetForProjects, CreateResultSetForProjects) { std::vector project_ids = {"id-1", "id-2"}; diff --git a/google/cloud/odbc/bq_driver/odbc_connection.cc b/google/cloud/odbc/bq_driver/odbc_connection.cc index 44ad27886d..d65fde65c2 100644 --- a/google/cloud/odbc/bq_driver/odbc_connection.cc +++ b/google/cloud/odbc/bq_driver/odbc_connection.cc @@ -211,6 +211,11 @@ SQLRETURN HandleDriverPrompt(std::string& conn_string, SQLHWND window_handle, {"SESSIONLOCATION", form.GetAdvanceOptions()->GetSessionLocation()}, {"ENABLESESSION", form.GetAdvanceOptions()->GetEnableSession()}, {"ADDITIONALPROJECTS", form.GetAdvanceOptions()->GetAdditionalProjects()}, + {"ALLOWEDPROJECTS", form.GetAdvanceOptions()->GetAllowedProjects()}, + {"PRIVATESERVICECONNECTURIS", + form.GetAdvanceOptions()->GetPrivateServiceConnectUris()}, + {"ENABLEGCD", form.GetAdvanceOptions()->GetEnableGcd()}, + {"UNIVERSEDOMAIN", form.GetAdvanceOptions()->GetUniverseDomain()}, }; handle_ref->SetUp(dsn_section, form.GetDSN()); diff --git a/google/cloud/odbc/bq_driver/odbc_driver_metadata.cc b/google/cloud/odbc/bq_driver/odbc_driver_metadata.cc index fa87214ba9..73d082ca5c 100644 --- a/google/cloud/odbc/bq_driver/odbc_driver_metadata.cc +++ b/google/cloud/odbc/bq_driver/odbc_driver_metadata.cc @@ -646,12 +646,14 @@ SQLRETURN SQLTablesInternal(SQLHSTMT stmt_handle, SQLCHAR* catalog_name, if (!metadata_id && project_filter == SQL_ALL_CATALOGS && dataset_filter.empty() && table_filter.empty()) { result_set_status = GetResultSetForProjects( - bq_client, metadata_id, conn_handle.GetDsn().additional_projects); + bq_client, metadata_id, conn_handle.GetDsn().additional_projects, + conn_handle.GetDsn().allowed_projects); } else if (!metadata_id && project_filter.empty() && dataset_filter == SQL_ALL_SCHEMAS && table_filter.empty()) { result_set_status = GetResultSetForDatasets(bq_client, metadata_id, kMatchAll, - conn_handle.GetDsn().additional_projects); + conn_handle.GetDsn().additional_projects, + conn_handle.GetDsn().allowed_projects); } else if (!metadata_id && project_filter.empty() && dataset_filter.empty() && table_filter.empty() && table_type_filter == SQL_ALL_TABLE_TYPES) { result_set_status = CreateResultSetForTableTypes(); diff --git a/google/cloud/odbc/bq_driver/odbc_windows.cc b/google/cloud/odbc/bq_driver/odbc_windows.cc index 839b57d744..ac42a712f8 100644 --- a/google/cloud/odbc/bq_driver/odbc_windows.cc +++ b/google/cloud/odbc/bq_driver/odbc_windows.cc @@ -145,6 +145,8 @@ bool ConfigDSNInternal(HWND hwnd_parent, WORD f_request, LPCSTR lpsz_driver, GetValueOrDefault(section, max_retries_key, kDefaultMaxRetries); std::string additional_projects = GetValueOrDefault(section, additional_projects_key); + std::string allowed_projects = + GetValueOrDefault(section, allowed_projects_key); std::string query_properties = GetValueOrDefault(section, query_properties_key); // std::string use_wchar = GetValueOrDefault(section, use_wchar_key); @@ -196,6 +198,7 @@ bool ConfigDSNInternal(HWND hwnd_parent, WORD f_request, LPCSTR lpsz_driver, {temp_expiration_key, temp_expiration}, {session_location_key, session_location}, {additional_projects_key, additional_projects}, + {allowed_projects_key, allowed_projects}, {query_properties_key, query_properties}, // {use_wchar_key, use_wchar}, {enable_session_key, enable_session}, @@ -260,6 +263,7 @@ bool ConfigDSNInternal(HWND hwnd_parent, WORD f_request, LPCSTR lpsz_driver, temp_expiration = advance_form.GetTempTableExpiration(); session_location = advance_form.GetSessionLocation(); additional_projects = advance_form.GetAdditionalProjects(); + allowed_projects = advance_form.GetAllowedProjects(); query_properties = advance_form.GetQueryProperties(); // use_wchar = advance_form.GetUseWchar(); enable_session = advance_form.GetEnableSession(); diff --git a/google/cloud/odbc/bq_driver/odbc_windows.h b/google/cloud/odbc/bq_driver/odbc_windows.h index 20917a8337..2f968e4591 100644 --- a/google/cloud/odbc/bq_driver/odbc_windows.h +++ b/google/cloud/odbc/bq_driver/odbc_windows.h @@ -44,6 +44,7 @@ std::string const default_string_length_key = "DefaultStringColumnLength"; std::string const temp_expiration_key = "LargeResultsTempTableExpirationTime"; std::string const session_location_key = "SessionLocation"; std::string const additional_projects_key = "AdditionalProjects"; +std::string const allowed_projects_key = "AllowedProjects"; std::string const query_properties_key = "QueryProperties"; std::string const max_threads_key = "MaxThreads"; std::string const max_retries_key = "MaxRetries"; diff --git a/google/cloud/odbc/integration_tests/odbc_driver_tests/catalog_test.cc b/google/cloud/odbc/integration_tests/odbc_driver_tests/catalog_test.cc index 9c91207168..1f80ebbc7b 100644 --- a/google/cloud/odbc/integration_tests/odbc_driver_tests/catalog_test.cc +++ b/google/cloud/odbc/integration_tests/odbc_driver_tests/catalog_test.cc @@ -506,6 +506,65 @@ error } */ +#ifdef BQ_DRIVER_INTEGRATION_TESTS +TEST(CatalogTest, SQLTables_AllowedProjects) { + // First learn every project the test principal can reach, so the allow-listed + // run below can be compared against it. + std::set all_catalogs; + { + auto conn = std::make_shared(); + EXPECT_EQ(Connect(kDefaultConnectionString, conn), SQL_SUCCESS); + auto status = SQLSetStmtAttr(conn->hstmt, SQL_ATTR_METADATA_ID, + (SQLPOINTER)SQL_FALSE, 0); + CheckError(status, "SQLSetStmtAttr", conn); + for (auto const& result : + Catalog::GetTables(conn, SQL_ALL_CATALOGS, "", "")) { + if (result.project_name.has_value()) { + all_catalogs.insert(result.project_name.value()); + } + } + EXPECT_EQ(Disconnect(conn), SQL_SUCCESS); + } + ASSERT_TRUE(all_catalogs.find(kCatalogName) != all_catalogs.end()) + << "Default project/catalog not found without an allowlist."; + if (all_catalogs.size() < 2) { + GTEST_LOG_(WARNING) + << "Test principal can reach only " << all_catalogs.size() + << " project(s), so this run does not exercise exclusion."; + } + + auto conn = std::make_shared(); + std::string connection_string = + kDefaultConnectionString + ";AllowedProjects=" + kCatalogName; + EXPECT_EQ(Connect(connection_string, conn), SQL_SUCCESS); + + auto status = SQLSetStmtAttr(conn->hstmt, SQL_ATTR_METADATA_ID, + (SQLPOINTER)SQL_FALSE, 0); + CheckError(status, "SQLSetStmtAttr", conn); + + std::vector results = + Catalog::GetTables(conn, SQL_ALL_CATALOGS, "", ""); + + std::set catalogs; + for (auto const& result : results) { + if (result.project_name.has_value()) { + catalogs.insert(result.project_name.value()); + } + + EXPECT_FALSE(result.dataset_name.has_value()); + EXPECT_FALSE(result.table_name.has_value()); + EXPECT_FALSE(result.table_type.has_value()); + EXPECT_FALSE(result.description.has_value()); + } + + // Exactly the allowlist: any other project the principal can reach must be + // absent, which is only possible if projects.list was not consulted. + EXPECT_EQ(catalogs, std::set{kCatalogName}); + + EXPECT_EQ(Disconnect(conn), SQL_SUCCESS); +} +#endif // BQ_DRIVER_INTEGRATION_TESTS + TEST(CatalogTest, SQLTables_AllDatasets) { auto conn = std::make_shared(); EXPECT_EQ(Connect(kDefaultConnectionString, conn), SQL_SUCCESS);