Repository: redis/redis

Problem statement:
Lookup subcommand more strictly and consistently
After #9504 we treat subcommands as commands, it's a good step, but it also introduced ambiguity, i.e. `get|key` is a bad command or a base command `get`? Maybe you will say it's a base command because `get` doesn't have subcommands, but it's very confused to users.

Here are some examples:
1. About `COMMAND INFO`
    * `command info config|get` returns subcommand `config|get`'s info.
    * `command info get|key` returns base command `get`'s info. 
2. About `ACL setuser`
    * `acl setuser test +config|get` allows user execute subcommand `config|get`.
    * `acl setuser test +config|get|dir` allows user execute subcommand `config|get` with first arg `dir`.
    * `acl setuser test +config|xxx` returns error.
    * `acl setuser test +get|key` allows user execute base command `get` with first arg `key`.
    * `acl setuser test +get|key1|key2` allows user execute base command `get` with the **second** arg `key2`, `key1` lost.

To me the result is very confused, I thinks we should treat `get|key` as a bad command, and use another character instead of `|` to indicate the first arg.

BTW the first-arg rule is very difficult to understand IMHO, it's too flexible.


Hints:
as i see it the bugs are:
1. command info get|key returns base command get's info.
2. acl setuser test +get|key1|key2 allows user execute base command get with the second arg key2, key1 lost.

the rest are by design
beside these bugs, I still wanna revert the `first-arg` feature, the first-arg has different meanings in different commands:
1. for `config|get` it's a configuration's name, but now we support multi-param config in #9748, that may let user ignore the ACL rule, for example:
    * `acl setuser test on +config|get|dir`, but `test` user can get requirepass by `config get dir requirepass`.
2. for `mget` it's a key name, but like `config|get`, it also can ignore ACL rule to get more key-values:
    * `acl setuser test on +mget|key1`, user can get other keys by `mget key1 key2 key3`.
3. for `migrate` command, the first arg is `host`, maybe you mean just allow user transfer a key to a special host? I think it's not your purpose.
4. for `eval`, it's a script, the first-arg is nonsense here I think.
i don't like this `first-arg` feature, and i agree that it lost some of its "capabilities" when variadic CONFIG SET and CONFIG GET were added. but people still use it for other purposes (including SELECT), so removing it is a breaking change.

maybe redis 7.0 is our opportunity to remove it (when we add sub-commands that handle part of it in a better way).

alternatively, if we don't wanna completely remove it, we can support it only on commands with no sub-commands.
if we do that in 7.0, it means we didn't add the feature of matching argv[2] on commands with sub-commands (like `config|set|dir`), and then it'l at least be easier to remove the first-arg completely later (won't break the new patters that the current code in unstable adds).

@redis/core-team PTAL and comment.
>i don't like this first-arg feature, and i agree that it lost some of its "capabilities" when variadic CONFIG SET and CONFIG GET were added. but people still use it for other purposes (including SELECT), so removing it is a breaking change.

we didn't release it yet (6.0/6.2 doesn't have this feature, it's just in unstable branch), so I don't think it's a breaking change.
the `CONFIG|SET` and `SELECT|0` feature exists since 6.0, it was named sub_commands, and now it's named first_arg, but it's the same feature.
the new mechanism of sub-commands covers part of the use case for first-arg (it's intended use case), but not all (ab)use cases.
if we remove it, we would break the `SELECT|0` and possibly others (even the ones using DEBUG, which we didn't make a container command).

so blocking it on sub-commands only, will mean we don't introduce a breaking change, and just avoid enabling new capabilities.
removing it completely would be a breaking change, but since the sub-commands cover part of the past use cases, the break is maybe relatively small.
oh, I see.. the sub command in 6.0 is actually implicitly include first-arg, what a ...

but it's not a good (right) use case I think, all document said it's subcommand not first-arg.
yes.. but we saw people use it.. here's one example: #8099.
DEBUG command is another example.. (not currently a sub-command).
maybe there are other non-container commands on which people use it.
>alternatively, if we don't wanna completely remove it, we can support it only on commands with no sub-commands.
if we do that in 7.0, it means we didn't add the feature of matching argv[2] on commands with sub-commands (like config|set|dir), and then it'l at least be easier to remove the first-arg completely later (won't break the new patters that the current code in unstable adds).

i think this is the best compromise 
TBH i would really like to get rid of first-arg altogether but I feel like it's gonna break many existing use-cases... if I'm wrong and we think the damage would be minimal, let's get rid of it
IMHO, `select|0` it's an abuse case, we should get rid of it. And we can support database level ACL in another right way, it's a good requirement. Does ACL v2 support database control? Sorry I didn't look deep into #9974
decisions from discussion with @yossigo and @oranagra :
1. we will keep supporting first-arg for now, but not for subcommand (we feel removing this support will cause too much damage to existing users)
2. we will add a log message whenever someone is setting first-arg, warning about upcoming deprecation 
i wanna stress that unlike the `random` flag, for which we don't know anyone who's using it and if there's any reason for using it (which is why i think we can afford to break it), here, we do know of use cases for it (SELECT and DEBUG), and we do know of people who rely on it, so although there's a bigger potential for tech-debt cleanup, i think it'll be wrong to break it.

also wanna mention that in the past we concluded that we don't wanna invest in database level ACL, we don't wanna promote the use of multiple databases in redis, it increases complexity and already unsupported in various configurations.
it's OK to keep supporting first-arg for base commands.

>also wanna mention that in the past we concluded that we don't wanna invest in database level ACL, we don't wanna promote the use of multiple databases in redis, it increases complexity and already unsupported in various configurations.

I don't think so, in our platform many users are using multiple databases (we even have to support it in cluster mode) and they also want database level ACL, we can discuss after 7.0.

Patch:
diff --git a/src/acl.c b/src/acl.c
index d4fa2c6c8e1..24a9e35b41e 100644
--- a/src/acl.c
+++ b/src/acl.c
@@ -1110,13 +1110,20 @@ int ACLSetSelector(aclSelector *selector, const char* op, size_t oplen) {
             struct redisCommand *cmd = ACLLookupCommand(copy);
 
             /* Check if the command exists. We can't check the
-             * subcommand to see if it is valid. */
+             * first-arg to see if it is valid. */
             if (cmd == NULL) {
                 zfree(copy);
                 errno = ENOENT;
                 return C_ERR;
             }
 
+            /* We do not support allowing first-arg of a subcommand */
+            if (cmd->parent) {
+                zfree(copy);
+                errno = ECHILD;
+                return C_ERR;
+            }
+
             /* The subcommand cannot be empty, so things like DEBUG|
              * are syntax errors of course. */
             if (strlen(sub) == 0) {
@@ -1127,7 +1134,7 @@ int ACLSetSelector(aclSelector *selector, const char* op, size_t oplen) {
 
             if (cmd->subcommands_dict) {
                 /* If user is trying to allow a valid subcommand we can just add its unique ID */
-                struct redisCommand *cmd = ACLLookupCommand(op+1);
+                cmd = ACLLookupCommand(op+1);
                 if (cmd == NULL) {
                     zfree(copy);
                     errno = ENOENT;
@@ -1138,13 +1145,11 @@ int ACLSetSelector(aclSelector *selector, const char* op, size_t oplen) {
                 /* If user is trying to use the ACL mech to block SELECT except SELECT 0 or
                  * block DEBUG except DEBUG OBJECT (DEBUG subcommands are not considered
                  * subcommands for now) we use the allowed_firstargs mechanism. */
-                struct redisCommand *cmd = ACLLookupCommand(copy);
-                if (cmd == NULL) {
-                    zfree(copy);
-                    errno = ENOENT;
-                    return C_ERR;
-                }
+
                 /* Add the first-arg to the list of valid ones. */
+                serverLog(LL_WARNING, "Deprecation warning: Allowing a first arg of an otherwise "
+                                      "blocked command is a misuse of ACL and may get disabled "
+                                      "in the future (offender: +%s)", op+1);
                 ACLAddAllowedFirstArg(selector,cmd->id,sub);
             }
 
@@ -1236,6 +1241,7 @@ int ACLSetSelector(aclSelector *selector, const char* op, size_t oplen) {
  *         almost surely an error on the user side.
  * ENODEV: The password you are trying to remove from the user does not exist.
  * EBADMSG: The hash you are trying to add is not a valid hash.
+ * ECHILD: Attempt to allow a specific first argument of a subcommand
  */
 int ACLSetUser(user *u, const char *op, ssize_t oplen) {
     if (oplen == -1) oplen = strlen(op);
@@ -1360,6 +1366,8 @@ const char *ACLSetUserStringError(void) {
     else if (errno == EALREADY)
         errmsg = "Duplicate user found. A user can only be defined once in "
                  "config files";
+    else if (errno == ECHILD)
+        errmsg = "Allowing first-arg of a subcommand is not supported";
     return errmsg;
 }
 
diff --git a/src/server.c b/src/server.c
index 5de6d61c5e7..50fc7a9574f 100644
--- a/src/server.c
+++ b/src/server.c
@@ -2812,20 +2812,32 @@ int isContainerCommandBySds(sds s) {
     return has_subcommands;
 }
 
-struct redisCommand *lookupCommandLogic(dict *commands, robj **argv, int argc) {
+/* Look up a command by argv and argc
+ *
+ * If `strict` is not 0 we expect argc to be exact (i.e. argc==2
+ * for a subcommand and argc==1 for a top-level command)
+ * `strict` should be used every time we want to look up a command
+ * name (e.g. in COMMAND INFO) rather than to find the command
+ * a user requested to execute (in processCommand).
+ */
+struct redisCommand *lookupCommandLogic(dict *commands, robj **argv, int argc, int strict) {
     struct redisCommand *base_cmd = dictFetchValue(commands, argv[0]->ptr);
     int has_subcommands = base_cmd && base_cmd->subcommands_dict;
     if (argc == 1 || !has_subcommands) {
+        if (strict && argc != 1)
+            return NULL;
         /* Note: It is possible that base_cmd->proc==NULL (e.g. CONFIG) */
         return base_cmd;
-    } else {
+    } else { /* argc > 1 && has_subcommands */
+        if (strict && argc != 2)
+            return NULL;
         /* Note: Currently we support just one level of subcommands */
         return dictFetchValue(base_cmd->subcommands_dict, argv[1]->ptr);
     }
 }
 
 struct redisCommand *lookupCommand(robj **argv, int argc) {
-    return lookupCommandLogic(server.commands,argv,argc);
+    return lookupCommandLogic(server.commands,argv,argc,0);
 }
 
 struct redisCommand *lookupCommandBySdsLogic(dict *commands, sds s) {
@@ -2846,7 +2858,7 @@ struct redisCommand *lookupCommandBySdsLogic(dict *commands, sds s) {
         argv[j] = &objects[j];
     }
 
-    struct redisCommand *cmd = lookupCommandLogic(commands,argv,argc);
+    struct redisCommand *cmd = lookupCommandLogic(commands,argv,argc,1);
     sdsfreesplitres(strings,argc);
     return cmd;
 }
@@ -2876,9 +2888,9 @@ struct redisCommand *lookupCommandByCString(const char *s) {
  * rewriteClientCommandVector() in order to set client->cmd pointer
  * correctly even if the command was renamed. */
 struct redisCommand *lookupCommandOrOriginal(robj **argv ,int argc) {
-    struct redisCommand *cmd = lookupCommandLogic(server.commands, argv, argc);
+    struct redisCommand *cmd = lookupCommandLogic(server.commands, argv, argc, 0);
 
-    if (!cmd) cmd = lookupCommandLogic(server.orig_commands, argv, argc);
+    if (!cmd) cmd = lookupCommandLogic(server.orig_commands, argv, argc, 0);
     return cmd;
 }
 


Test patch:
diff --git a/tests/unit/acl.tcl b/tests/unit/acl.tcl
index 872bade905d..5c559210859 100644
--- a/tests/unit/acl.tcl
+++ b/tests/unit/acl.tcl
@@ -327,16 +327,27 @@ start_server {tags {"acl external:skip"}} {
         set e
     } {*NOPERM*config|set*}
 
-    test {ACLs can include a subcommand with a specific arg} {
+    test {ACLs cannot include a subcommand with a specific arg} {
         r ACL setuser newuser +@all -config|get
-        r ACL setuser newuser +config|get|appendonly
-        set cmdstr [dict get [r ACL getuser newuser] commands]
-        assert_match {*-config|get*} $cmdstr
-        assert_match {*+config|get|appendonly*} $cmdstr
-        r CONFIG GET appendonly; # Should not fail
-        catch {r CONFIG GET loglevel} e
+        catch { r ACL setuser newuser +config|get|appendonly} e
         set e
-    } {*NOPERM*config|get*}
+    } {*Allowing first-arg of a subcommand is not supported*}
+
+    test {ACLs cannot exclude or include a container commands with a specific arg} {
+        r ACL setuser newuser +@all +config|get
+        catch { r ACL setuser newuser +@all +config|asdf} e
+        assert_match "*Unknown command or category name in ACL*" $e
+        catch { r ACL setuser newuser +@all -config|asdf} e
+        assert_match "*Unknown command or category name in ACL*" $e
+    } {}
+
+    test {ACLs cannot exclude or include a container command with two args} {
+        r ACL setuser newuser +@all +config|get
+        catch { r ACL setuser newuser +@all +get|key1|key2} e
+        assert_match "*Unknown command or category name in ACL*" $e
+        catch { r ACL setuser newuser +@all -get|key1|key2} e
+        assert_match "*Unknown command or category name in ACL*" $e
+    } {}
 
     test {ACLs including of a type includes also subcommands} {
         r ACL setuser newuser -@all +acl +@stream
diff --git a/tests/unit/introspection-2.tcl b/tests/unit/introspection-2.tcl
index 437826f764d..457ac6bc4a0 100644
--- a/tests/unit/introspection-2.tcl
+++ b/tests/unit/introspection-2.tcl
@@ -110,4 +110,9 @@ start_server {tags {"introspection"}} {
         set reply [r command list filterby pattern pf*]
         assert_equal [lsort $reply] {pfadd pfcount pfdebug pfmerge pfselftest}
     }
+
+    test {COMMAND INFO of invalid subcommands} {
+        assert_equal {{}} [r command info get|key]
+        assert_equal {{}} [r command info config|get|key]
+    }
 }