From 7a40c7f2f695caba0ebf30cad6bd03e23344520b Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Thu, 26 Mar 2026 01:54:25 +0300 Subject: [PATCH] Support TLS CN authentication and per-image permissions in vitastor-cli serve --- mon/etcd_schema.js | 8 +++ src/client/etcd_state_client.cpp | 9 ++++ src/client/etcd_state_client.h | 2 + src/client/http_client.cpp | 29 ++++++---- src/cmd/cli.h | 12 +++++ src/cmd/cli_common.cpp | 21 ++++++++ src/cmd/cli_create.cpp | 32 +++++++++++ src/cmd/cli_flatten.cpp | 6 +++ src/cmd/cli_ls.cpp | 19 +++++-- src/cmd/cli_modify.cpp | 6 +++ src/cmd/cli_rm.cpp | 13 +++++ src/cmd/cli_serve.cpp | 93 ++++++++++++++++++++++++-------- 12 files changed, 214 insertions(+), 36 deletions(-) diff --git a/mon/etcd_schema.js b/mon/etcd_schema.js index ee8b7516..2043b081 100644 --- a/mon/etcd_schema.js +++ b/mon/etcd_schema.js @@ -16,6 +16,7 @@ const etcd_allow = new RegExp('^'+[ 'config/pools', 'config/osd/[1-9]\\d*', 'config/pgs', // old name + 'config/user/.*', 'pg/config', 'config/inode/[1-9]\\d*/[1-9]\\d*', 'osd/state/[1-9]\\d*', @@ -234,6 +235,13 @@ const etcd_tree = { } }, */ inode: {}, + /* user: { + : { + type: 'osd'|'mon'|'admin'|'client', + groups: string[], + }, + }, */ + user: {}, }, osd: { state: { diff --git a/src/client/etcd_state_client.cpp b/src/client/etcd_state_client.cpp index 2b3afcea..9fcc5e8a 100644 --- a/src/client/etcd_state_client.cpp +++ b/src/client/etcd_state_client.cpp @@ -262,6 +262,7 @@ void etcd_state_client_t::parse_config(const json11::Json & config) } this->etcd_ca = config["etcd_ca"].string_value(); this->etcd_prefix = config["etcd_prefix"].string_value(); + this->use_auth = config["use_auth"].bool_value(); if (this->etcd_prefix == "") { this->etcd_prefix = "/vitastor"; @@ -1353,6 +1354,14 @@ void etcd_state_client_t::parse_state(const etcd_kv_t & kv) if (on_change_node_placement_hook) on_change_node_placement_hook(); } + else if (use_auth && key.substr(0, etcd_prefix.length()+13) == etcd_prefix+"/config/user/") + { + // /config/user/ + if (!value.is_object()) + user_info.erase(key.substr(etcd_prefix.length()+13)); + else + user_info[key.substr(etcd_prefix.length()+13)] = value; + } } uint32_t etcd_state_client_t::parse_immediate_commit(const std::string & immediate_commit_str, uint32_t default_value) diff --git a/src/client/etcd_state_client.h b/src/client/etcd_state_client.h index 6b61c1d9..109044ee 100644 --- a/src/client/etcd_state_client.h +++ b/src/client/etcd_state_client.h @@ -145,6 +145,7 @@ public: int etcd_slow_timeout = 5000; int etcd_min_reload_interval = 1000; bool infinite_start = true; + bool use_auth = false; uint64_t global_block_size = DEFAULT_BLOCK_SIZE; uint32_t global_bitmap_granularity = DEFAULT_BITMAP_GRANULARITY; uint32_t global_immediate_commit = IMMEDIATE_NONE; @@ -170,6 +171,7 @@ public: std::set seen_peers; std::map inode_config; std::map inode_by_name; + std::map user_info; json11::Json node_placement; std::function &)> on_change_hook; diff --git a/src/client/http_client.cpp b/src/client/http_client.cpp index 9e5ea723..7768c429 100644 --- a/src/client/http_client.cpp +++ b/src/client/http_client.cpp @@ -187,6 +187,19 @@ bool openssl_ctx_use_ca(SSL_CTX *ssl_ctx, const std::string & file_or_pem) : !!SSL_CTX_load_verify_locations(ssl_ctx, file_or_pem.c_str(), NULL); } +static std::string openssl_get_cn(X509 *x509) +{ + X509_NAME* subj = X509_get_subject_name(x509); + int pos = X509_NAME_get_index_by_NID(subj, NID_commonName, -1); + if (pos != -1) + { + X509_NAME_ENTRY* cn = X509_NAME_get_entry(subj, pos); + ASN1_STRING* str = X509_NAME_ENTRY_get_data(cn); + return std::string((const char*)ASN1_STRING_get0_data(str), ASN1_STRING_length(str)); + } + return ""; +} + bool openssl_ctx_use_cert(SSL_CTX *ssl_ctx, const std::string & file_or_pem, std::string & common_name) { BIO *bio = NULL; @@ -208,16 +221,7 @@ bool openssl_ctx_use_cert(SSL_CTX *ssl_ctx, const std::string & file_or_pem, std { ok = SSL_CTX_use_certificate(ssl_ctx, x509); if (ok) - { - X509_NAME* subj = X509_get_subject_name(x509); - int pos = X509_NAME_get_index_by_NID(subj, NID_commonName, -1); - if (pos != -1) - { - X509_NAME_ENTRY* cn = X509_NAME_get_entry(subj, pos); - ASN1_STRING* str = X509_NAME_ENTRY_get_data(cn); - common_name = std::string((const char*)ASN1_STRING_get0_data(str), ASN1_STRING_length(str)); - } - } + common_name = openssl_get_cn(x509); X509_free(x509); } BIO_free(bio); @@ -1136,6 +1140,11 @@ bool http_co_t::handle_read() } state = HTTP_CO_REQ_HDR_RECEIVED; parse_http_headers(response, &parsed, true); + if (ssl) + { + auto x509 = SSL_get0_peer_certificate(ssl_cli); + parsed.headers["_tls_common_name"] = openssl_get_cn(x509); + } auto conn_it = parsed.headers.find("connection"); keepalive = (conn_it != parsed.headers.end() && conn_it->second == "keep-alive"); auto enc_it = parsed.headers.find("transfer-encoding"); diff --git a/src/cmd/cli.h b/src/cmd/cli.h index bc78e930..5c5e1696 100644 --- a/src/cmd/cli.h +++ b/src/cmd/cli.h @@ -9,6 +9,7 @@ #include "object_id.h" #include "ringloop.h" #include +#include struct rm_inode_t; struct snap_merger_t; @@ -26,6 +27,13 @@ struct cli_result_t json11::Json data; }; +struct cli_user_t +{ + std::string name; + std::string type; + std::set groups; +}; + class cli_tool_t { public: @@ -37,6 +45,8 @@ public: bool is_command_line = false; bool color = false; + std::unique_ptr user; // for http mode + ring_loop_t *ringloop = NULL; epoll_manager_t *epmgr = NULL; cluster_client_t *cli = NULL; @@ -53,6 +63,8 @@ public: void change_parent(inode_t cur, inode_t new_parent, cli_result_t *result); inode_config_t* get_inode_cfg(const std::string & name); + bool check_image_perm(const inode_config_t & cfg, bool write); + friend struct rm_inode_t; friend struct snap_merger_t; friend struct snap_flattener_t; diff --git a/src/cmd/cli_common.cpp b/src/cmd/cli_common.cpp index 11f827a4..75a9faab 100644 --- a/src/cmd/cli_common.cpp +++ b/src/cmd/cli_common.cpp @@ -6,6 +6,15 @@ #include "cluster_client.h" #include "cli.h" +bool cli_tool_t::check_image_perm(const inode_config_t & cfg, bool write) +{ + return !user || + user->type == "admin" || + user->name == cfg.owner || + cfg.owner_group != "" && user->groups.find(cfg.owner_group) != user->groups.end() || + !write && cfg.reader_group != "" && user->groups.find(cfg.reader_group) != user->groups.end(); +} + json11::Json::object cli_tool_t::format_image(const inode_config_t & cfg) { auto pool_it = cli->st_cli.pool_config.find(INODE_POOL(cfg.num)); @@ -20,6 +29,18 @@ json11::Json::object cli_tool_t::format_image(const inode_config_t & cfg) { "readonly", cfg.readonly }, { "deleted", cfg.deleted }, }; + if (cfg.owner != "") + { + img["owner"] = cfg.owner; + } + if (cfg.owner_group != "") + { + img["owner_group"] = cfg.owner_group; + } + if (cfg.reader_group != "") + { + img["reader_group"] = cfg.reader_group; + } if (!cfg.enc_key.empty()) { img["encrypted"] = true; diff --git a/src/cmd/cli_create.cpp b/src/cmd/cli_create.cpp index 2d866f18..d3531c7d 100644 --- a/src/cmd/cli_create.cpp +++ b/src/cmd/cli_create.cpp @@ -121,6 +121,23 @@ struct image_creator_t create_snapshot(); } + bool check_pool_permission() + { + if (!parent->user || parent->user->type == "admin") + { + return true; + } + auto pool_it = parent->cli->st_cli.pool_config.find(new_pool_id); + if (pool_it == parent->cli->st_cli.pool_config.end() || + (pool_it->second.creator_group == "" || parent->user->groups.find(pool_it->second.creator_group) == parent->user->groups.end())) + { + result = (cli_result_t){ .err = EACCES, .text = "Pool image create permission denied" }; + state = 100; + return false; + } + return true; + } + void create_image() { if (state == 2) @@ -160,6 +177,10 @@ struct image_creator_t state = 100; return; } + if (!check_pool_permission()) + { + return; + } if (!size && !force_size) { result = (cli_result_t){ .err = EINVAL, .text = "Image size is missing" }; @@ -251,11 +272,22 @@ resume_3: state = 100; return; } + if (!parent->check_image_perm(cur_cfg, true)) + { + result = (cli_result_t){ .err = EACCES, .text = "Image permission denied" }; + state = 100; + return; + } if (!new_pool_id) { // Create snapshot in the same pool by default new_pool_id = old_pool_id; } + // Verify pool permissions if the pool is different from the original + if (new_pool_id != old_pool_id && !check_pool_permission()) + { + return; + } attempt_create(); state = 4; resume_4: diff --git a/src/cmd/cli_flatten.cpp b/src/cmd/cli_flatten.cpp index 5adc7798..6e102294 100644 --- a/src/cmd/cli_flatten.cpp +++ b/src/cmd/cli_flatten.cpp @@ -35,6 +35,12 @@ struct snap_flattener_t state = 100; return; } + if (!parent->check_image_perm(*target_cfg, true)) + { + result = (cli_result_t){ .err = EACCES, .text = "Image permission denied" }; + state = 100; + return; + } target_id = target_cfg->num; std::vector chain_list; inode_config_t *cur = target_cfg; diff --git a/src/cmd/cli_ls.cpp b/src/cmd/cli_ls.cpp index b31760b7..a4021c7a 100644 --- a/src/cmd/cli_ls.cpp +++ b/src/cmd/cli_ls.cpp @@ -53,13 +53,19 @@ struct image_lister_t return; } } - for (auto & ic: parent->cli->st_cli.inode_config) + auto begin_it = list_pool_id + ? parent->cli->st_cli.inode_config.lower_bound(INODE_WITH_POOL(list_pool_id, 0)) + : parent->cli->st_cli.inode_config.begin(); + auto end_it = list_pool_id + ? parent->cli->st_cli.inode_config.lower_bound(INODE_WITH_POOL(list_pool_id+1, 0)) + : parent->cli->st_cli.inode_config.end(); + for (auto it = begin_it; it != end_it; it++) { - if (list_pool_id && INODE_POOL(ic.second.num) != list_pool_id) + if (!parent->check_image_perm(it->second, false)) { continue; } - stats[ic.second.num] = parent->format_image(ic.second); + stats[it->second.num] = parent->format_image(it->second); } } @@ -106,6 +112,7 @@ resume_1: state = 100; return; } + // FIXME: Do not always read everything space_info = parent->etcd_result; std::map pool_pg_real_size; for (auto & kv_item: space_info["responses"][0]["response_range"]["kvs"].array_items()) @@ -139,6 +146,11 @@ resume_1: } inode_t inode_num = INODE_WITH_POOL(pool_id, only_inode_num); uint64_t used_size = kv.value["raw_used"].uint64_value(); + auto stat_it = stats.find(inode_num); + if (parent->user && parent->user->type != "admin" && stat_it == stats.end()) + { + continue; + } // save stats auto pool_it = parent->cli->st_cli.pool_config.find(pool_id); if (pool_it != parent->cli->st_cli.pool_config.end()) @@ -147,7 +159,6 @@ resume_1: used_size = used_size / (pool_pg_real_size[pool_id] ? pool_pg_real_size[pool_id] : 1) * (pool_cfg.scheme == POOL_SCHEME_REPLICATED ? 1 : pool_cfg.pg_size-pool_cfg.parity_chunks); } - auto stat_it = stats.find(inode_num); if (stat_it == stats.end()) { stats[inode_num] = json11::Json::object { diff --git a/src/cmd/cli_modify.cpp b/src/cmd/cli_modify.cpp index 40fadbf0..f6738769 100644 --- a/src/cmd/cli_modify.cpp +++ b/src/cmd/cli_modify.cpp @@ -77,6 +77,12 @@ struct image_changer_t state = 100; return; } + if (!parent->check_image_perm(cfg, true)) + { + result = (cli_result_t){ .err = EACCES, .text = "Image permission denied" }; + state = 100; + return; + } for (auto & ic: parent->cli->st_cli.inode_config) { if (ic.second.parent_id == inode_num) diff --git a/src/cmd/cli_rm.cpp b/src/cmd/cli_rm.cpp index fcc87c31..65aa73f4 100644 --- a/src/cmd/cli_rm.cpp +++ b/src/cmd/cli_rm.cpp @@ -269,10 +269,17 @@ resume_100: { char buf[1024]; snprintf(buf, 1024, "Parent inode of layer %s (id 0x%jx) not found", cur->name.c_str(), cur->parent_id); + result = (cli_result_t){ .err = ENOENT, .text = buf }; state = 100; return; } cur = &it->second; + if (!parent->check_image_perm(*cur, true)) + { + result = (cli_result_t){ .err = EACCES, .text = "Image permission denied" }; + state = 100; + return; + } chain_list.push_back(cur->num); } if (cur->num != from_cfg->num) @@ -297,6 +304,12 @@ resume_100: auto it = sources.find(ic.second.parent_id); if (it != sources.end() && sources.find(ic.second.num) == sources.end()) { + if (!parent->check_image_perm(ic.second, true)) + { + result = (cli_result_t){ .err = EACCES, .text = "Image permission denied" }; + state = 100; + return; + } merge_children.push_back(ic.second.num); if (ic.second.readonly || writers_stopped) { diff --git a/src/cmd/cli_serve.cpp b/src/cmd/cli_serve.cpp index 419fd04a..e85d8e33 100644 --- a/src/cmd/cli_serve.cpp +++ b/src/cmd/cli_serve.cpp @@ -35,34 +35,35 @@ struct cli_serve_path_t { std::string cmd; bool allow_get; + bool allow_client; }; // Serve vitastor-cli commands over HTTP in JSON format struct cli_serve_t { std::map cmd_paths = { - {"data/delete", {"rm-data", false}}, - {"data/describe", {"describe", true}}, - {"data/fix", {"fix", false}}, - {"data/merge", {"merge-data", false}}, - {"image/create", {"create", false}}, - {"image/delete", {"rm", false}}, - {"image/flatten", {"flatten", false}}, - {"image/list", {"ls", true}}, - {"image/modify", {"modify", false}}, - {"osd/alloc", {"alloc-osd", false}}, - {"osd/delete", {"rm-osd", false}}, - {"osd/list", {"ls-osd", true}}, - {"osd/modify", {"modify-osd", false}}, - {"pg/list", {"ls-pgs", true}}, - {"pool/create", {"create-pool", false}}, - {"pool/delete", {"rm-pool", false}}, - {"pool/list", {"pools", true}}, - {"pool/modify", {"modify-pool", false}}, - {"user/delete", {"remove-user", false}}, - {"user/list", {"ls-user", false}}, - {"user/modify", {"modify-user", false}}, - {"status", {"status", true}}, + {"data/delete", {"rm-data", false, false}}, + {"data/describe", {"describe", true, false}}, + {"data/fix", {"fix", false, false}}, + {"data/merge", {"merge-data", false, false}}, + {"image/create", {"create", false, true}}, + {"image/delete", {"rm", false, true}}, + {"image/flatten", {"flatten", false, true}}, + {"image/list", {"ls", true, true}}, + {"image/modify", {"modify", false, true}}, + {"osd/alloc", {"alloc-osd", false, false}}, + {"osd/delete", {"rm-osd", false, false}}, + {"osd/list", {"ls-osd", true, false}}, + {"osd/modify", {"modify-osd", false, false}}, + {"pg/list", {"ls-pgs", true, false}}, + {"pool/create", {"create-pool", false, false}}, + {"pool/delete", {"rm-pool", false, false}}, + {"pool/list", {"pools", true, false}}, + {"pool/modify", {"modify-pool", false, false}}, + {"user/delete", {"remove-user", false, false}}, + {"user/list", {"ls-user", false, false}}, + {"user/modify", {"modify-user", false, false}}, + {"status", {"status", true, false}}, }; cli_tool_t *parent = NULL; @@ -203,6 +204,12 @@ struct cli_serve_t if (text) *text = "Bad Request"; } + else if (err == EACCES) + { + code = 403; + if (text) + *text = "Forbidden"; + } else if (err == EOPNOTSUPP) { code = 404; @@ -337,6 +344,18 @@ struct cli_serve_t conn->request_path = std::move(req_line[1]); conn->request_body = std::move(msg->body); conn->response_type = ""; + if (parent->cli->st_cli.use_auth) + { + auto user = std::make_unique(); + user->name = msg->headers["_tls_common_name"]; + auto user_it = parent->cli->st_cli.user_info.find(user->name); + auto userinfo = user_it == parent->cli->st_cli.user_info.end() ? user_it->second : json11::Json(); + user->type = user->name == "root" ? "admin" : userinfo["type"].string_value(); + for (auto & gr: userinfo["groups"].array_items()) + { + user->groups.insert(gr.string_value()); + } + } auto ctype = msg->headers["content-type"]; if (conn->request_method != "GET" && conn->request_method != "POST") { @@ -364,6 +383,32 @@ struct cli_serve_t { conn->response_type = "application/json"; conn->result = { .text = openapi_description }; + if (parent->cli->st_cli.use_auth) + { + // Filter available paths by privileges + if (conn->p->user->type == "client") + { + std::string error; + auto openapi = json11::Json::parse(openapi_description, error).object_items(); + json11::Json::object paths; + for (auto & kv: openapi["paths"].object_items()) + { + auto cmd_it = cmd_paths.find(kv.first.substr(1)); + if (cmd_it != cmd_paths.end() && cmd_it->second.allow_client) + { + paths[kv.first] = kv.second; + } + } + openapi["paths"] = paths; + conn->response_type = "application/json"; + conn->result = { .text = json11::Json(openapi).dump() }; + } + else if (conn->p->user->type != "admin") + { + conn->response_type = ""; + conn->result = { .err = EACCES, .text = "Access denied" }; + } + } } else if (cmd_it == cmd_paths.end()) { @@ -373,6 +418,10 @@ struct cli_serve_t { conn->result = { .err = ENOSYS, .text = "method /"+uri[0]+" only allows POST requests" }; } + else if (parent->cli->st_cli.use_auth && conn->p->user->type == "client" && !cmd_it->second.allow_client) + { + conn->result = { .err = EACCES, .text = "Access denied" }; + } else { std::string error;