diff --git a/changelogs/unreleased/gh-8861-config-privilege-sync.md b/changelogs/unreleased/gh-8861-config-privilege-sync.md new file mode 100644 index 0000000000000000000000000000000000000000..a5f4c0165420217b5bc272ecbb6f490783a7c0c5 --- /dev/null +++ b/changelogs/unreleased/gh-8861-config-privilege-sync.md @@ -0,0 +1,4 @@ +## feature/config + +* Improved the credentials applier: now it supports two-way synchronization + of roles and privileges for both users and roles (gh-8861). diff --git a/src/box/lua/config/applier/credentials.lua b/src/box/lua/config/applier/credentials.lua index 424e1b11d50933ed560a7dad12cc021e94868b11..9bbfb84581cbc0e0c3394c7615f59050f343ef38 100644 --- a/src/box/lua/config/applier/credentials.lua +++ b/src/box/lua/config/applier/credentials.lua @@ -1,30 +1,328 @@ local log = require('internal.config.utils.log') -local function grant_privileges(name, privileges, role_or_user, grant_f) - for _, privilege in ipairs(privileges or {}) do - log.verbose('credentials.apply: grant %s to %s %s (if not exists)', - privilege, role_or_user, name) - for _, permission in ipairs(privilege.permissions or {}) do - local opts = {if_not_exists = true} - if privilege.universe then - grant_f(name, permission, 'universe', nil, opts) - end - -- TODO: It is not possible to grant a permission for - -- a non-existing object. It blocks ability to set it - -- from a config. Disabled for now. - --[[ - for _, space in ipairs(privilege.spaces or {}) do - grant_f(name, permission, 'space', space, opts) +--[[ +This intermediate representation is a formatted set of +all permissions for a user or role. It is required to +standardize diff functions. All the validation is done +by config or box.info(), so neither the format nor the +helper function don't perform it. Below you can find two +converters to this representation, from box format and +config schema format, accordingly `privileges_{box,config}_convert()`. + +[obj_type][obj_name] = { + read = true, + write = true, + ... +} + +obj_types: + - 'user' + - 'role' + - 'space' + - 'function' + - 'sequence' + - 'universe' + +obj_names: + - mostly user defined strings, provided by config or box + - special value '', when there is no obj_name, e.g. for + 'universe' obj_type or for granting permission for all + objects of a type. + +privs: + - read + - write + - execute + - lua_eval + - lua_call + - sql + - session + - usage + - create + - drop + - alter + - reference + - trigger + - insert + - update + - delete + +Examples: +- - box.schema.user.grant('myuser', 'execute', 'function', 'myfunc') + - ['function']['myfunc']['execute'] = true + - grant execute of myfunc + +- - box.schema.user.grant('myuser', 'execute', 'function') + - ['function']['']['execute'] = true + - grant execute of all registered functions + +- - box.schema.user.grant('myuser', 'read', 'universe') + - ['universe']['']['read'] = true + - grant read to universe + +- - box.schema.user.grant('myuser', 'execute', 'role', 'super') + - ['role']['super']['execute'] = true + - equivalent to granting a role to myuser + +]]-- + +local function privileges_from_box(privileges) + privileges = privileges or {} + assert(type(privileges) == 'table') + + local res = { + ['user'] = {}, + ['role'] = {}, + ['space'] = {}, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = {}, + } + + for _, priv in ipairs(privileges) do + local perms, obj_type, obj_name = unpack(priv) + obj_name = obj_name or '' + + res[obj_type][obj_name] = res[obj_type][obj_name] or {} + + for _, perm in ipairs(perms:split(',')) do + res[obj_type][obj_name][perm] = true + end + end + + return res +end + +-- Note that 'all' is considered a special value, meaning all objects of +-- obj_type will be granted this permission. Don't use this function if it +-- may occur in any other meaning, e.g. user defined name. +-- +-- Note: `obj_names` can be either an array with objects names or a string +-- with a single one. It could also be `nil`, meaning "do nothing". +local function privileges_add_perm(obj_type, obj_names, perm, intermediate) + if obj_names == nil then + return + end + if type(obj_names) == 'string' then + obj_names = {obj_names} + end + + for _, obj_name in ipairs(obj_names) do + if obj_name == 'all' then + -- '' is a special value, meaning all objects of this obj_type. + obj_name = '' + end + intermediate[obj_type][obj_name] = + intermediate[obj_type][obj_name] or {} + intermediate[obj_type][obj_name][perm] = true + end +end + +local function privileges_from_config(config_data) + local privileges = config_data.privileges or {} + assert(type(privileges) == 'table') + + local intermediate = { + ['user'] = {}, + ['role'] = {}, + ['space'] = {}, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = {}, + } + + for _, priv in ipairs(privileges) do + for _, perm in ipairs(priv.permissions) do + if priv.universe then + privileges_add_perm('universe', 'all', perm, intermediate) end - for _, func in ipairs(privilege.functions or {}) do - grant_f(name, permission, 'function', func, opts) + privileges_add_perm('space', priv.spaces, perm, intermediate) + privileges_add_perm('function', priv.functions, perm, intermediate) + privileges_add_perm('sequence', priv.sequences, perm, intermediate) + end + end + + local roles = config_data.roles or {} + + for _, role_name in ipairs(roles) do + -- Unlike spaces, functions and sequences, role is allowed to be + -- named 'all', so `privileges_add_perm()` isn't used. + intermediate['role'][role_name] = intermediate['role'][role_name] or {} + intermediate['role'][role_name]['execute'] = true + end + + return intermediate +end + +-- Intermediate representation is basically a set, so this function subtracts +-- `current` from `target`. +-- Return privileges that are present in `target` but not in `current` as +-- a list in the following format: +-- - obj_type: my_type +-- obj_name: my_name +-- privs: +-- - read +-- - write +-- - ... +-- +local function privileges_subtract(target, current) + local lacking = {} + + for obj_type, privileges_group in pairs(target) do + for obj_name, privileges in pairs(privileges_group) do + local lacking_privs = {} + for priv, target_val in pairs(privileges) do + if target_val and (current[obj_type][obj_name] == nil or + not current[obj_type][obj_name][priv]) then + table.insert(lacking_privs, priv) + end end - for _, seq in ipairs(privilege.sequences or {}) do - grant_f(name, permission, 'sequence', seq, opts) + if next(lacking_privs) then + table.insert(lacking, { + obj_type = obj_type, + obj_name = obj_name, + privs = lacking_privs, + }) end - ]]-- end end + + return lacking +end + +local function privileges_add_defaults(name, role_or_user, intermediate) + local res = table.deepcopy(intermediate) + + if role_or_user == 'user' then + if name == 'guest' then + privileges_add_perm('role', 'public', 'execute', res) + + privileges_add_perm('universe', '', 'session', res) + privileges_add_perm('universe', '', 'usage', res) + + elseif name == 'admin' then + privileges_add_perm('universe', '', 'read', res) + privileges_add_perm('universe', '', 'write', res) + privileges_add_perm('universe', '', 'execute', res) + privileges_add_perm('universe', '', 'session', res) + privileges_add_perm('universe', '', 'usage', res) + privileges_add_perm('universe', '', 'create', res) + privileges_add_perm('universe', '', 'drop', res) + privileges_add_perm('universe', '', 'alter', res) + privileges_add_perm('universe', '', 'reference', res) + privileges_add_perm('universe', '', 'trigger', res) + privileges_add_perm('universe', '', 'insert', res) + privileges_add_perm('universe', '', 'update', res) + privileges_add_perm('universe', '', 'delete', res) + + else + -- Newly created user: + privileges_add_perm('role', 'public', 'execute', res) + + privileges_add_perm('universe', '', 'session', res) + privileges_add_perm('universe', '', 'usage', res) + + privileges_add_perm('user', name, 'alter', res) + end + + elseif role_or_user == 'role' then + -- luacheck: ignore 542 empty if branch + if name == 'public' then + privileges_add_perm('function', 'box.schema.user.info', 'execute', + res) + privileges_add_perm('function', 'LUA', 'read', res) + + privileges_add_perm('space', '_vcollation', 'read', res) + privileges_add_perm('space', '_vspace', 'read', res) + privileges_add_perm('space', '_vsequence', 'read', res) + privileges_add_perm('space', '_vindex', 'read', res) + privileges_add_perm('space', '_vfunc', 'read', res) + privileges_add_perm('space', '_vuser', 'read', res) + privileges_add_perm('space', '_vpriv', 'read', res) + privileges_add_perm('space', '_vspace_sequence', 'read', res) + + privileges_add_perm('space', '_truncate', 'write', res) + + privileges_add_perm('space', '_session_settings', 'read', res) + privileges_add_perm('space', '_session_settings', 'write', res) + + elseif name == 'replication' then + privileges_add_perm('space', '_cluster', 'write', res) + privileges_add_perm('universe', '', 'read', res) + + elseif name == 'super' then + privileges_add_perm('universe', '', 'read', res) + privileges_add_perm('universe', '', 'write', res) + privileges_add_perm('universe', '', 'execute', res) + privileges_add_perm('universe', '', 'session', res) + privileges_add_perm('universe', '', 'usage', res) + privileges_add_perm('universe', '', 'create', res) + privileges_add_perm('universe', '', 'drop', res) + privileges_add_perm('universe', '', 'alter', res) + privileges_add_perm('universe', '', 'reference', res) + privileges_add_perm('universe', '', 'trigger', res) + privileges_add_perm('universe', '', 'insert', res) + privileges_add_perm('universe', '', 'update', res) + privileges_add_perm('universe', '', 'delete', res) + + else + -- Newly created role has NO permissions. + end + else + assert(false, 'neither role nor user provided') + end + + return res +end + +-- The privileges synchronization between A and B is performed in three steps: +-- 1. Grant all privileges that are present in B, +-- but not present in A (`grant(B - A)`). +-- 2. Add default privileges to B (`B = B + defaults`). +-- 3. Revoke all privileges that are not present in B, +-- but present in A (`revoke(A - B)). +-- +-- Default privileges are not granted on step 1, so they stay revoked if +-- revoked manually (e.g. with `box.schema.{user,role}.revoke()`). +-- However, defaults should never be revoked, so target state B is enriched +-- with them before step 3. +local function sync_privileges(name, config_privileges, role_or_user) + assert(role_or_user == 'user' or role_or_user == 'role') + log.verbose('syncing privileges for %s %q', role_or_user, name) + + local grant_f = function(name, privs, obj_type, obj_name) + privs = table.concat(privs, ',') + log.debug('credentials.apply: ' .. role_or_user .. + '.grant(%q, %q, %q, %q)', name, privs, obj_type, obj_name) + box.schema[role_or_user].grant(name, privs, obj_type, obj_name) + end + local revoke_f = function(name, privs, obj_type, obj_name) + privs = table.concat(privs, ',') + log.debug('credentials.apply: ' .. role_or_user .. + '.revoke(%q, %q, %q, %q)', name, privs, obj_type, obj_name) + box.schema[role_or_user].revoke(name, privs, obj_type, obj_name) + end + + local box_privileges = box.schema[role_or_user].info(name) + + config_privileges = privileges_from_config(config_privileges) + box_privileges = privileges_from_box(box_privileges) + + local grants = privileges_subtract(config_privileges, box_privileges) + + for _, to_grant in ipairs(grants) do + grant_f(name, to_grant.privs, to_grant.obj_type, to_grant.obj_name) + end + + config_privileges = privileges_add_defaults(name, role_or_user, + config_privileges) + + local revokes = privileges_subtract(box_privileges, config_privileges) + + for _, to_revoke in ipairs(revokes) do + revoke_f(name, to_revoke.privs, to_revoke.obj_type, to_revoke.obj_name) + end + end -- {{{ Create roles @@ -37,14 +335,6 @@ local function create_role(role_name) end end -local function assign_roles_to_role(role_name, roles) - for _, role in ipairs(roles or {}) do - log.verbose('credentials.apply: add role %q as underlying for ' .. - 'role %q (if not exists)', role, role_name) - box.schema.role.grant(role_name, role, nil, nil, {if_not_exists = true}) - end -end - -- Create roles, grant them permissions and assign underlying -- roles. local function create_roles(role_map) @@ -52,20 +342,16 @@ local function create_roles(role_map) return end - -- Create roles and grant then permissions. Skip assigning - -- underlying roles till all the roles will be created. for role_name, role_def in pairs(role_map or {}) do if role_def ~= nil then create_role(role_name) - grant_privileges(role_name, role_def.privileges, 'role', - box.schema.role.grant) end end - -- Assign underlying roles. + -- Sync privileges and assign underlying roles. for role_name, role_def in pairs(role_map or {}) do if role_def ~= nil then - assign_roles_to_role(role_name, role_def.roles) + sync_privileges(role_name, role_def, 'role') end end end @@ -117,13 +403,6 @@ local function set_password(user_name, password) end end -local function assing_roles_to_user(user_name, roles) - for _, role in ipairs(roles or {}) do - log.verbose('grant role %q to user %q (if not exists)', role, user_name) - box.schema.user.grant(user_name, role, nil, nil, {if_not_exists = true}) - end -end - -- Create users, set them passwords, assign roles, grant -- permissions. local function create_users(user_map) @@ -135,9 +414,7 @@ local function create_users(user_map) if user_def ~= nil then create_user(user_name) set_password(user_name, user_def.password) - assing_roles_to_user(user_name, user_def.roles) - grant_privileges(user_name, user_def.privileges, 'user', - box.schema.user.grant) + sync_privileges(user_name, user_def, 'user') end end end @@ -177,5 +454,13 @@ end return { name = 'credentials', - apply = apply + apply = apply, + -- Exported for testing purposes. + _internal = { + privileges_from_box = privileges_from_box, + privileges_from_config = privileges_from_config, + privileges_subtract = privileges_subtract, + privileges_add_defaults = privileges_add_defaults, + sync_privileges = sync_privileges, + }, } diff --git a/test/config-luatest/credentials_applier_test.lua b/test/config-luatest/credentials_applier_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..a55d1c06b532c8add6b12189b72e9764387d3ba7 --- /dev/null +++ b/test/config-luatest/credentials_applier_test.lua @@ -0,0 +1,480 @@ +local json = require('json') +local it = require('test.interactive_tarantool') +local t = require('luatest') +local treegen = require('test.treegen') + +local g = t.group() + +local internal = require('internal.config.applier.credentials')._internal + +g.before_all(function(g) + treegen.init(g) +end) + +g.after_all(function(g) + treegen.clean(g) +end) + +g.test_converters = function() + -- Guest privileges in format provided by box.schema.{user,role}.info() + local box_guest_privileges = {{ + 'execute', + 'role', + 'public', + }, { + 'session,usage', + 'universe', + }, + } + + -- Guest privileges in format provided by config schema + local config_guest_data = { + privileges = {{ + permissions = { + 'session', + 'usage' + }, + universe = true, + } + }, + roles = { + 'public' + }, + } + + -- Guest privileges in format of intermediate representation + local intermediate_guest_privileges = { + ['user'] = {}, + ['role'] = { + ['public'] = { + ['execute'] = true + } + }, + ['space'] = {}, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = { + [''] = { + ['session'] = true, + ['usage'] = true, + } + }, + } + + t.assert_equals(internal.privileges_from_box(box_guest_privileges), + intermediate_guest_privileges) + + t.assert_equals(internal.privileges_from_config(config_guest_data), + intermediate_guest_privileges) + + + local box_admin_privileges = {{ + 'read,write,execute,session,usage,create,drop,alter,reference,' .. + 'trigger,insert,update,delete', + 'universe' + }, + } + + local config_admin_data = { + privileges = {{ + permissions = { + 'read', + 'write', + 'execute', + 'session', + 'usage', + 'create', + 'drop', + 'alter', + 'reference', + 'trigger', + 'insert', + 'update', + 'delete', + }, + universe = true, + }, + } + } + + local intermediate_admin_privileges = { + ['user'] = {}, + ['role'] = {}, + ['space'] = {}, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = { + [''] = { + ['read'] = true, + ['write'] = true, + ['execute'] = true, + ['session'] = true, + ['usage'] = true, + ['create'] = true, + ['drop'] = true, + ['alter'] = true, + ['reference'] = true, + ['trigger'] = true, + ['insert'] = true, + ['update'] = true, + ['delete'] = true, + } + }, + } + + t.assert_equals(internal.privileges_from_box(box_admin_privileges), + intermediate_admin_privileges) + + t.assert_equals(internal.privileges_from_config(config_admin_data), + intermediate_admin_privileges) + + local box_replication_privileges = {{ + 'write', + 'space', + '_cluster', + }, { + 'read', + 'universe', + }, + } + + local config_replication_data = { + privileges = {{ + permissions = { + 'write' + }, + spaces = { + '_cluster', + }, + }, { + permissions = { + 'read' + }, + universe = true, + }, + } + } + + local intermediate_replication_privileges = { + ['user'] = {}, + ['role'] = {}, + ['space'] = { + ['_cluster'] = { + ['write'] = true, + }, + }, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = { + [''] = { + ['read'] = true, + }, + }, + } + + t.assert_equals(internal.privileges_from_box(box_replication_privileges), + intermediate_replication_privileges) + + t.assert_equals(internal.privileges_from_config(config_replication_data), + intermediate_replication_privileges) + + + local box_custom_privileges = {{ + 'read,write', + 'space', + }, { + 'read,write', + 'sequence', + 'myseq1', + }, { + 'read,write', + 'sequence', + 'myseq2', + }, { + 'execute', + 'function', + 'myfunc1', + }, { + 'execute', + 'function', + 'myfunc2', + }, { + 'read', + 'universe', + }, { + 'execute', + 'role', + 'myrole1', + }, { + 'execute', + 'role', + 'myrole2', + }, { + 'execute', + 'role', + 'public', + }, + } + + local config_custom_data = { + privileges = {{ + permissions = { + 'read', + 'write', + }, + spaces = { + 'all', + }, + sequences = { + 'myseq1', + 'myseq2', + }, + }, { + permissions = { + 'execute', + }, + functions = { + 'myfunc1', + 'myfunc2', + }, + }, { + permissions = { + 'read', + }, + universe = true, + }, + }, + roles = { + 'myrole1', + 'myrole2', + 'public', + } + } + + local intermediate_custom_privileges = { + ['user'] = {}, + ['role'] = { + ['myrole1'] = { + ['execute'] = true, + }, + ['myrole2'] = { + ['execute'] = true, + }, + ['public'] = { + ['execute'] = true, + }, + }, + ['space'] = { + [''] = { + ['read'] = true, + ['write'] = true, + }, + }, + ['function'] = { + ['myfunc1'] = { + ['execute'] = true, + }, + ['myfunc2'] = { + ['execute'] = true, + }, + }, + ['sequence'] = { + ['myseq1'] = { + ['read'] = true, + ['write'] = true, + }, + ['myseq2'] = { + ['read'] = true, + ['write'] = true, + }, + }, + ['universe'] = { + [''] = { + ['read'] = true, + }, + }, + } + + t.assert_equals(internal.privileges_from_box(box_custom_privileges), + intermediate_custom_privileges) + + t.assert_equals(internal.privileges_from_config(config_custom_data), + intermediate_custom_privileges) +end + +g.test_privileges_subtract = function() + local target = { + ['user'] = {}, + ['role'] = {}, + ['space'] = { + ['myspace1'] = { + ['read'] = true, + ['write'] = true, + }, + ['myspace2'] = { + ['read'] = true, + } + }, + ['function'] = { + ['myfunc1'] = { + ['execute'] = true, + }, + }, + ['sequence'] = { + ['myseq1'] = { + ['read'] = true, + ['write'] = true, + } + }, + ['universe'] = {}, + } + + local current = { + ['user'] = {}, + ['role'] = { + ['myrole1'] = { + ['execute'] = true, + }, + }, + ['space'] = { + ['myspace1'] = { + ['read'] = true, + }, + }, + ['function'] = { + ['myfunc1'] = { + ['execute'] = true, + }, + }, + ['sequence'] = {}, + ['universe'] = {}, + } + + local lack = {{ + obj_type = 'space', + obj_name = 'myspace1', + privs = {'write'}, + }, { + obj_type = 'space', + obj_name = 'myspace2', + privs = {'read'}, + }, { + obj_type = 'sequence', + obj_name = 'myseq1', + privs = {'read', 'write'}, + }, + } + + t.assert_items_equals(internal.privileges_subtract(target, current), lack) +end + +g.test_privileges_add_defaults = function(g) + local cases = { + {'user', 'guest'}, + {'user', 'admin'}, + {'user', '<newly_created>'}, + {'role', 'public'}, + {'role', 'replication'}, + {'role', 'super'}, + {'role', '<newly_created>'}, + } + + for _, case in ipairs(cases) do + local role_or_user, name = unpack(case) + + local child = it.new() + local dir = treegen.prepare_directory(g, {}, {}) + + child:execute_command(("box.cfg{work_dir = %q}"):format(dir)) + child:read_response() + if name == '<newly_created>' then + name = 'somerandomname' + child:execute_command(("box.schema.%s.create('%s')"):format( + role_or_user, name)) + child:read_response() + end + child:execute_command(("box.schema.%s.info('%s')"):format(role_or_user, + name)) + local box_privileges = child:read_response() + box_privileges = internal.privileges_from_box(box_privileges) + + local defaults = { + ['user'] = {}, + ['role'] = {}, + ['space'] = {}, + ['function'] = {}, + ['sequence'] = {}, + ['universe'] = {}, + } + defaults = internal.privileges_add_defaults(name, role_or_user, + defaults) + + t.assert_equals(defaults, box_privileges) + + child:close() + end +end + +g.test_sync_privileges = function(g) + local box_configuration = {{ + "grant", "read", "universe", "" + }, { + "revoke", "session,usage", "universe", "" + }, { + "grant", "execute", "functions", "" + }, + } + + local config_privileges = { + privileges = {{ + permissions = { + 'write', + 'execute', + }, + universe = true, + }, { + permissions = { + 'session', + 'usage', + }, + universe = true, + } + }, + roles = { + 'public' + }, + } + + local child = it.new() + local dir = treegen.prepare_directory(g, {}, {}) + child:roundtrip(("box.cfg{work_dir = %q}"):format(dir)) + + local name = "myuser" + child:roundtrip(("box.schema.user.create(%q)"):format(name)) + for _, command in ipairs(box_configuration) do + local action, perm, obj_type, obj_name = unpack(command) + local opts = "{if_not_exists = true}" + child:roundtrip(("box.schema.user.%s(%q, %q, %q, %q, %s)"):format( + action, name, perm, obj_type, obj_name, opts)) + end + child:roundtrip("sync_privileges = require('internal.config.applier." .. + "credentials')._internal.sync_privileges") + child:roundtrip("json = require('json')") + child:roundtrip(("config_privileges = json.decode(%q)"):format( + json.encode(config_privileges))) + child:roundtrip(("sync_privileges(%q, config_privileges, 'user')") + :format(name)) + + child:execute_command(("box.schema.user.info('%s')"):format(name)) + local result_privileges = child:read_response() + + result_privileges = internal.privileges_from_box(result_privileges) + config_privileges = internal.privileges_from_config(config_privileges) + + config_privileges = internal.privileges_add_defaults(name, "user", + config_privileges) + + t.assert_equals(result_privileges, config_privileges) + + child:close() +end