diff --git a/libs/CMakeLists.txt b/libs/CMakeLists.txt index caad13d76..57caa08c5 100644 --- a/libs/CMakeLists.txt +++ b/libs/CMakeLists.txt @@ -500,6 +500,7 @@ set(srcs $<$:server/src/ecflow/server/SslTcpServer.cpp> # Service -- Headers + service/src/ecflow/service/auth/Credentials.hpp service/src/ecflow/service/aviso/Aviso.hpp service/src/ecflow/service/aviso/AvisoService.hpp service/src/ecflow/service/aviso/etcd/Client.hpp @@ -512,6 +513,7 @@ set(srcs service/src/ecflow/service/Log.hpp service/src/ecflow/service/Registry.hpp # Service -- Sources + service/src/ecflow/service/auth/Credentials.cpp service/src/ecflow/service/aviso/Aviso.cpp service/src/ecflow/service/aviso/AvisoService.cpp service/src/ecflow/service/aviso/etcd/Range.cpp @@ -540,11 +542,11 @@ set(srcs ecbuild_add_library( TARGET - ecflow_all + ecflow_all NOINSTALL TYPE STATIC SOURCES - ${srcs} + ${srcs} PUBLIC_INCLUDES attribute/src base/src diff --git a/libs/service/CMakeLists.txt b/libs/service/CMakeLists.txt index 7cc36672d..2b4c1bb4f 100644 --- a/libs/service/CMakeLists.txt +++ b/libs/service/CMakeLists.txt @@ -11,7 +11,7 @@ # ecflow_services # -------------------------------------------------- -# u_services +# u_service_executor # -------------------------------------------------- ecbuild_add_test( @@ -31,6 +31,33 @@ target_clangformat(u_service_executor CONDITION ENABLE_TESTS ) +# u_service_auth +# -------------------------------------------------- + +ecbuild_add_test( + TARGET + u_service_auth + LABELS + unit nightly + SOURCES + # Headers -- utilities + test/TestContentProvider.hpp + # Sources -- utilities + test/TestContentProvider.cpp + # Sources + test/auth/TestAuth_main.cpp # test entry point + test/auth/TestAuth.cpp + INCLUDES + test + LIBS + ecflow_all + Boost::boost # Boost header-only libraries must be available (namely unit_test_framework) + Boost::filesystem +) +target_clangformat(u_service_auth + CONDITION ENABLE_TESTS +) + # u_service_aviso # -------------------------------------------------- @@ -41,13 +68,15 @@ ecbuild_add_test( unit nightly SOURCES # Headers -- utilities - test/aviso/TestContentProvider.hpp + test/TestContentProvider.hpp # Sources -- utilities - test/aviso/TestContentProvider.cpp + test/TestContentProvider.cpp # Sources test/aviso/TestAviso_main.cpp # test entry point test/aviso/TestAviso.cpp test/aviso/TestAvisoService.cpp + INCLUDES + test LIBS ecflow_all Boost::boost # Boost header-only libraries must be available (namely unit_test_framework) diff --git a/libs/service/src/ecflow/service/auth/Credentials.cpp b/libs/service/src/ecflow/service/auth/Credentials.cpp new file mode 100644 index 000000000..e1825b20b --- /dev/null +++ b/libs/service/src/ecflow/service/auth/Credentials.cpp @@ -0,0 +1,91 @@ +/* + * Copyright 2009- ECMWF. + * + * This software is licensed under the terms of the Apache Licence version 2.0 + * which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. + * In applying this licence, ECMWF does not waive the privileges and immunities + * granted to it by virtue of its status as an intergovernmental organisation + * nor does it submit to any jurisdiction. + */ + +#include "ecflow/service/auth/Credentials.hpp" + +#include + +#include + +#include "ecflow/core/Message.hpp" + +namespace ecf::service::auth { + +void Credentials::add(std::string key, std::string value) { + entries_.push_back({std::move(key), std::move(value)}); +} + +std::optional Credentials::value(std::string_view key) const { + for (const auto& entry : entries_) { + if (entry.key == key) { + return entry.value; + } + } + return std::nullopt; +} + +std::optional Credentials::user() const { + if (auto username = value("username"); username) { + if (auto password = value("password"); password) { + return UserCredentials{std::move(*username), std::move(*password)}; + } + } + return std::nullopt; +} + +std::optional Credentials::key() const { + if (auto key = value("key"); key) { + return KeyCredentials{std::move(*key)}; + } + return std::nullopt; +} + +namespace { + +Credentials load_from_stream(std::istream& input) { + using json = nlohmann::ordered_json; + + json content; + try { + content = json::parse(input); + } + catch (const json::parse_error& e) { + throw std::runtime_error(Message("Credentials: Unable to parse content, due to ", e.what()).str()); + } + + Credentials credentials; + for (auto field: content.items()) { + try { + credentials.add(field.key(), field.value()); + } catch (const json::type_error& e) { + throw std::runtime_error(Message("Credentials: Unable to retrieve content, due to ", e.what()).str()); + } + } + + if (!credentials.user() && !credentials.key()) { + throw std::runtime_error("Credentials: Invalid content found (neither user nor key credentials provided)"); + } + + return credentials; +} + +} // namespace + +Credentials Credentials::load(const std::string& filepath) { + std::ifstream stream(filepath); + return load_from_stream(stream); +} + +Credentials Credentials::load_content(const std::string& content) { + std::istringstream stream(content); + return load_from_stream(stream); +} + +} // namespace ecf::service::auth diff --git a/libs/service/src/ecflow/service/auth/Credentials.hpp b/libs/service/src/ecflow/service/auth/Credentials.hpp new file mode 100644 index 000000000..bcb2132a2 --- /dev/null +++ b/libs/service/src/ecflow/service/auth/Credentials.hpp @@ -0,0 +1,57 @@ +/* + * Copyright 2009- ECMWF. + * + * This software is licensed under the terms of the Apache Licence version 2.0 + * which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. + * In applying this licence, ECMWF does not waive the privileges and immunities + * granted to it by virtue of its status as an intergovernmental organisation + * nor does it submit to any jurisdiction. + */ + +#ifndef ecflow_service_auth_Credentials_HPP +#define ecflow_service_auth_Credentials_HPP + +#include +#include +#include + +namespace ecf::service::auth { + +class Credentials { +public: + struct UserCredentials + { + std::string username; + std::string password; + }; + + struct KeyCredentials + { + std::string key; + }; + + Credentials() = default; + + void add(std::string key, std::string value); + + [[nodiscard]] std::optional value(std::string_view key) const; + + [[nodiscard]] std::optional user() const; + [[nodiscard]] std::optional key() const; + + static Credentials load(const std::string& filepath); + static Credentials load_content(const std::string& content); + +private: + struct Entry + { + std::string key; + std::string value; + }; + + std::vector entries_; +}; + +} // namespace ecf::service::auth + +#endif /* ecflow_service_auth_Credentials_HPP */ diff --git a/libs/service/src/ecflow/service/aviso/AvisoService.cpp b/libs/service/src/ecflow/service/aviso/AvisoService.cpp index 1d0638ef1..3c9dd661d 100644 --- a/libs/service/src/ecflow/service/aviso/AvisoService.cpp +++ b/libs/service/src/ecflow/service/aviso/AvisoService.cpp @@ -12,27 +12,11 @@ #include "ecflow/core/Overload.hpp" #include "ecflow/service/Registry.hpp" +#include "ecflow/service/auth/Credentials.hpp" #include "ecflow/service/aviso/etcd/Client.hpp" namespace ecf::service::aviso { -namespace { - -std::string load_authentication_credential(const std::string& auth_file) { - std::ifstream file(auth_file); - if (!file.is_open()) { - throw std::runtime_error("Unable to open file: " + auth_file); - } - - std::string token; - std::getline(file, token); - file.close(); - - return token; -} - -} // namespace - std::ostream& operator<<(std::ostream& os, const AvisoResponse& r) { std::visit(ecf::overload{[&os](const NotificationPackage& p) { os << p; }, [&os](const AvisoNoMatch& a) { os << a; }, @@ -144,7 +128,13 @@ void AvisoService::register_listener(const AvisoSubscribe& listen) { auto& inserted = listeners_.emplace_back(listener); if (auto auth = listen.auth(); !auth.empty()) { - inserted.auth_token = load_authentication_credential(listen.auth()); + auto credentials = ecf::service::auth::Credentials::load(auth); + if (auto key_credentials = credentials.key(); key_credentials) { + inserted.auth_token = key_credentials->key; + } + else { + SLOG(I, "AvisoService: no key found in auth token for listener {" << listener.path() << "}"); + } } } diff --git a/libs/service/src/ecflow/service/aviso/etcd/Client.hpp b/libs/service/src/ecflow/service/aviso/etcd/Client.hpp index 2e251730f..934852c15 100644 --- a/libs/service/src/ecflow/service/aviso/etcd/Client.hpp +++ b/libs/service/src/ecflow/service/aviso/etcd/Client.hpp @@ -29,7 +29,6 @@ namespace ecf::service::aviso::etcd { class Client { public: - Client() = default; Client(const std::string& address); Client(const std::string& address, const std::string& auth_token); diff --git a/libs/service/src/ecflow/service/mirror/MirrorService.cpp b/libs/service/src/ecflow/service/mirror/MirrorService.cpp index 6b6f6971d..cb980f0b0 100644 --- a/libs/service/src/ecflow/service/mirror/MirrorService.cpp +++ b/libs/service/src/ecflow/service/mirror/MirrorService.cpp @@ -21,27 +21,10 @@ #include "ecflow/node/Node.hpp" #include "ecflow/service/Log.hpp" #include "ecflow/service/Registry.hpp" +#include "ecflow/service/auth/Credentials.hpp" namespace ecf::service::mirror { -namespace { - -std::pair load_auth_credentials(const std::string& auth_file) { - std::ifstream file(auth_file); - if (!file.is_open()) { - throw std::runtime_error("Unable to open file: " + auth_file); - } - - std::string username, password; - std::getline(file, username); - std::getline(file, password); - file.close(); - - return {username, password}; -} - -} // namespace - /* MirrorService */ void MirrorService::start() { @@ -115,9 +98,11 @@ void MirrorService::register_listener(const MirrorRequest& request) { if (!request.auth.empty()) { SLOG(D, "MirrorService: Loading auth {" << request.auth << "}"); try { - auto [username, password] = load_auth_credentials(request.auth); - inserted.remote_username_ = username; - inserted.remote_password_ = password; + auto credentials = ecf::service::auth::Credentials::load(request.auth); + if (auto user = credentials.user(); user) { + inserted.remote_username_ = user->username; + inserted.remote_password_ = user->password; + } } catch (std::runtime_error& e) { throw std::runtime_error("MirrorService: Unable to load auth credentials"); @@ -132,7 +117,8 @@ MirrorController::MirrorController() if (auto* server = TheOneServer::server(); server) { // The following forces the server to increment the job generation count and traverse the defs server->increment_job_generation_count(); - } else { + } + else { SLOG(E, "MirrorController: no server available, thus unable to increment job generation count"); } }, diff --git a/libs/service/test/aviso/TestContentProvider.cpp b/libs/service/test/TestContentProvider.cpp similarity index 97% rename from libs/service/test/aviso/TestContentProvider.cpp rename to libs/service/test/TestContentProvider.cpp index 53b4230fc..69da78340 100644 --- a/libs/service/test/aviso/TestContentProvider.cpp +++ b/libs/service/test/TestContentProvider.cpp @@ -25,7 +25,7 @@ std::string make_temp_filename(std::string file_name_prefix) { std::array file_name_template{}; std::copy(file_name_prefix.begin(), file_name_prefix.end(), file_name_template.data()); mkstemp(file_name_template.data()); - return file_name_prefix; + return file_name_template.data(); } void store_content_to_file(const std::string& file_path, const std::string& content) { diff --git a/libs/service/test/aviso/TestContentProvider.hpp b/libs/service/test/TestContentProvider.hpp similarity index 100% rename from libs/service/test/aviso/TestContentProvider.hpp rename to libs/service/test/TestContentProvider.hpp diff --git a/libs/service/test/auth/TestAuth.cpp b/libs/service/test/auth/TestAuth.cpp new file mode 100644 index 000000000..907266a81 --- /dev/null +++ b/libs/service/test/auth/TestAuth.cpp @@ -0,0 +1,213 @@ +/* + * Copyright 2009- ECMWF. + * + * This software is licensed under the terms of the Apache Licence version 2.0 + * which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. + * In applying this licence, ECMWF does not waive the privileges and immunities + * granted to it by virtue of its status as an intergovernmental organisation + * nor does it submit to any jurisdiction. + */ + +#include + +#include "TestContentProvider.hpp" +#include "ecflow/service/auth/Credentials.hpp" + +BOOST_AUTO_TEST_SUITE(U_Auth) + +BOOST_AUTO_TEST_SUITE(T_Credentials) + +BOOST_AUTO_TEST_CASE(can_create_default_credentials) { + using namespace ecf::service::auth; + + Credentials credentials; + + auto found = credentials.value("inexistent"); + BOOST_CHECK(!found.has_value()); +} + +BOOST_AUTO_TEST_CASE(can_retrieve_existing_key) { + using namespace ecf::service::auth; + + Credentials credentials; + credentials.add("key", "value"); + + auto found = credentials.value("key"); + BOOST_CHECK(found.has_value()); + BOOST_CHECK_EQUAL(found.value(), "value"); +} + +BOOST_AUTO_TEST_CASE(throw_exceptions_when_unable_to_parse_credentials) { + using namespace ecf::service::auth; + + std::string content = R"( + { + INVALID JSON + } + )"; + + BOOST_CHECK_EXCEPTION(Credentials::load_content(content), std::runtime_error, [](const std::runtime_error& e) { + return std::string{e.what()}.find("Credentials: Unable to parse content, due to ") != std::string::npos; + }); +} + +BOOST_AUTO_TEST_CASE(throw_exceptions_when_invalid_content_found) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "email": "user@host.int" + } + )"; + + BOOST_CHECK_EXCEPTION(Credentials::load_content(content), std::runtime_error, [](const std::runtime_error& e) { + return std::string{e.what()}.find("Credentials: Invalid content found") != std::string::npos; + }); +} + +BOOST_AUTO_TEST_CASE(can_load_credentials_with_key) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "key": "00000000111111110000000011111111", + "email": "user@host.int" + } + )"; + + auto credentials = Credentials::load_content(content); + + { + auto found = credentials.key(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->key, "00000000111111110000000011111111"); + } +} + +BOOST_AUTO_TEST_CASE(can_load_credentials_with_username_and_password) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "username": "user", + "password": "pass", + "email": "user@host.int" + } + )"; + + auto credentials = Credentials::load_content(content); + + { + auto found = credentials.user(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->username, "user"); + BOOST_CHECK_EQUAL(found->password, "pass"); + } +} + +BOOST_AUTO_TEST_CASE(can_load_credentials_with_key_and_username_and_password) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "key": "00000000111111110000000011111111", + "username": "user", + "password": "pass", + "email": "user@host.int" + } + )"; + + auto credentials = Credentials::load_content(content); + + { + auto found = credentials.key(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->key, "00000000111111110000000011111111"); + } + + { + auto found = credentials.user(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->username, "user"); + BOOST_CHECK_EQUAL(found->password, "pass"); + } +} + +BOOST_AUTO_TEST_CASE(can_handle_invalid_credentials_with_array_type) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "key": ["00000000111111110000000011111111", "11111111000000001111111100000000"], + "email": "user@host.int" + } + )"; + + BOOST_CHECK_EXCEPTION(Credentials::load_content(content), std::runtime_error, [](const std::runtime_error& e) { + return std::string{e.what()}.find("Credentials: Unable to retrieve content, due to") != std::string::npos; + }); +} + +BOOST_AUTO_TEST_CASE(can_handle_invalid_credentials_with_object_type) { + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "key": {}, + "email": "user@host.int" + } + )"; + + BOOST_CHECK_EXCEPTION(Credentials::load_content(content), std::runtime_error, [](const std::runtime_error& e) { + return std::string{e.what()}.find("Credentials: Unable to retrieve content, due to") != std::string::npos; + }); +} + +BOOST_AUTO_TEST_CASE(can_load_credentials_from_file) { + using namespace ecf::test; + using namespace ecf::service::auth; + + // Doesn't provide neither 'key' nor 'username'+'password' + std::string content = R"( + { + "url": "http://address:1234", + "key": "00000000111111110000000011111111", + "username": "user", + "password": "pass", + "email": "user@host.int" + } + )"; + + TestContentProvider provider("credentials", content); + + auto credentials = Credentials::load(provider.file()); + + { + auto found = credentials.key(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->key, "00000000111111110000000011111111"); + } + + { + auto found = credentials.user(); + BOOST_CHECK(found); + BOOST_CHECK_EQUAL(found->username, "user"); + BOOST_CHECK_EQUAL(found->password, "pass"); + } +} + +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE_END() diff --git a/libs/service/test/auth/TestAuth_main.cpp b/libs/service/test/auth/TestAuth_main.cpp new file mode 100644 index 000000000..0eefa9aff --- /dev/null +++ b/libs/service/test/auth/TestAuth_main.cpp @@ -0,0 +1,12 @@ +/* + * Copyright 2009- ECMWF. + * + * This software is licensed under the terms of the Apache Licence version 2.0 + * which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. + * In applying this licence, ECMWF does not waive the privileges and immunities + * granted to it by virtue of its status as an intergovernmental organisation + * nor does it submit to any jurisdiction. + */ + +#define BOOST_TEST_MODULE Test_Aviso +#include diff --git a/libs/service/test/aviso/TestAvisoService.cpp b/libs/service/test/aviso/TestAvisoService.cpp index 296c61b8d..64c077eef 100644 --- a/libs/service/test/aviso/TestAvisoService.cpp +++ b/libs/service/test/aviso/TestAvisoService.cpp @@ -258,9 +258,16 @@ BOOST_AUTO_TEST_CASE(can_create_start_and_stop_aviso_controller) { } } )"; + std::string auth_content = R"( + { + "url": "http://address:1234", + "key": "00000000111111110000000011111111", + "email": "user@host.int" + } + )"; ecf::test::TestContentProvider schema_content_provider{"aviso_schema_test", schema_content}; - ecf::test::TestContentProvider auth_content_provider{"aviso_auth_test"}; + ecf::test::TestContentProvider auth_content_provider{"aviso_auth_test", auth_content}; ecf::service::TheOneServer::set_server(nullptr);