diff --git a/CMakeLists.txt b/CMakeLists.txt index c88978626b6d..b6a7ea51e1ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -310,6 +310,7 @@ set(scylla_sources auth/default_authorizer.cc auth/password_authenticator.cc auth/rest_authenticator.cc + auth/rest_role_manager.cc auth/passwords.cc auth/permission.cc auth/permissions_cache.cc diff --git a/README.md b/README.md index 61e1b2c67aab..2b2ff16b9568 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ [![Slack](https://img.shields.io/badge/slack-scylla-brightgreen.svg?logo=slack)](http://slack.scylladb.com) [![Twitter](https://img.shields.io/twitter/follow/ScyllaDB.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=ScyllaDB) +## This is a fork of scylla + +Scylla forks adding support of a specific rest authenticator: [rest_authc_authz](docs/guides/rest_authc_authz.md) + ## What is Scylla? Scylla is the real-time big data database that is API-compatible with Apache Cassandra and Amazon DynamoDB. diff --git a/auth/rest_authenticator.cc b/auth/rest_authenticator.cc index bc7435e43c98..72a0ba60b51f 100644 --- a/auth/rest_authenticator.cc +++ b/auth/rest_authenticator.cc @@ -97,7 +97,7 @@ namespace auth { static const sstring &create_row_query_roles() { static const sstring create_row_query_roles = format( - "INSERT INTO {} ({}, can_login, is_superuser, member_of, {}) VALUES (?, true, false, {{}}, ?)", + "INSERT INTO {} ({}, can_login, is_superuser, {}) VALUES (?, true, false, ?)", meta::roles_table::qualified_name, meta::roles_table::role_col_name, SALTED_HASH); @@ -137,7 +137,7 @@ namespace auth { // Ensure the _authenticator_config has been well initialized if (_authenticator_config.rest_authenticator_endpoint_host == "") { throw std::invalid_argument("Missing configuration for rest_authenticator_endpoint_host. " - "Did you call set_authenticator_config before calling start?" ); + "Did you call set_authenticator_config before calling start?"); } // Init rest http client _rest_http_client = rest_http_client(_authenticator_config.rest_authenticator_endpoint_host, @@ -266,8 +266,10 @@ namespace auth { // If not super user (super user is local only) and the username is not in roles_valid or salted_hash empty or bad password // call external endpoint - if ((username != DEFAULT_USER_NAME && !role_name) || !salted_hash || - !passwords::check(password, *salted_hash)) { + if (username == DEFAULT_USER_NAME && (!salted_hash || !passwords::check(password, *salted_hash))) { + std::throw_with_nested(exceptions::authentication_exception("Bad password for superuser")); + } else if (username != DEFAULT_USER_NAME && + (!role_name || !salted_hash || !passwords::check(password, *salted_hash))) { bool create_user = res_roles->empty(); // TODO manage retry? @@ -278,20 +280,18 @@ namespace auth { return with_timeout( timer<>::clock::now() + std::chrono::seconds(_authenticator_config.rest_authenticator_endpoint_timeout), - _rest_http_client.connect() - .then([username, password]( - std::unique_ptr c) { - return seastar::do_with( - std::move(c), - [username, password](auto &c) { - return c->do_get_groups(username, password); - }); - }) - .then([this, create_user, username, password]( - std::vector groups) { - return create_or_update(create_user, username, password, groups); - }) - ); + _rest_http_client.connect()) + .then([username, password]( + std::unique_ptr c) { + return seastar::do_with( + std::move(c), + [username, password](auto &c) { + return c->do_get_groups(username, password); + }); + }) + .then([this, create_user, username, password](role_set roles) { + return create_or_update(create_user, username, password, roles); + }); } return make_ready_future(username); @@ -308,29 +308,30 @@ namespace auth { } future - rest_authenticator::create_or_update(bool create_user, sstring username, sstring password, - std::vector groups) const { - authentication_options authen_options; - authen_options.password = std::optional < std::string > {password}; - - if (create_user) { - plogger.info("Create role for username {}", username); - return rest_authenticator::create_with_groups(username, groups, authen_options).then([username] { + rest_authenticator::create_or_update(bool create_user, sstring username, sstring password, role_set &roles) const { + return do_with(std::move(roles), [this, create_user, username, password](role_set &roles) { + authentication_options authen_options; + authen_options.password = std::optional < std::string > {password}; + + if (create_user) { + plogger.info("Create role for username {}", username); + return rest_authenticator::create_with_groups(username, roles, authen_options).then([username] { + return make_ready_future(username); + }); + } + plogger.info("Update password for username {}", username); + return rest_authenticator::alter_with_groups(username, roles, authen_options).then([username] { return make_ready_future(username); }); - } - plogger.info("Update password for username {}", username); - return rest_authenticator::alter_with_groups(username, groups, authen_options).then([username] { - return make_ready_future(username); }); } future<> rest_authenticator::create(std::string_view role_name, const authentication_options &options) const { - std::vector groups; - return create_with_groups(sstring(role_name), groups, options); + role_set roles; + return create_with_groups(sstring(role_name), roles, options); } - future<> rest_authenticator::create_with_groups(sstring role_name, std::vector groups, + future<> rest_authenticator::create_with_groups(sstring role_name, role_set &roles, const authentication_options &options) const { if (!options.password) { return make_ready_future<>(); @@ -347,15 +348,17 @@ namespace auth { consistency_for_user(role_name), internal_distributed_timeout_config(), {role_name}) - ).discard_result(); + ).then([this, role_name, &roles](auto f) { + return modify_membership(role_name, roles); + }).discard_result(); } future<> rest_authenticator::alter(std::string_view role_name, const authentication_options &options) const { - std::vector groups; - return alter_with_groups(sstring(role_name), groups, options); + role_set roles; + return alter_with_groups(sstring(role_name), roles, options); } - future<> rest_authenticator::alter_with_groups(sstring role_name, std::vector groups, + future<> rest_authenticator::alter_with_groups(sstring role_name, role_set &roles, const authentication_options &options) const { if (!options.password) { return make_ready_future<>(); @@ -377,7 +380,9 @@ namespace auth { consistency_for_user(role_name), internal_distributed_timeout_config(), {role_name}) - ).discard_result(); + ).then([this, role_name, &roles](auto f) { + return modify_membership(role_name, roles); + }).discard_result(); } future<> rest_authenticator::drop(std::string_view name) const { @@ -392,6 +397,25 @@ namespace auth { {sstring(name)}).discard_result(); } + + future<> + rest_authenticator::modify_membership(sstring grantee_name, role_set &roles) const { + const auto modify_roles = [this, grantee_name, &roles] { + const auto query = format( + "UPDATE {} SET member_of = ? WHERE {} = ?", + meta::roles_table::qualified_name, + meta::roles_table::role_col_name); + + return _qp.execute_internal( + query, + consistency_for_user(grantee_name), + internal_distributed_timeout_config(), + {roles, grantee_name}); + }; + + return modify_roles().discard_result(); + } + future rest_authenticator::query_custom_options(std::string_view role_name) const { return make_ready_future(); } diff --git a/auth/rest_authenticator.hh b/auth/rest_authenticator.hh index 0ab88e495085..e5f3cd720dbd 100644 --- a/auth/rest_authenticator.hh +++ b/auth/rest_authenticator.hh @@ -25,6 +25,7 @@ #include "auth/authenticator.hh" #include "auth/rest_http_client.hh" +#include "auth/role_manager.hh" #include "cql3/query_processor.hh" namespace service { @@ -91,17 +92,21 @@ public: private: - future<> create_default_if_missing() const; + + enum class membership_change { + add, remove + }; future - create_or_update(bool user_to_create, sstring username, sstring password, - std::vector groups) const; + create_or_update(bool user_to_create, sstring username, sstring password, role_set &roles) const; - future<> create_with_groups(sstring role_name, std::vector groups, - const authentication_options &options) const; + future<> create_with_groups(sstring role_name, role_set &roles, const authentication_options &options) const; - future<> alter_with_groups(sstring role_name, std::vector groups, - const authentication_options &options) const; + future<> alter_with_groups(sstring role_name, role_set &roles, const authentication_options &options) const; + + future<> modify_membership(sstring grantee_name, role_set &roles) const; + + future<> create_default_if_missing() const; }; diff --git a/auth/rest_http_client.hh b/auth/rest_http_client.hh index 7a092e1daacc..5a51a1f9ae73 100644 --- a/auth/rest_http_client.hh +++ b/auth/rest_http_client.hh @@ -26,6 +26,7 @@ #include #include "picojson/picojson.h" #include "auth/rest_response_parser.hh" +#include "auth/role_manager.hh" #include "alternator/base64.hh" @@ -53,7 +54,7 @@ namespace auth { rest_response_parser _parser; sstring _request; - future > extract_groups(temporary_buffer buf, uint content_len) { + future extract_groups(temporary_buffer buf, uint content_len) { const char *json = buf.get(); picojson::value v; std::string err; @@ -83,20 +84,20 @@ namespace auth { } const picojson::value::array &p_groups = pv_groups.get(); - std::vector groups; + role_set groups; // TODO filter groups to add (For ex. only add groups containing scylla... to avoid storing all groups) for (auto p_group : p_groups) { if (p_group.is()) { auto group = p_group.get(); - groups.push_back(group); + groups.insert(sstring(group)); } } - return make_ready_future < std::vector < std::string >> (groups); + return make_ready_future(groups); } - future > + future get_groups_from_body(std::unique_ptr > rsp) { auto it = rsp->_headers.find("content-length"); if (it == rsp->_headers.end()) { @@ -125,7 +126,7 @@ namespace auth { ~connection() {} - future > do_get_groups(sstring username, sstring password) { + future do_get_groups(sstring username, sstring password) { std::string b64_authorization = get_authorization_header(username, password); sstring request = format(_request.c_str(), b64_authorization); @@ -173,7 +174,8 @@ namespace auth { _server, _port); // Load system CA trust - // TODO see tls.credentials_builder in Seastar + // TODO see tls.credentials_builder in Seastar in order to create + // a reloadable creds: tls::credentials_builder::build_reloadable_server_credentials auto f = _creds->set_system_trust(); if (_ca_file != "") { f = f.then([this] { return _creds->set_x509_trust_file(_ca_file, tls::x509_crt_format::PEM); }); diff --git a/auth/rest_response_parser.rl b/auth/rest_response_parser.rl index 06ec7e94aa8e..dca835d8cf58 100644 --- a/auth/rest_response_parser.rl +++ b/auth/rest_response_parser.rl @@ -1,25 +1,29 @@ /* - * Copyright (C) 2021 Criteo - */ - -/* - * This file is part of Scylla. + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. * - * Scylla is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * You may obtain a copy of the License at * - * Scylla is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. + * http://www.apache.org/licenses/LICENSE-2.0 * - * You should have received a copy of the GNU General Public License - * along with Scylla. If not, see . + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright (C) 2015 Cloudius Systems, Ltd. + */ + +/* + * Modified by Criteo: June 2021 + * Manage status and enforce all header fields to be in lowercase */ -// Added to manage status and enforce all header fields to be in lowercase #include #include #include diff --git a/auth/rest_role_manager.cc b/auth/rest_role_manager.cc new file mode 100644 index 000000000000..a11e5e3c9dfe --- /dev/null +++ b/auth/rest_role_manager.cc @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2021 Criteo + */ + +/* + * This file is part of Scylla. + * + * Scylla is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Scylla is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Scylla. If not, see . + */ + +#include "auth/rest_role_manager.hh" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "auth/common.hh" +#include "auth/roles-metadata.hh" +#include "cql3/query_processor.hh" +#include "cql3/untyped_result_set.hh" +#include "db/consistency_level_type.hh" +#include "exceptions/exceptions.hh" +#include "log.hh" +#include "utils/class_registrator.hh" +#include "database.hh" + +namespace auth { + + static logging::logger log("rest_role_manager"); + + static const class_registrator< + role_manager, + rest_role_manager, + cql3::query_processor &, + ::service::migration_manager &> registration("com.criteo.scylladb.auth.RestManager"); + + struct record final { + sstring name; + bool is_superuser; + bool can_login; + role_set member_of; + }; + + static db::consistency_level consistency_for_role(std::string_view role_name) noexcept { + if (role_name == meta::DEFAULT_SUPERUSER_NAME) { + return db::consistency_level::QUORUM; + } + + return db::consistency_level::LOCAL_ONE; + } + + static bool has_can_login(const cql3::untyped_result_set_row &row) { + return row.has("can_login") && !(boolean_type->deserialize(row.get_blob("can_login")).is_null()); + } + + std::string_view rest_role_manager::qualified_java_name() const noexcept { + return "com.criteo.scylladb.auth.RestManager"; + } + + const resource_set &rest_role_manager::protected_resources() const { + static const resource_set resources({make_data_resource(meta::AUTH_KS, meta::roles_table::name)}); + return resources; + } + + future<> rest_role_manager::create_metadata_tables_if_missing() const { + return create_metadata_table_if_missing( + meta::roles_table::name, + _qp, + meta::roles_table::creation_query(), + _migration_manager).discard_result(); + } + + future<> rest_role_manager::create_default_role_if_missing() const { + return default_role_row_satisfies(_qp, &has_can_login).then([this](bool exists) { + if (!exists) { + static const sstring query = format("INSERT INTO {} ({}, is_superuser, can_login) VALUES (?, true, true)", + meta::roles_table::qualified_name, + meta::roles_table::role_col_name); + + return _qp.execute_internal( + query, + db::consistency_level::QUORUM, + internal_distributed_timeout_config(), + {meta::DEFAULT_SUPERUSER_NAME}).then([](auto&&) { + log.info("Created default superuser role '{}'.", meta::DEFAULT_SUPERUSER_NAME); + return make_ready_future<>(); + }); + } + + return make_ready_future<>(); + }).handle_exception_type([](const exceptions::unavailable_exception& e) { + log.warn("Skipped default role setup: some nodes were not ready; will retry"); + return make_exception_future<>(e); + }); + } + + future<> rest_role_manager::start() { + return once_among_shards([this] { + return this->create_metadata_tables_if_missing().then([this] { + _stopped = auth::do_after_system_ready(_as, [this] { + return seastar::async([this] { + wait_for_schema_agreement(_migration_manager, _qp.db(), _as).get0(); + + create_default_role_if_missing().get0(); + }); + }); + }); + }); + } + + future<> rest_role_manager::stop() { + _as.request_abort(); + return _stopped.handle_exception_type([](const sleep_aborted &) {}).handle_exception_type( + [](const abort_requested_exception &) {});; + } + + static future > find_record(cql3::query_processor &qp, std::string_view role_name) { + static const sstring query = format("SELECT * FROM {} WHERE {} = ?", + meta::roles_table::qualified_name, + meta::roles_table::role_col_name); + + return qp.execute_internal( + query, + consistency_for_role(role_name), + internal_distributed_timeout_config(), + {sstring(role_name)}, + true).then([](::shared_ptr results) { + if (results->empty()) { + return std::optional(); + } + + const cql3::untyped_result_set_row &row = results->one(); + + return std::make_optional( + record{ + row.get_as(sstring(meta::roles_table::role_col_name)), + row.get_or("is_superuser", false), + row.get_or("can_login", false), + (row.has("member_of") + ? row.get_set("member_of") + : role_set())}); + }); + } + + static future require_record(cql3::query_processor &qp, std::string_view role_name) { + return find_record(qp, role_name).then([role_name](std::optional mr) { + if (!mr) { + throw nonexistant_role(role_name); + } + + return make_ready_future(*mr); + }); + } + + static future<> collect_roles(cql3::query_processor &qp, std::string_view grantee_name, role_set &roles) { + return require_record(qp, grantee_name).then([&qp, &roles](record r) { + return do_with(std::move(r.member_of), [&qp, &roles](const role_set &memberships) { + return do_for_each(memberships.begin(), memberships.end(), [&qp, &roles](const sstring &role_name) { + roles.insert(role_name); + return make_ready_future<>(); + }); + }); + }); + } + + future rest_role_manager::query_granted(std::string_view grantee_name, recursive_role_query m) const { + // Our implementation of roles does not support recursive role query + return do_with( + role_set{sstring(grantee_name)}, + [this, grantee_name](role_set &roles) { + return collect_roles(_qp, grantee_name, roles).then([&roles] { return roles; }); + }); + } + + future rest_role_manager::exists(std::string_view role_name) const { + // Used in grant revoke permissions to add permission if role exist + // but we do not create role for groups so not checking if it exists + // Also used after authentication to check if user has been well created + // but user is created by the rest authenticator so not required also + return make_ready_future(true); + } + + future rest_role_manager::is_superuser(std::string_view role_name) const { + return find_record(_qp, role_name).then([](std::optional mr) { + if (mr) { + record r = *mr; + return r.is_superuser; + } + return false; + }); + } + + future rest_role_manager::can_login(std::string_view role_name) const { + return require_record(_qp, role_name).then([](record r) { + return r.can_login; + }); + } + + // Needed for unittest + future<> rest_role_manager::create_or_replace(std::string_view role_name, const role_config& c) const { + static const sstring query = format("INSERT INTO {} ({}, is_superuser, can_login) VALUES (?, ?, ?)", + meta::roles_table::qualified_name, + meta::roles_table::role_col_name); + return _qp.execute_internal( + query, + consistency_for_role(role_name), + internal_distributed_timeout_config(), + {sstring(role_name), c.is_superuser, c.can_login}, + true).discard_result(); + } + + // Needed for unittest + future<> rest_role_manager::create(std::string_view role_name, const role_config &c) const { + return this->create_or_replace(role_name, c); + } + + future<> + rest_role_manager::alter(std::string_view role_name, const role_config_update &u) const { + throw std::logic_error("Not Implemented"); + } + + future<> rest_role_manager::drop(std::string_view role_name) const { + throw std::logic_error("Not Implemented"); + } + + future<> rest_role_manager::grant(std::string_view grantee_name, std::string_view role_name) const { + throw std::logic_error("Not Implemented"); + } + + future<> rest_role_manager::revoke(std::string_view revokee_name, std::string_view role_name) const { + throw std::logic_error("Not Implemented"); + } + + future rest_role_manager::query_all() const { + throw std::logic_error("Not Implemented"); + } + +} diff --git a/auth/rest_role_manager.hh b/auth/rest_role_manager.hh new file mode 100644 index 000000000000..3e9ad2c94436 --- /dev/null +++ b/auth/rest_role_manager.hh @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2021 Criteo + */ + +/* + * This file is part of Scylla. + * + * Scylla is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Scylla is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Scylla. If not, see . + */ + +#pragma once + +#include "auth/role_manager.hh" + +#include +#include + +#include +#include +#include + +#include "seastarx.hh" + +namespace cql3 { + class query_processor; +} + +namespace service { + class migration_manager; +} + +namespace auth { + + class rest_role_manager final : public role_manager { + cql3::query_processor &_qp; + ::service::migration_manager &_migration_manager; + future<> _stopped; + seastar::abort_source _as; + + public: + rest_role_manager(cql3::query_processor &qp, ::service::migration_manager &mm) + : _qp(qp), _migration_manager(mm), _stopped(make_ready_future<>()) { + } + + virtual std::string_view qualified_java_name() const + + noexcept override; + + virtual const resource_set &protected_resources() const override; + + virtual future<> start() override; + + virtual future<> stop() override; + + virtual future<> create(std::string_view role_name, const role_config &) const override; + + virtual future<> drop(std::string_view role_name) const override; + + virtual future<> alter(std::string_view role_name, const role_config_update &) const override; + + virtual future<> grant(std::string_view grantee_name, std::string_view role_name) const override; + + virtual future<> revoke(std::string_view revokee_name, std::string_view role_name) const override; + + virtual future query_granted(std::string_view grantee_name, recursive_role_query) const override; + + virtual future query_all() const override; + + virtual future exists(std::string_view role_name) const override; + + virtual future is_superuser(std::string_view role_name) const override; + + virtual future can_login(std::string_view role_name) const override; + + private: + future<> create_metadata_tables_if_missing() const; + + future<> create_or_replace(std::string_view role_name, const role_config &) const; + + future<> create_default_role_if_missing() const; + + }; + + +} diff --git a/configure.py b/configure.py index fe1f592fc1ea..d47539e575ab 100755 --- a/configure.py +++ b/configure.py @@ -829,6 +829,7 @@ def find_headers(repodir, excluded_dirs): 'auth/passwords.cc', 'auth/password_authenticator.cc', 'auth/rest_authenticator.cc', + 'auth/rest_role_manager.cc', 'auth/rest_response_parser.rl', 'auth/permission.cc', 'auth/permissions_cache.cc', diff --git a/db/config.cc b/db/config.cc index b637b467e0ed..b8d0fcede865 100644 --- a/db/config.cc +++ b/db/config.cc @@ -656,6 +656,7 @@ db::config::config(std::shared_ptr exts) , role_manager(this, "role_manager", value_status::Used, "org.apache.cassandra.auth.CassandraRoleManager", "The role-management backend, used to maintain grantts and memberships between roles.\n" "The available role-managers are:\n" + "\tcom.criteo.scylladb.auth.RestManager : Rely on roles created by rest_authenticator in the system_auth.roles table.\n" "\tCassandraRoleManager : Stores role data in the system_auth keyspace.") , permissions_validity_in_ms(this, "permissions_validity_in_ms", value_status::Used, 10000, "How long permissions in cache remain valid. Depending on the authorizer, such as CassandraAuthorizer, fetching permissions can be resource intensive. Permissions caching is disabled when this property is set to 0 or when AllowAllAuthorizer is used. The cached value is considered valid as long as both its value is not older than the permissions_validity_in_ms " diff --git a/docs/guides/rest_authc_authz.md b/docs/guides/rest_authc_authz.md new file mode 100644 index 000000000000..f97fd3eac7ac --- /dev/null +++ b/docs/guides/rest_authc_authz.md @@ -0,0 +1,73 @@ +# rest_authc_authz + +## Description + +The rest authenticator relies on an external rest endpoint to validate user credentials and retrieves user groups. + +It will: + +- check in the system_auth.roles table if the role exists. If the role exists check password with the stored hashed one +- If the role doesn't exists, it calls an external rest endpoint (https only) +- If creds are fine it stores the user/hashed password in the system_auth.roles table and returns the authenticated object user + +The rest role manager only query for member roles so the default authorizer can assign permission to the uer role depending to it's member roles. +Difference with the standard role manager: + +- do not manage creation, update and deletion of roles. This is performed by rest_authentication from ldap informations +- do not managed nested roles. Only takes in account roles in the member_of field of a user while the standard create a specific role for any roles in member_fo field and provide nested capability + +## Rest Endpoint definition + +Endpoint: GET /api/v1/user/groups, Protected with Basic Auth + +Response: + - 200 if ok with body: +`{ + "groups": [ "group1", "group2" ] +}` +- 404 if user not found +- 401 if auth failed + +## Internal DB + +It relies on: + +- system_auth.roles DB to store the role and the list of member_of roles: [text (role_name), boolean (can_login), boolean (is_superuser), set (member_of),text (salted_hash)] +- system_auth.roles_validation: role name are inserted in it with a TTL. When the role is not available in that table the rest_authenticator re check the user from the external endpoint [text (role_name)] +- system_auth.permissions to store permissions set for each roles: [text (role_name), text (resource), set (permissions)] + +## Test + +Building Scylla with the frozen toolchain `dbuild` is as easy as: + +```bash +$ git submodule update --init --force --recursive +$ ./tools/toolchain/dbuild ./configure.py +$ ./tools/toolchain/dbuild ninja build/release/scylla +``` + +Run scylla with RestAuthenticator + +```bash +$ ./tools/toolchain/dbuild ./build/release/scylla --workdir tmp --smp 2 --developer-mode 1 \ +--logger-log-level rest_authenticator=debug \ +--authenticator com.criteo.scylladb.auth.RestAuthenticator \ +--rest-authenticator-endpoint-host localhost \ +--rest-authenticator-endpoint-port 8000 \ +--rest-authenticator-endpoint-cafile-path ./tools/rest_authenticator_server/ssl/ca.crt \ +--rest-authenticator-endpoint-ttl 30 \ +--role-manager com.criteo.scylladb.auth.RestManager --authorizer CassandraAuthorizer +``` + +Run FastAPI rest server + +```bash +$ ./tools/rest_authenticator_server/rest_server.sh +``` + +Run Test client + +```bash +$ ./tools/rest_authenticator_server/scylla_client.sh +``` + diff --git a/docs/guides/rest_authenticator.md b/docs/guides/rest_authenticator.md deleted file mode 100644 index 7a6e56372402..000000000000 --- a/docs/guides/rest_authenticator.md +++ /dev/null @@ -1,51 +0,0 @@ -# ResAuthenticator - -## Description - -The rest authenticator rely on an external rest endpoint to validate user credentials. - -It will: - -- check in the system_auth.roles table if the role exist. If the role exist check password with the stored hashed one -- If the role doesn't exist, it call an external rest endpoint (https only) -- If creds are fine it stored the user/hashed password in the system_auth.roles table and return the authenticate object user - -## Rest Endpoint definition - -Endpoint: GET /api/v1/user/groups, Protected with Basic Auth - -Response: - - 200 if ok with body: -`{ - "groups": [ "group1", "group2" ] -}` -- 404 if user not found -- 401 if auth failed - -## Test - -Building Scylla with the frozen toolchain `dbuild` is as easy as: - -```bash -$ git submodule update --init --force --recursive -$ ./tools/toolchain/dbuild ./configure.py -$ ./tools/toolchain/dbuild ninja build/release/scylla -``` - -Run scylla with RestAuthenticator - -```bash -$ ./tools/toolchain/dbuild ./build/release/scylla --workdir tmp --smp 2 --developer-mode 1 --logger-log-level rest_authenticator=debug --authenticator com.criteo.scylladb.auth.RestAuthenticator --rest-authenticator-endpoint-host localhost --rest-authenticator-endpoint-port 8000 --rest-authenticator-endpoint-cafile-path ./tools/rest_authenticator_server/ssl/ca.crt -``` - -Run FastAPI rest server - -```bash -$ ./tools/rest_authenticator_server/rest_server.sh -``` - -Run Test client - -```bash -$ ./tools/rest_authenticator_server/scylla_client.sh -``` \ No newline at end of file diff --git a/test/boost/rest_authenticator_test.cc b/test/boost/rest_authenticator_test.cc index 5a1137d295ad..4a083d005732 100644 --- a/test/boost/rest_authenticator_test.cc +++ b/test/boost/rest_authenticator_test.cc @@ -23,6 +23,14 @@ #include #include "test/lib/cql_test_env.hh" #include "alternator/base64.hh" +#include "cql3/query_processor.hh" +#include "cql3/untyped_result_set.hh" +#include "auth/common.hh" +#include "auth/passwords.hh" +#include "auth/rest_authenticator.hh" +#include "auth/rest_role_manager.hh" +#include "auth/roles-metadata.hh" + cql_test_config rest_authenticator_on() { cql_test_config cfg; @@ -30,8 +38,11 @@ cql_test_config rest_authenticator_on() { cfg.db_config->rest_authenticator_endpoint_host("localhost"); cfg.db_config->rest_authenticator_endpoint_port(54321); cfg.db_config->rest_authenticator_endpoint_cafile_path("tools/rest_authenticator_server/ssl/ca.crt"); - cfg.db_config->rest_authenticator_endpoint_ttl(10); // TODO confirm unit - cfg.db_config->rest_authenticator_endpoint_timeout(10);// TODO confirm unit + cfg.db_config->rest_authenticator_endpoint_ttl(10); + cfg.db_config->rest_authenticator_endpoint_timeout(10); + + cfg.db_config->role_manager("com.criteo.scylladb.auth.RestManager"); + cfg.db_config->authorizer("CassandraAuthorizer"); return cfg; } @@ -51,11 +62,8 @@ class rest_authentication_handler : public seastar::httpd::handler_base { auto auth_token = auth_header.substr(6); auto auth_token_str = base64_decode(auth_token); - std::cout << "***** received auth_token = " << auth_token << std::endl; - std::cout << "***** received auth_token_str = " << auth_token_str << std::endl; - if (auth_token_str.find("alice") != sstring::npos) { - rep->write_body(sstring("json"), sstring("{\"groups\": [\"scylla-rw\"]}")); + rep->write_body(sstring("json"), sstring("{\"groups\": [\"scylla-rw\", \"other\"]}")); } else if (auth_token_str.find("john.doe") != sstring::npos) { rep->set_status(seastar::httpd::reply::status_type::not_found); rep->done(); @@ -68,7 +76,6 @@ class rest_authentication_handler : public seastar::httpd::handler_base { } }; - future<> with_dummy_authentication_server(std::function func) { return seastar::async([func] { auto conf = std::move(rest_authenticator_on()); @@ -106,9 +113,139 @@ future<> with_dummy_authentication_server(std::function f }); } +struct record final { + sstring name; + bool is_superuser; + bool can_login; + auth::role_set member_of; + sstring salted_hash; +}; + +static future > find_record(cql3::query_processor &qp, std::string_view role_name) { + static const sstring query = format("SELECT * FROM {} WHERE {} = ?", + auth::meta::roles_table::qualified_name, + auth::meta::roles_table::role_col_name); + return qp.execute_internal( + query, + db::consistency_level::LOCAL_ONE, + auth::internal_distributed_timeout_config(), + {sstring(role_name)}, + true).then([](::shared_ptr results) { + if (results->empty()) { + return std::optional(); + } + + const cql3::untyped_result_set_row &row = results->one(); + + return std::make_optional( + record{ + row.get_as(sstring(auth::meta::roles_table::role_col_name)), + row.get_or("is_superuser", false), + row.get_or("can_login", false), + (row.has("member_of") + ? row.get_set("member_of") + : auth::role_set()), + row.get_as("salted_hash")}); + }); +} + +static future can_login(cql3::query_processor &qp, std::string_view role_name) { + return find_record(qp, role_name).then([](std::optional mr) { + if (mr) { + record r = *mr; + return r.can_login; + } + return false; + }); +} + +static future is_superuser(cql3::query_processor &qp, std::string_view role_name) { + return find_record(qp, role_name).then([](std::optional mr) { + if (mr) { + record r = *mr; + return r.is_superuser; + } + return false; + }); +} + +static future get_role_set(cql3::query_processor &qp, std::string_view role_name) { + return find_record(qp, role_name).then([](std::optional mr) { + if (mr) { + record r = *mr; + return r.member_of; + } + return auth::role_set(); + }); +} + +static future get_salted_hash(cql3::query_processor &qp, std::string_view role_name) { + return find_record(qp, role_name).then([](std::optional mr) { + if (mr) { + record r = *mr; + return r.salted_hash; + } + return sstring(); + }); +} + +static future<> delete_record_valid(cql3::query_processor &qp, std::string_view role_name) { + static const sstring query = format("DELETE FROM {} WHERE {} = ?", + auth::meta::roles_valid_table::qualified_name, + auth::meta::roles_valid_table::role_col_name); + return qp.execute_internal( + query, + db::consistency_level::LOCAL_ONE, + auth::internal_distributed_timeout_config(), + {sstring(role_name)}, + true).discard_result(); +}; + +static future > find_record_valid(cql3::query_processor &qp, std::string_view role_name) { + static const sstring query = format("SELECT * FROM {} WHERE {} = ?", + auth::meta::roles_valid_table::qualified_name, + auth::meta::roles_valid_table::role_col_name); + return qp.execute_internal( + query, + db::consistency_level::LOCAL_ONE, + auth::internal_distributed_timeout_config(), + {sstring(role_name)}, + true).then([](::shared_ptr results) { + if (results->empty()) { + return std::optional(); + } + + const cql3::untyped_result_set_row &row = results->one(); + return std::make_optional(row.get_as(sstring(auth::meta::roles_valid_table::role_col_name))); + }); +}; + +static future require_record_valid(cql3::query_processor &qp, std::string_view role_name) { + return find_record_valid(qp, role_name).then([role_name](std::optional mr) { + if (!mr) { + throw auth::nonexistant_role(role_name); + } + return make_ready_future(*mr); + }); +} + +static thread_local auto rng_for_salt = std::default_random_engine(std::random_device{}()); + +static future<> create_superuser_role(cql3::query_processor &qp) { + static const sstring query = format( + "INSERT INTO {} ({}, is_superuser, can_login, salted_hash) VALUES (?, true, true, ?)", + auth::meta::roles_table::qualified_name, + auth::meta::roles_table::role_col_name); + return qp.execute_internal( + query, + db::consistency_level::QUORUM, + auth::internal_distributed_timeout_config(), + {auth::meta::DEFAULT_SUPERUSER_NAME, + auth::passwords::hash(sstring(auth::meta::DEFAULT_SUPERUSER_NAME), rng_for_salt)}).discard_result(); +} SEASTAR_TEST_CASE(rest_authenticator_conf) { - return with_dummy_authentication_server([] (cql_test_env& env) { + return with_dummy_authentication_server([](cql_test_env &env) { auto &a = env.local_auth_service().underlying_authenticator(); BOOST_REQUIRE_EQUAL(a.qualified_java_name(), "com.criteo.scylladb.auth.RestAuthenticator"); BOOST_REQUIRE(a.require_authentication()); @@ -121,12 +258,17 @@ SEASTAR_TEST_CASE(rest_authenticator_conf) { BOOST_REQUIRE_EQUAL(authenticator_config.rest_authenticator_endpoint_ttl, 10); BOOST_REQUIRE_EQUAL(authenticator_config.rest_authenticator_endpoint_timeout, 10); + auto &authorizer = env.local_auth_service().underlying_authorizer(); + BOOST_REQUIRE_EQUAL(authorizer.qualified_java_name(), + "org.apache.cassandra.auth.CassandraAuthorizer"); + + auto &rm = env.local_auth_service().underlying_role_manager(); + BOOST_REQUIRE_EQUAL(rm.qualified_java_name(), "com.criteo.scylladb.auth.RestManager"); }); } - SEASTAR_TEST_CASE(valid_user) { - return with_dummy_authentication_server([] (cql_test_env& env) { + return with_dummy_authentication_server([](cql_test_env &env) { auto &a = env.local_auth_service().underlying_authenticator(); auto creds = auth::authenticator::credentials_map{ @@ -136,11 +278,56 @@ SEASTAR_TEST_CASE(valid_user) { auto auth_user = a.authenticate(creds).get(); BOOST_REQUIRE_EQUAL(auth_user.name.value(), "alice"); + + // Check state in DB + auto &qp = env.local_qp(); + BOOST_REQUIRE(can_login(qp, "alice").get()); + BOOST_REQUIRE(can_login(qp, "norole").get() == false); + + // Check state through role_manager should be align with DB state + auto &rm = env.local_auth_service().underlying_role_manager(); + BOOST_REQUIRE(rm.can_login("alice").get()); + BOOST_REQUIRE_EXCEPTION(rm.can_login("norole").get(), exceptions::invalid_request_exception, + seastar::testing::exception_predicate::message_contains( + "Role norole doesn't exist.")); + }); +} + +SEASTAR_TEST_CASE(valid_superuser) { + return with_dummy_authentication_server([](cql_test_env &env) { + auto &qp = env.local_qp(); + create_superuser_role(qp).get(); + + auto &a = env.local_auth_service().underlying_authenticator(); + + auto creds = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("cassandra")}, + {auth::authenticator::PASSWORD_KEY, sstring("cassandra")} + }; + + auto auth_user = a.authenticate(creds).get(); + BOOST_REQUIRE_EQUAL(auth_user.name.value(), "cassandra"); + BOOST_REQUIRE(is_superuser(qp, "cassandra").get()); + }); +} + +SEASTAR_TEST_CASE(bad_password_superuser) { + return with_dummy_authentication_server([](cql_test_env &env) { + auto &a = env.local_auth_service().underlying_authenticator(); + + auto creds = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("cassandra")}, + {auth::authenticator::PASSWORD_KEY, sstring("bad_password")} + }; + + BOOST_REQUIRE_EXCEPTION(a.authenticate(creds).get(), exceptions::authentication_exception, + seastar::testing::exception_predicate::message_contains( + "Bad password for superuser")); }); } SEASTAR_TEST_CASE(unknown_user) { - return with_dummy_authentication_server([] (cql_test_env& env) { + return with_dummy_authentication_server([](cql_test_env &env) { auto &a = env.local_auth_service().underlying_authenticator(); auto creds = auth::authenticator::credentials_map{ @@ -149,12 +336,23 @@ SEASTAR_TEST_CASE(unknown_user) { }; BOOST_REQUIRE_EXCEPTION(a.authenticate(creds).get(), exceptions::authentication_exception, - seastar::testing::exception_predicate::message_contains("Unknown username")); + seastar::testing::exception_predicate::message_contains( + "Unknown username")); + + // Check state in DB + auto &qp = env.local_qp(); + BOOST_REQUIRE(can_login(qp, "john.doe").get() == false); + + // Check state through role_manager should be align with DB state + auto &rm = env.local_auth_service().underlying_role_manager(); + BOOST_REQUIRE_EXCEPTION(rm.can_login("john.doe").get(), exceptions::invalid_request_exception, + seastar::testing::exception_predicate::message_contains( + "Role john.doe doesn't exist.")); }); } SEASTAR_TEST_CASE(invalid_credentials) { - return with_dummy_authentication_server([] (cql_test_env& env) { + return with_dummy_authentication_server([](cql_test_env &env) { auto &a = env.local_auth_service().underlying_authenticator(); auto creds = auth::authenticator::credentials_map{ @@ -164,5 +362,97 @@ SEASTAR_TEST_CASE(invalid_credentials) { BOOST_REQUIRE_EXCEPTION(a.authenticate(creds).get(), exceptions::authentication_exception, seastar::testing::exception_predicate::message_contains("Bad password")); + + // Check state in DB + auto &qp = env.local_qp(); + BOOST_REQUIRE(can_login(qp, "foo.bar").get() == false); + + // Check state through role_manager should be align with DB state + auto &rm = env.local_auth_service().underlying_role_manager(); + BOOST_REQUIRE_EXCEPTION(rm.can_login("foo.bar").get(), exceptions::invalid_request_exception, + seastar::testing::exception_predicate::message_contains( + "Role foo.bar doesn't exist.")); + }); +} + + +SEASTAR_TEST_CASE(user_has_roles) { + return with_dummy_authentication_server([](cql_test_env &env) { + auto &a = env.local_auth_service().underlying_authenticator(); + + auto creds = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("alice")}, + {auth::authenticator::PASSWORD_KEY, sstring("password")} + }; + + a.authenticate(creds).discard_result().get(); + + auth::role_set roles; + roles.insert(sstring("scylla-rw")); + roles.insert(sstring("other")); + + // Check state in DB + auto &qp = env.local_qp(); + BOOST_REQUIRE_EQUAL(get_role_set(qp, "alice").get(), roles); + + // Check state through role_manager + roles.insert(sstring("alice")); // query_granted also return current role name in the role set + auto &rm = env.local_auth_service().underlying_role_manager(); + BOOST_REQUIRE_EQUAL(rm.query_granted("alice", auth::recursive_role_query::no).get(), roles); + }); +} + +SEASTAR_TEST_CASE(user_expired_is_well_recreated) { + return with_dummy_authentication_server([](cql_test_env &env) { + auto &a = env.local_auth_service().underlying_authenticator(); + + auto creds = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("alice")}, + {auth::authenticator::PASSWORD_KEY, sstring("password")} + }; + + a.authenticate(creds).discard_result().get(); + + auto &qp = env.local_qp(); + BOOST_REQUIRE_EQUAL(require_record_valid(qp, "alice").get(), "alice"); + + // To ensure deletion of expired rows (TTL) + forward_jump_clocks(20s); + BOOST_REQUIRE_EXCEPTION(require_record_valid(qp, "alice").get(), exceptions::invalid_request_exception, + seastar::testing::exception_predicate::message_contains( + "Role alice doesn't exist.")); + + a.authenticate(creds).get(); + // Entry has been well recreated in the DB + BOOST_REQUIRE_EQUAL(require_record_valid(qp, "alice").get(), "alice"); + }); +} + + +SEASTAR_TEST_CASE(user_password_is_updated) { + return with_dummy_authentication_server([](cql_test_env &env) { + auto &a = env.local_auth_service().underlying_authenticator(); + + auto creds = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("alice")}, + {auth::authenticator::PASSWORD_KEY, sstring("password")} + }; + + a.authenticate(creds).discard_result().get(); + + auto &qp = env.local_qp(); + BOOST_REQUIRE_EQUAL(require_record_valid(qp, "alice").get(), "alice"); + sstring salted_hash = get_salted_hash(qp, "alice").get(); + + auto creds2 = auth::authenticator::credentials_map{ + {auth::authenticator::USERNAME_KEY, sstring("alice")}, + {auth::authenticator::PASSWORD_KEY, sstring("password2")} + }; + + a.authenticate(creds2).discard_result().get(); + BOOST_REQUIRE_EQUAL(require_record_valid(qp, "alice").get(), "alice"); + + sstring salted_hash2 = get_salted_hash(qp, "alice").get(); + BOOST_REQUIRE(salted_hash != salted_hash2); }); -} \ No newline at end of file +} diff --git a/tools/rest_authenticator_server/client.py b/tools/rest_authenticator_server/client.py index ce919d01deea..c1d2aacf2601 100644 --- a/tools/rest_authenticator_server/client.py +++ b/tools/rest_authenticator_server/client.py @@ -24,13 +24,47 @@ from cassandra.cluster import Cluster from cassandra.auth import PlainTextAuthProvider -if __name__ == '__main__': - auth_provider = PlainTextAuthProvider(username='scylla_user', password='not_cassandra') + +def contact_scylla(username='scylla_user', password='not_cassandra'): + print(f'Run with user {username}') + auth_provider = PlainTextAuthProvider(username=username, password=password) cluster = Cluster(auth_provider=auth_provider, protocol_version=2) session = cluster.connect() + try: + print('roles') + rows = session.execute('SELECT * FROM system_auth.roles') + for user_row in rows: + print(user_row) + + print('role_members') + rows = session.execute('SELECT * FROM system_auth.roles_valid') + for user_row in rows: + print(user_row) + + if username == 'cassandra': + print('permissions') + session.execute('GRANT ALL PERMISSIONS ON system_auth.roles TO group1') + session.execute('GRANT ALL PERMISSIONS ON system_auth.roles_valid TO group1') - session.execute("DELETE FROM system_auth.roles where role='scylla_user'") + # print('Delete scylla role') + # session.execute("DELETE FROM system_auth.roles where role='scylla_user'") + # session.execute("DELETE FROM system_auth.roles_valid where role='scylla_user'") - rows = session.execute('SELECT * FROM system_auth.roles') - for user_row in rows: - print(user_row) + # print('role_permissions') + # rows = session.execut + # e('SELECT * FROM system_auth.role_permissions') + # for user_row in rows: + # print(user_row) + + # print(session.execute('LIST ALL PERMISSIONS OF scylla_user;')) + # rows = session.execute('LIST ROLES OF scylla_user;') + # for user_row in rows: + # print(user_row) + finally: + session.shutdown() + + +if __name__ == '__main__': + contact_scylla(username='cassandra', password='cassandra') + contact_scylla() + contact_scylla(username='scylla_user2', password='not_cassandra')