commit 38c6b0d38254d5ebde82cd469366955e808798b0 from: Maksim Tiushev via: Alexander Turenko date: Wed Sep 25 11:56:35 2024 UTC config: grant runtime access to lua_call from config This patch introduces the ability to grant execution privileges for Lua functions through declarative configuration, even when the database is in read-only mode or has an outdated schema version. Users can specify `lua_call: []`, enabling the execution of specified Lua functions (e.g., `failover:execute()` when all instance are in read-only mode). The `lua_call: [all]` option is also supported, allowing access to all global Lua functions except built-in ones, regardless of database mode or status. Privileges are still written to the database when possible for consistency and compatibility. Closes tarantool#10310 @TarantoolBot document Title: Grant runtime access to Lua functions via configuration It is now possible to grant execution privileges for Lua functions through the declarative configuration, even when the database is in read-only mode or has an outdated schema version. You can specify function permissions using the `lua_call` option in the configuration, for example: ```yaml credentials: users: alice: privileges: - permissions: [execute] lua_call: [my_func] ``` This grants the `alice` user permission to execute the `my_func` Lua function, regardless of the database's mode or status. The special option `lua_call: [all]` is also supported, granting access to all global Lua functions except built-in ones, bypassing database restrictions. Privileges will still be written to the database when possible to maintain compatibility and consistency with other privilege types. commit - db27af79da9866f0e65965671e4750f9c0cd7f38 commit + 38c6b0d38254d5ebde82cd469366955e808798b0 blob - /dev/null blob + dab95a2ad74ab188c7901b2882f690bca61d0d64 (mode 644) --- /dev/null +++ changelogs/unreleased/gh-10310-runtime-grant-lua-call-via-config.md @@ -0,0 +1,5 @@ +## feature/config + +* Now users can specify the `lua_call` option to allow calling Lua functions + even when the database is in read-only mode or has an outdated schema version + (gh-10310). blob - e2223e194e795aa9b1cff7b4358dfbd374c5751d blob + 82d5a253eff53279319a5bd97d4a393310ed0c60 --- src/box/CMakeLists.txt +++ src/box/CMakeLists.txt @@ -44,29 +44,30 @@ lua_source(lua_sources lua/iproto.lua iproto_lua) lua_source(lua_sources lua/mkversion.lua mkversion_lua) # {{{ config -lua_source(lua_sources lua/config/applier/app.lua config_applier_app_lua) -lua_source(lua_sources lua/config/applier/box_cfg.lua config_applier_box_cfg_lua) -lua_source(lua_sources lua/config/applier/compat.lua config_applier_compat_lua) -lua_source(lua_sources lua/config/applier/console.lua config_applier_console_lua) -lua_source(lua_sources lua/config/applier/credentials.lua config_applier_credentials_lua) -lua_source(lua_sources lua/config/applier/fiber.lua config_applier_fiber_lua) -lua_source(lua_sources lua/config/applier/mkdir.lua config_applier_mkdir_lua) -lua_source(lua_sources lua/config/applier/roles.lua config_applier_roles_lua) -lua_source(lua_sources lua/config/applier/sharding.lua config_applier_sharding_lua) -lua_source(lua_sources lua/config/cluster_config.lua config_cluster_config_lua) -lua_source(lua_sources lua/config/configdata.lua config_configdata_lua) -lua_source(lua_sources lua/config/init.lua config_init_lua) -lua_source(lua_sources lua/config/instance_config.lua config_instance_config_lua) -lua_source(lua_sources lua/config/source/env.lua config_source_env_lua) -lua_source(lua_sources lua/config/source/file.lua config_source_file_lua) -lua_source(lua_sources lua/config/utils/aboard.lua config_utils_aboard_lua) -lua_source(lua_sources lua/config/utils/expression.lua config_utils_expression_lua) -lua_source(lua_sources lua/config/utils/file.lua config_utils_file_lua) -lua_source(lua_sources lua/config/utils/log.lua config_utils_log_lua) -lua_source(lua_sources lua/config/utils/odict.lua config_utils_odict_lua) -lua_source(lua_sources lua/config/utils/schema.lua config_utils_schema_lua) -lua_source(lua_sources lua/config/utils/snapshot.lua config_utils_snapshot_lua) -lua_source(lua_sources lua/config/utils/tabulate.lua config_utils_tabulate_lua) +lua_source(lua_sources lua/config/applier/app.lua config_applier_app_lua) +lua_source(lua_sources lua/config/applier/box_cfg.lua config_applier_box_cfg_lua) +lua_source(lua_sources lua/config/applier/runtime_priv.lua config_applier_runtime_priv_lua) +lua_source(lua_sources lua/config/applier/compat.lua config_applier_compat_lua) +lua_source(lua_sources lua/config/applier/console.lua config_applier_console_lua) +lua_source(lua_sources lua/config/applier/credentials.lua config_applier_credentials_lua) +lua_source(lua_sources lua/config/applier/fiber.lua config_applier_fiber_lua) +lua_source(lua_sources lua/config/applier/mkdir.lua config_applier_mkdir_lua) +lua_source(lua_sources lua/config/applier/roles.lua config_applier_roles_lua) +lua_source(lua_sources lua/config/applier/sharding.lua config_applier_sharding_lua) +lua_source(lua_sources lua/config/cluster_config.lua config_cluster_config_lua) +lua_source(lua_sources lua/config/configdata.lua config_configdata_lua) +lua_source(lua_sources lua/config/init.lua config_init_lua) +lua_source(lua_sources lua/config/instance_config.lua config_instance_config_lua) +lua_source(lua_sources lua/config/source/env.lua config_source_env_lua) +lua_source(lua_sources lua/config/source/file.lua config_source_file_lua) +lua_source(lua_sources lua/config/utils/aboard.lua config_utils_aboard_lua) +lua_source(lua_sources lua/config/utils/expression.lua config_utils_expression_lua) +lua_source(lua_sources lua/config/utils/file.lua config_utils_file_lua) +lua_source(lua_sources lua/config/utils/log.lua config_utils_log_lua) +lua_source(lua_sources lua/config/utils/odict.lua config_utils_odict_lua) +lua_source(lua_sources lua/config/utils/schema.lua config_utils_schema_lua) +lua_source(lua_sources lua/config/utils/snapshot.lua config_utils_snapshot_lua) +lua_source(lua_sources lua/config/utils/tabulate.lua config_utils_tabulate_lua) if (ENABLE_CONFIG_EXTRAS) lua_source(lua_sources ${CONFIG_EXTRAS_DIR}/source/etcd.lua config_source_etcd_lua) blob - /dev/null blob + a44fab989226a924e123fb524788beedcfc1967e (mode 644) --- /dev/null +++ src/box/lua/config/applier/runtime_priv.lua @@ -0,0 +1,125 @@ +-- Extract and add functions from a user or a role definition to +-- the `{[func_name] = true, <...>}` mapping `res`. +-- +-- The source is `lua_call` declarations. +local function add_funcs(res, user_or_role_def) + local privileges = user_or_role_def.privileges or {} + + for _, privilege in ipairs(privileges) do + local permissions = privilege.permissions or {} + local has_execute = false + for _, perm in ipairs(permissions) do + if perm == 'execute' then + has_execute = true + break + end + end + if has_execute and privilege.lua_call ~= nil then + for _, func_name in ipairs(privilege.lua_call) do + res[func_name] = true + end + end + end +end + +-- Extract and add roles for the given user to the +-- `{[role_name] = true, <...>}` mapping `res`, including +-- transitively assigned (when a role is assigned to a role). +local function add_roles(res, user_or_role_def, ctx) + for _, role_name in ipairs(user_or_role_def.roles or {}) do + -- Detect a recursion. + if ctx.visited[role_name] then + error(('Recursion detected: credentials.roles.%s depends on ' .. + 'itself'):format(role_name), 0) + end + + -- Add the role into the result. + res[role_name] = true + + -- Add the nested roles. + -- + -- Ignore unknown roles. For example, there is a + -- built-in role 'super' that doesn't have to be + -- configured. + local role_def = ctx.roles[role_name] + if role_def ~= nil then + ctx.visited[role_name] = true + add_roles(res, role_def, ctx) + ctx.visited[role_name] = nil + end + end +end + +-- Extract all the user's functions listed in the `lua_call` +-- directives in the user definition or its roles assigned +-- directly or transitively over the other roles. +local function extract_funcs(user_name, ctx) + local user_def = ctx.users[user_name] + + local roles = {} + ctx.visited = {} + add_roles(roles, user_def, ctx) + ctx.visited = nil + + -- Collect a full set of functions for the given user. + -- + -- { + -- [func_name] = true, + -- <...>, + -- } + local funcs = {} + add_funcs(funcs, user_def) + for role_name, _ in pairs(roles) do + local role_def = ctx.roles[role_name] + if role_def ~= nil then + add_funcs(funcs, role_def) + end + end + + return funcs +end + +local function apply(config_module) + -- Prepare a context with the configuration information to + -- transform. + local configdata = config_module._configdata + local ctx = { + roles = configdata:get('credentials.roles') or {}, + users = configdata:get('credentials.users') or {}, + } + + -- Collect a mapping from users to their granted functions. + -- + -- { + -- [user_name] = { + -- [func_name] = true, + -- <...>, + -- }, + -- <...> + -- } + local res = {} + for user_name, _ in pairs(ctx.users) do + local funcs = extract_funcs(user_name, ctx) + if next(funcs) ~= nil then + res[user_name] = funcs + end + end + + -- Reset the runtime privileges and grant all the configured + -- ones. + box.internal.lua_call_runtime_priv_reset() + for user_name, funcs in pairs(res) do + for func_name, _ in pairs(funcs) do + if func_name == 'all' then + box.internal.lua_call_runtime_priv_grant(user_name, '') + else + box.internal.lua_call_runtime_priv_grant(user_name, func_name) + end + end + end +end + +return { + name = 'runtime_priv', + apply = apply, +} blob - 26a2fe4aceadfb66fb1c4a9bcabf6cfd5bd7303a blob + f356aa943ab9d5d72b51a0c60dd8141d779497c0 --- src/box/lua/config/init.lua +++ src/box/lua/config/init.lua @@ -146,6 +146,7 @@ function methods._initialize(self) self:_register_applier(require('internal.config.applier.compat')) self:_register_applier(require('internal.config.applier.mkdir')) self:_register_applier(require('internal.config.applier.console')) + self:_register_applier(require('internal.config.applier.runtime_priv')) self:_register_applier(require('internal.config.applier.box_cfg')) self:_register_applier(require('internal.config.applier.credentials')) self:_register_applier(require('internal.config.applier.fiber')) blob - 5d607b4071a05d426fd6470060ec462e180bcc5c blob + 386d8db4fa268f1bc9ad99f09304f8abcf122b6f --- src/box/lua/init.c +++ src/box/lua/init.c @@ -151,6 +151,7 @@ extern char session_lua[], /* {{{ config */ config_applier_app_lua[], config_applier_box_cfg_lua[], + config_applier_runtime_priv_lua[], config_applier_compat_lua[], config_applier_console_lua[], config_applier_credentials_lua[], @@ -426,6 +427,10 @@ static const char *lua_sources[] = { "internal.config.applier.console", config_applier_console_lua, + "config/applier/runtime_priv", + "internal.config.applier.runtime_priv", + config_applier_runtime_priv_lua, + "config/applier/credentials", "internal.config.applier.credentials", config_applier_credentials_lua, blob - /dev/null blob + 8e300555c2bd868937ef92633b8c7191edce36ef (mode 644) --- /dev/null +++ test/config-luatest/runtime_priv_test.lua @@ -0,0 +1,203 @@ +local t = require('luatest') +local cbuilder = require('luatest.cbuilder') +local cluster = require('test.config-luatest.cluster') + +local g = t.group() + +g.before_all(cluster.init) +g.after_each(cluster.drop) +g.after_all(cluster.clean) + +-- {{{ Testing helpers + +local function access_error_msg(user, func) + local templ = 'Execute access to function \'%s\' is denied for user \'%s\'' + return string.format(templ, func, user) +end + +local function define_access_error_msg_function(server) + server:exec(function(access_error_msg) + rawset(_G, 'access_error_msg', loadstring(access_error_msg)) + end, {string.dump(access_error_msg)}) +end + +local function define_stub_function(server, func_name) + server:exec(function(func_name) + rawset(_G, func_name, function() + return true + end) + end, {func_name}) +end + +local function define_get_connection_function(server) + server:exec(function() + local netbox = require('net.box') + local config = require('config') + + rawset(_G, 'get_connection', function() + local uri = config:instance_uri().uri + return netbox.connect(uri, {user = 'alice', password = 'ALICE'}) + end) + end) +end + +local function new_cluster_in_ro() + local config = cbuilder:new() + :add_instance('i-001', {}) + -- Create a user and set the password while we're in RW. + :set_global_option('credentials.users.alice', { + password = 'ALICE', + }) + :config() + local cluster = cluster.new(g, config) + cluster:start() + + define_access_error_msg_function(cluster['i-001']) + define_stub_function(cluster['i-001'], 'foo') + define_stub_function(cluster['i-001'], 'bar') + define_stub_function(cluster['i-001'], 'baz') + define_get_connection_function(cluster['i-001']) + + -- We can only bootstrap the initial database in the RW mode. + -- Let's reload to RO after it. + local config = cbuilder:new(config) + :set_global_option('database.mode', 'ro') + :config() + cluster:reload(config) + + -- Verify that the instance is in the RO mode. + cluster['i-001']:exec(function() + t.assert_equals(box.info.ro, true) + end) + + return config, cluster +end + +local function lua_call_priv(funcs) + return { + permissions = {'execute'}, + lua_call = funcs, + } +end + +local function inane_lua_call_priv(funcs) + return { + permissions = {'read'}, + lua_call = funcs, + } +end + +-- }}} Testing helpers + +g.test_direct_lua_call_in_ro_mode = function() + local config, cluster = new_cluster_in_ro() + + -- Add new function privileges. + local config = cbuilder:new(config) + :set_global_option('credentials.roles.test', { + privileges = {lua_call_priv({'bar'})}, + }) + :set_global_option('credentials.roles.deps', { + privileges = {lua_call_priv({'box.info'})}, + roles = {'test'}, + }) + :set_global_option('credentials.users.alice', { + privileges = {lua_call_priv({'foo'})}, + roles = {'deps'}, + password = 'ALICE', + }) + :config() + cluster:reload(config) + + -- Verify that the new privileges are granted. + cluster['i-001']:exec(function() + local conn = _G.get_connection() + + -- Granted by user's privileges. + t.assert(conn:call('foo')) + + -- Granted by a directly assigned role. + t.assert(conn:call('box.info')) + + -- Granted by an indirectly assigned role. + t.assert(conn:call('bar')) + + -- Any other function is forbidden. + local exp_err = _G.access_error_msg('alice', 'baz') + t.assert_error_msg_equals(exp_err, function() + conn:call('baz') + end) + end) +end + +g.test_universe_lua_call_in_ro_mode = function() + local config, cluster = new_cluster_in_ro() + + -- Add new function privileges. + local config = cbuilder:new(config) + :set_global_option('credentials.roles.test', { + privileges = {lua_call_priv({'all', 'box.info'})}, + }) + :set_global_option('credentials.roles.no_exec', { + privileges = {inane_lua_call_priv({'loadstring'})}, + }) + :set_global_option('credentials.users.alice', { + privileges = {lua_call_priv({'box.info'})}, + roles = {'test', 'exec'}, + password = 'ALICE', + }) + :config() + cluster:reload(config) + + cluster['i-001']:exec(function() + local conn = _G.get_connection() + + -- Verify that user `alice` able to call any global lua function. + t.assert(conn:call('foo')) + + -- Verify that user `alice` able to call granted built-in function. + t.assert(conn:call('box.info')) + + -- User `alice` is unable to use the loadstring function because the + -- `no_exec` role hasn't `execute` permissions. + local exp_err = _G.access_error_msg('alice', 'loadstring') + t.assert_error_msg_equals(exp_err, function() + conn:call('loadstring') + end) + end) +end + +g.test_no_user_privileges_lua_call_ro_mode = function() + -- This test case checks the scenario where the user 'alice' has no direct + -- privileges, but inherits them from a role `test` that grants the lua_call + -- privileges. + local config, cluster = new_cluster_in_ro() + + -- Add new function privileges. + local config = cbuilder:new(config) + :set_global_option('credentials.roles.test', { + privileges = {lua_call_priv({'box.info', 'foo'})}, + }) + :set_global_option('credentials.users.alice', { + roles = {'test'}, + password = 'ALICE', + }) + :config() + cluster:reload(config) + + cluster['i-001']:exec(function() + local conn = _G.get_connection() + + -- Verify that user `alice` able to call `foo` function. + t.assert(conn:call('foo')) + + -- Verify that user `alice` able to call `box.info` built-in function. + t.assert(conn:call('box.info')) + + -- Any other function is forbidden. + local exp_err = _G.access_error_msg('alice', 'baz') + t.assert_error_msg_equals(exp_err, function() + conn:call('baz') + end) + end) +end