From d03c9972396766d72276391196c5cc28d1481ec5 Mon Sep 17 00:00:00 2001
From: Gleb Kashkin <g.kashkin@tarantool.org>
Date: Wed, 9 Aug 2023 09:12:38 +0000
Subject: [PATCH] config: rework credentials to support priv sync

Before this patch, credentials applier used to just grant all privileges
and permissions with {if_not_exists = true}. It didn't allow removing a
permission, nor setting only new permissions.

Now credentials applier converts box configuration and desired config to
an intermediate representation, calculates diff for them and only after
that applies the diff.

Part of #8967

NO_DOC=yet
---
 .../gh-8861-config-privilege-sync.md          |   4 +
 src/box/lua/config/applier/credentials.lua    | 375 ++++++++++++--
 .../credentials_applier_test.lua              | 480 ++++++++++++++++++
 3 files changed, 814 insertions(+), 45 deletions(-)
 create mode 100644 changelogs/unreleased/gh-8861-config-privilege-sync.md
 create mode 100644 test/config-luatest/credentials_applier_test.lua

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 0000000000..a5f4c01654
--- /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 424e1b11d5..9bbfb84581 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 0000000000..a55d1c06b5
--- /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
-- 
GitLab