Support TLS CN authentication and per-image permissions in vitastor-cli serve

This commit is contained in:
Vitaliy Filippov
2026-04-17 13:53:41 +03:00
parent c014d20fca
commit c371b74e12
12 changed files with 214 additions and 36 deletions
+8
View File
@@ -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: {
<username>: {
type: 'osd'|'mon'|'admin'|'client',
groups: string[],
},
}, */
user: {},
},
osd: {
state: {
+9
View File
@@ -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/")
{
// <etcd_prefix>/config/user/<username>
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)
+2
View File
@@ -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<osd_num_t> seen_peers;
std::map<inode_t, inode_config_t> inode_config;
std::map<std::string, inode_t> inode_by_name;
std::map<std::string, json11::Json> user_info;
json11::Json node_placement;
std::function<void(std::map<std::string, etcd_kv_t> &)> on_change_hook;
+19 -10
View File
@@ -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");
+12
View File
@@ -9,6 +9,7 @@
#include "object_id.h"
#include "ringloop.h"
#include <functional>
#include <set>
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<std::string> groups;
};
class cli_tool_t
{
public:
@@ -37,6 +45,8 @@ public:
bool is_command_line = false;
bool color = false;
std::unique_ptr<cli_user_t> 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;
+21
View File
@@ -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;
+32
View File
@@ -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:
+6
View File
@@ -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<inode_t> chain_list;
inode_config_t *cur = target_cfg;
+15 -4
View File
@@ -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_id_t, uint64_t> 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 {
+6
View File
@@ -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)
+13
View File
@@ -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)
{
+71 -22
View File
@@ -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<std::string, cli_serve_path_t> 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<cli_user_t>();
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;