diff --git a/changelogs/unreleased/gh-8803-exec-priv.md b/changelogs/unreleased/gh-8803-exec-priv.md
new file mode 100644
index 0000000000000000000000000000000000000000..30a4a7370844542c94de89b92ad8528b2cd34191
--- /dev/null
+++ b/changelogs/unreleased/gh-8803-exec-priv.md
@@ -0,0 +1,8 @@
+## feature/box
+
+* Introduced the new `lua_eval` and `lua_call` object types for
+  `box.schema.user.grant`. Granting the `'execute'` privilege on `lua_eval`
+  allows the user to execute an arbitrary Lua expression with the
+  `IPROTO_EVAL` request. Granting the `'execute'` privilege on `lua_call`
+  allows the user to execute any global user-defined Lua function with
+  the `IPROTO_CALL` request (gh-8803).
diff --git a/src/box/call.c b/src/box/call.c
index 65312e2406c21cf20dbe29daa8cc457dfa595300..ed1e85903f8ebe7579e505d2a07d3f4360a2e369 100644
--- a/src/box/call.c
+++ b/src/box/call.c
@@ -31,6 +31,7 @@
 
 #include "box/call.h"
 #include "lua/call.h"
+#include "../lua/init.h" /* tarantool_lua_is_builtin_global */
 #include "schema.h"
 #include "session.h"
 #include "func.h"
@@ -151,6 +152,45 @@ box_run_on_call(enum iproto_type type, const char *expr, int expr_len,
 	trigger_run(&box_on_call, &ctx);
 }
 
+/** Checks if the current user may execute any global Lua function. */
+static int
+access_check_call(const char *name, uint32_t name_len)
+{
+	struct credentials *cr = effective_user();
+	user_access_t access = PRIV_X | PRIV_U;
+	access &= ~cr->universal_access;
+	if (access == 0)
+		return 0;
+	access &= ~universe.access_lua_call[cr->auth_token].effective;
+	if (access == 0 && !tarantool_lua_is_builtin_global(name, name_len))
+		return 0;
+	struct user *user = user_find(cr->uid);
+	if (user != NULL)
+		diag_set(AccessDeniedError, priv_name(PRIV_X),
+			 schema_object_name(SC_FUNCTION),
+			 tt_cstr(name, name_len), user->def->name);
+	return -1;
+}
+
+/** Checks if the current user may execute an arbitrary Lua expression. */
+static int
+access_check_eval(void)
+{
+	struct credentials *cr = effective_user();
+	user_access_t access = PRIV_X | PRIV_U;
+	access &= ~cr->universal_access;
+	if (access == 0)
+		return 0;
+	access &= ~universe.access_lua_eval[cr->auth_token].effective;
+	if (access == 0)
+		return 0;
+	struct user *user = user_find(cr->uid);
+	if (user != NULL)
+		diag_set(AccessDeniedError, priv_name(PRIV_X),
+			 schema_object_name(SC_UNIVERSE), "", user->def->name);
+	return -1;
+}
+
 int
 box_process_call(struct call_request *request, struct port *port)
 {
@@ -172,9 +212,7 @@ box_process_call(struct call_request *request, struct port *port)
 		if (func_call_no_access_check(func, &args, port) != 0)
 			return -1;
 	} else {
-		if (access_check_universe_object(PRIV_X | PRIV_U,
-						 SC_FUNCTION,
-						 tt_cstr(name, name_len)) != 0)
+		if (access_check_call(name, name_len) != 0)
 			return -1;
 		box_run_on_call(IPROTO_CALL, name, name_len, request->args);
 		if (box_lua_call(name, name_len, &args, port) != 0)
@@ -188,7 +226,7 @@ box_process_eval(struct call_request *request, struct port *port)
 {
 	rmean_collect(rmean_box, IPROTO_EVAL, 1);
 	/* Check permissions */
-	if (access_check_universe(PRIV_X) != 0)
+	if (access_check_eval() != 0)
 		return -1;
 	struct port args;
 	port_msgpack_create(&args, request->args,
diff --git a/src/box/lua/schema.lua b/src/box/lua/schema.lua
index 8950935fbef680a3dc64c2302f1c5106aaef088f..7d99f15f678bc0a88ebead9282672e9c6d28d3e2 100644
--- a/src/box/lua/schema.lua
+++ b/src/box/lua/schema.lua
@@ -2873,6 +2873,8 @@ end
 -- allowed combination of privilege bits for object
 local priv_object_combo = {
     ["universe"] = box.priv.ALL,
+    ["lua_call"] = bit.bor(box.priv.X, box.priv.U),
+    ["lua_eval"] = bit.bor(box.priv.X, box.priv.U),
 -- sic: we allow to grant 'execute' on space. This is a legacy
 -- bug, please fix it in 2.0
     ["space"]    = bit.bxor(box.priv.ALL, box.priv.S,
@@ -2946,12 +2948,23 @@ local function privilege_name(privilege)
     return table.concat(names, ",")
 end
 
+-- Set of object types that have a single global instance.
+local singleton_object_types = {
+    ['universe'] = true,
+    ['lua_call'] = true,
+    ['lua_eval'] = true,
+}
+
+local function is_singleton_object_type(object_type)
+    return singleton_object_types[object_type]
+end
+
 local function object_resolve(object_type, object_name)
     if object_name ~= nil and type(object_name) ~= 'string'
             and type(object_name) ~= 'number' then
         box.error(box.error.ILLEGAL_PARAMS, "wrong object name type")
     end
-    if object_type == 'universe' then
+    if is_singleton_object_type(object_type) then
         return 0
     end
     if object_type == 'space' then
@@ -3015,7 +3028,7 @@ local function object_resolve(object_type, object_name)
 end
 
 local function object_name(object_type, object_id)
-    if object_type == 'universe' or object_id == '' then
+    if is_singleton_object_type(object_type) or object_id == '' then
         return ""
     end
     local space
@@ -3350,7 +3363,7 @@ local function grant(uid, name, privilege, object_type,
                privilege == 'execute' then
                 box.error(box.error.ROLE_GRANTED, name, object_name)
             else
-                if object_type ~= 'universe' then
+                if not is_singleton_object_type(object_type) then
                     object_name = string.format(" '%s'", object_name)
                 end
                 box.error(box.error.PRIV_GRANTED, name, privilege,
diff --git a/src/box/schema_def.c b/src/box/schema_def.c
index cbfad479ac1c22767e14952cb9e869e86199ca4e..b635422076dd2d6d40d14bd9e4c60d4454849940 100644
--- a/src/box/schema_def.c
+++ b/src/box/schema_def.c
@@ -38,6 +38,8 @@ const char *sql_storage_engine_strs[] = {
 static const char *object_type_strs[] = {
 	/* [SC_UKNNOWN]         = */ "unknown",
 	/* [SC_UNIVERSE]        = */ "universe",
+	/* [SC_LUA_CALL]        = */ "lua_call",
+	/* [SC_LUA_EVAL]        = */ "lua_eval",
 	/* [SC_SPACE]           = */ "space",
 	/* [SC_FUNCTION]        = */ "function",
 	/* [SC_USER]            = */ "user",
diff --git a/src/box/schema_def.h b/src/box/schema_def.h
index 60d2340015f3e4c6f93242492a51bb34403924b0..34d8571223b25c071ad262aa46c0ec479741fe31 100644
--- a/src/box/schema_def.h
+++ b/src/box/schema_def.h
@@ -303,6 +303,8 @@ enum {
 enum schema_object_type {
 	SC_UNKNOWN = 0,
 	SC_UNIVERSE,
+	SC_LUA_CALL,
+	SC_LUA_EVAL,
 	SC_SPACE,
 	SC_FUNCTION,
 	SC_USER,
diff --git a/src/box/session.c b/src/box/session.c
index b9bf8e3ee00e9e691f90928ff709b201abf88833..fb1be868056faad06b80660381ed8c9a4e9e94aa 100644
--- a/src/box/session.c
+++ b/src/box/session.c
@@ -455,9 +455,7 @@ access_check_session(struct user *user)
 }
 
 int
-access_check_universe_object(user_access_t access,
-			     enum schema_object_type object_type,
-			     const char *object_name)
+access_check_universe(user_access_t access)
 {
 	struct credentials *credentials = effective_user();
 	access |= PRIV_U;
@@ -473,7 +471,7 @@ access_check_universe_object(user_access_t access,
 		if (user != NULL) {
 			diag_set(AccessDeniedError,
 				 priv_name(denied_access),
-				 schema_object_name(object_type), object_name,
+				 schema_object_name(SC_UNIVERSE), "",
 				 user->def->name);
 		} else {
 			/*
@@ -488,12 +486,6 @@ access_check_universe_object(user_access_t access,
 	return 0;
 }
 
-int
-access_check_universe(user_access_t access)
-{
-	return access_check_universe_object(access, SC_UNIVERSE, "");
-}
-
 /**
  * If set, raise an error on any attempt to use box.session.push.
  */
diff --git a/src/box/session.h b/src/box/session.h
index 6e02e62afc32d9e52bf9187501ae5672ef7a85b6..a0575b850afd76083f741e1b822dd5dda4c124ba 100644
--- a/src/box/session.h
+++ b/src/box/session.h
@@ -393,16 +393,6 @@ access_check_session(struct user *user);
 int
 access_check_universe(user_access_t access);
 
-/**
- * Same as access_check_universe(), but in case the current user
- * doesn't have universal access, set AccessDeniedError for the
- * given object type and name.
- */
-int
-access_check_universe_object(user_access_t access,
-			     enum schema_object_type object_type,
-			     const char *object_name);
-
 /**
  * This function is called by public API wrappers around session push.
  * It logs a deprecation warning. If session push is disabled, it also
diff --git a/src/box/user.cc b/src/box/user.cc
index 5510f902171e9ab1fbbcd6f1f1abbf117672da6c..7231481cd7e71a9263070d67d6d9dfa22e6b451d 100644
--- a/src/box/user.cc
+++ b/src/box/user.cc
@@ -216,6 +216,10 @@ access_find(const struct priv_def *priv)
 	switch (priv->object_type) {
 	case SC_UNIVERSE:
 		return universe.access;
+	case SC_LUA_CALL:
+		return universe.access_lua_call;
+	case SC_LUA_EVAL:
+		return universe.access_lua_eval;
 	case SC_SPACE:
 	{
 		if (priv->is_entity_access)
diff --git a/src/box/user.h b/src/box/user.h
index efbc39b92876edbfe24a08365810e5b9ff72b8b6..5a24cf79ee47b21d7863e32a20a70fe684463da9 100644
--- a/src/box/user.h
+++ b/src/box/user.h
@@ -43,6 +43,10 @@ extern "C" {
 struct universe {
 	/** Global privileges this user has on the universe. */
 	struct access access[BOX_USER_MAX];
+	/** Privileges to execute any global Lua function with IPROTO_CALL. */
+	struct access access_lua_call[BOX_USER_MAX];
+	/** Privileges to execute any Lua expression with IPROTO_EVAL. */
+	struct access access_lua_eval[BOX_USER_MAX];
 };
 
 /** A single instance of the universe. */
diff --git a/src/lua/init.c b/src/lua/init.c
index 1d741b90a92b371b874e24f1fedad89f645d4c1f..186b81df74d54455063b4ec0140f57dea092fc3e 100644
--- a/src/lua/init.c
+++ b/src/lua/init.c
@@ -45,6 +45,7 @@
 
 #include <fiber.h>
 #include "version.h"
+#include "assoc.h"
 #include "coio.h"
 #include "core/backtrace.h"
 #include "core/tt_static.h"
@@ -445,6 +446,62 @@ static const char *lua_modules_preload[] = {
 	NULL
 };
 
+/**
+ * Names of all global built-in objects. Note that this is a set, not a map,
+ * i.e. only keys are used while values are NULL.
+ *
+ * We consider a global key (from _G) to be built-in if it exists after
+ * initializing all Tarantool modules but before executing the user script.
+ */
+static struct mh_strnptr_t *builtin_globals = NULL;
+
+static void
+builtin_globals_init(struct lua_State *L)
+{
+	struct mh_strnptr_t *h = mh_strnptr_new();
+	lua_pushnil(L);
+	while (lua_next(L, LUA_GLOBALSINDEX) != 0) {
+		lua_pop(L, 1); /* pop the value, leave the key */
+		if (lua_type(L, -1) == LUA_TSTRING) {
+			size_t len;
+			const char *s = xstrdup(lua_tolstring(L, -1, &len));
+			uint32_t hash = mh_strn_hash(s, len);
+			struct mh_strnptr_node_t n = {s, len, hash, NULL};
+			struct mh_strnptr_node_t prev;
+			struct mh_strnptr_node_t *prev_ptr = &prev;
+			mh_strnptr_put(h, &n, &prev_ptr, NULL);
+			assert(prev_ptr == NULL);
+		}
+	}
+	builtin_globals = h;
+}
+
+static void
+builtin_globals_free(void)
+{
+	struct mh_strnptr_t *h = builtin_globals;
+	if (h != NULL) {
+		builtin_globals = NULL;
+		mh_int_t i;
+		mh_foreach(h, i)
+			free((void *)mh_strnptr_node(h, i)->str);
+		mh_strnptr_delete(h);
+	}
+}
+
+bool
+tarantool_lua_is_builtin_global(const char *name, uint32_t name_len)
+{
+	/* Extract the top-level namespace prefix. */
+	uint32_t len = 0;
+	for (const char *s = name; len < name_len; s++, len++) {
+		if (*s == ' ' || *s == '.' || *s == ':' || *s == '[')
+			break;
+	}
+	struct mh_strnptr_t *h = builtin_globals;
+	return h != NULL && mh_strnptr_find_str(h, name, len) != mh_end(h);
+}
+
 /*
  * {{{ box Lua library: common functions
  */
@@ -970,6 +1027,7 @@ tarantool_lua_init(const char *tarantool_bin, const char *script, int argc,
 int
 tarantool_lua_postinit(struct lua_State *L)
 {
+	builtin_globals_init(L);
 	/*
 	 * loaders.initializing = nil
 	 *
@@ -1282,6 +1340,7 @@ tarantool_lua_run_script(char *path, struct instance_state *instance,
 void
 tarantool_lua_free()
 {
+	builtin_globals_free();
 	builtin_modcache_free();
 	tarantool_lua_utf8_free();
 	/*
diff --git a/src/lua/init.h b/src/lua/init.h
index 28553c732f2c35f3ecaaae66e025fb0e98550ff5..9fdda1c0afe95b14bcd44dba85dc2ad72e010cbd 100644
--- a/src/lua/init.h
+++ b/src/lua/init.h
@@ -57,6 +57,10 @@ struct instance_state {
 #define O_EXECUTE     0x8
 #define O_HELP_ENV_LIST 0x10
 
+/** Returns true if the name refers to a built-in global Lua object. */
+bool
+tarantool_lua_is_builtin_global(const char *name, uint32_t name_len);
+
 /**
  * Create tarantool_L and initialize built-in Lua modules.
  */
diff --git a/test/box-luatest/gh_8803_exec_priv_test.lua b/test/box-luatest/gh_8803_exec_priv_test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..3103a6c677aab281d4b89a49edbb42ad7a10b680
--- /dev/null
+++ b/test/box-luatest/gh_8803_exec_priv_test.lua
@@ -0,0 +1,172 @@
+local net = require('net.box')
+local server = require('luatest.server')
+local t = require('luatest')
+
+local g_common = t.group('gh_8803_exec_priv.common', t.helpers.matrix({
+    obj_type = {'lua_call', 'lua_eval'},
+}))
+
+g_common.before_all(function(cg)
+    cg.server = server:new()
+    cg.server:start()
+end)
+
+g_common.after_all(function(cg)
+    cg.server:drop()
+end)
+
+g_common.before_each(function(cg)
+    cg.server:exec(function()
+        box.session.su('admin', box.schema.user.create, 'test')
+    end)
+end)
+
+g_common.after_each(function(cg)
+    cg.server:exec(function()
+        box.session.su('admin', box.schema.user.drop, 'test')
+    end)
+end)
+
+-- Checks the error raised on grant of unsupported privileges.
+g_common.test_unsupported_privs = function(cg)
+    cg.server:exec(function(obj_type)
+        local unsupported_privs = {
+            'read', 'write', 'session', 'create', 'drop', 'alter',
+            'reference', 'trigger', 'insert', 'udpate', 'delete',
+        }
+        for _, priv in ipairs(unsupported_privs) do
+            t.assert_error_msg_equals(
+                string.format("Unsupported %s privilege '%s'", obj_type, priv),
+                box.schema.user.grant, 'test', priv, obj_type)
+        end
+    end, {cg.params.obj_type})
+end
+
+-- Checks that global execute access may be granted only by admin.
+g_common.test_grant = function(cg)
+    cg.server:exec(function(obj_type)
+        box.session.su('admin', box.schema.user.grant, 'test', 'super')
+        t.assert_error_msg_equals(
+            string.format("Grant access to %s '' is denied for user 'test'",
+                          obj_type),
+            box.session.su, 'test', box.schema.user.grant, 'test', 'execute',
+            obj_type)
+    end, {cg.params.obj_type})
+end
+
+local g = t.group('gh_8803_exec_priv')
+
+g.before_all(function(cg)
+    cg.server = server:new()
+    cg.server:start()
+    cg.grant = function(...)
+        cg.server:exec(function(...)
+            box.session.su('admin', box.schema.user.grant, ...)
+        end, {...})
+    end
+    cg.revoke = function(...)
+        cg.server:exec(function(...)
+            box.session.su('admin', box.schema.user.revoke, ...)
+        end, {...})
+    end
+    cg.server:exec(function()
+        rawset(_G, 'lua_func', function() return true end)
+    end)
+end)
+
+g.after_all(function(cg)
+    cg.server:drop()
+end)
+
+g.before_each(function(cg)
+    cg.server:exec(function()
+        box.session.su('admin', box.schema.user.create, 'test',
+                       {password = 'secret'})
+    end)
+    cg.conn = net.connect(cg.server.net_box_uri, {
+        user = 'test', password = 'secret',
+    })
+end)
+
+g.after_each(function(cg)
+    cg.conn:close()
+    cg.server:exec(function()
+        box.session.su('admin', box.schema.user.drop, 'test')
+    end)
+end)
+
+-- Checks that execute privilege granted on lua_eval grants access to
+-- evaluating an arbitrary Lua expression.
+g.test_lua_eval = function(cg)
+    local c = cg.conn
+    local expr = 'return true'
+    local errmsg = "Execute access to universe '' is denied for user 'test'"
+    t.assert_error_msg_equals(errmsg, c.eval, c, expr)
+    cg.grant('test', 'execute', 'lua_eval')
+    t.assert(pcall(c.eval, c, expr))
+    cg.revoke('test', 'usage', 'universe')
+    t.assert_error_msg_equals(errmsg, c.eval, c, expr)
+    cg.grant('test', 'usage', 'lua_eval')
+    t.assert(pcall(c.eval, c, expr))
+end
+
+-- Checks that execute privilege granted on lua_call grants access to
+-- any global user-defined Lua function.
+g.test_lua_call = function(cg)
+    local c = cg.conn
+    local errmsg = "Execute access to function 'lua_func' is denied " ..
+                   "for user 'test'"
+    t.assert_error_msg_equals(errmsg, c.call, c, 'lua_func')
+    cg.grant('test', 'execute', 'lua_call')
+    t.assert(pcall(c.call, c, 'lua_func'))
+    cg.revoke('test', 'usage', 'universe')
+    t.assert_error_msg_equals(errmsg, c.call, c, 'lua_func')
+    cg.grant('test', 'usage', 'lua_call')
+    t.assert(pcall(c.call, c, 'lua_func'))
+end
+
+g.before_test('test_lua_call_func', function(cg)
+    cg.server:exec(function()
+        box.schema.func.create('c_func', {language = 'C'})
+        box.schema.func.create('lua_func', {language = 'LUA'})
+        box.schema.func.create('stored_lua_func', {
+            language = 'LUA',
+            body = [[function() return true end]],
+        })
+    end)
+end)
+
+g.after_test('test_lua_call_func', function(cg)
+    cg.server:exec(function()
+        box.schema.func.drop('c_func')
+        box.schema.func.drop('lua_func')
+        box.schema.func.drop('stored_lua_func')
+    end)
+end)
+
+-- Checks that execute privilege granted on lua_call does not grant access to
+-- Lua functions from _func.
+g.test_lua_call_func = function(cg)
+    local c = cg.conn
+    local errfmt = "Execute access to function '%s' is denied for user 'test'"
+    local func_list = {'c_func', 'lua_func', 'stored_lua_func'}
+    cg.grant('test', 'execute', 'lua_call')
+    for _, func in ipairs(func_list) do
+        t.assert_error_msg_equals(errfmt:format(func), c.call, c, func)
+    end
+end
+
+-- Checks that execute privilege granted on lua_call does not grant access
+-- to built-in Lua functions.
+g.test_lua_call_builtin = function(cg)
+    local c = cg.conn
+    local errfmt = "Execute access to function '%s' is denied for user 'test'"
+    local func_list = {
+        'load', 'loadstring', 'loadfile', 'rawset', 'pcall',
+        'box.cfg', 'box["cfg"]', 'box.session.su', 'box:error',
+    }
+    cg.grant('test', 'execute', 'lua_call')
+    for _, func in ipairs(func_list) do
+        t.assert_error_msg_equals(errfmt:format(func), c.call, c, func)
+    end
+end