Repository: Cog-Creators/Red-DiscordBot

Issue:
[V3 Permissions] Rule precedence bug.
Applying a default to a Cog as well as to a command within a Cog (Default deny cog, allow command) does not allow the command. 

With current intended precedence, this should allow the command. 

Initially reported by <@203619925179236352> (Céli28), confirmed by me and <@218773382617890828> (TrustyJAID)



Discussion:
This appears to just be default rules not playing well. Defaults (for cogs) are not be overridden by other rules which apply in the current context, and additionally, defaults can lock owners out of commands. This might indicate an exception being raised and suppressed silently breaking the expected behavior here based on the intended flow, but I won't have a chance to take a closer look into this until later.
Looks related to #2249 
Thanks for reporting, will look into this and #2249 when I get the chance.
I'm not sure it's related to 2249. 2249 being a UX issue that could possibly be caused by this leads me to think that solving this first and seeing if 2249 disappears with it is the best course of action for prioritizing fixes, then fixing that as a separate fix if this is not the case.
After a bunch of testing... Neither I, Toby, or Kowlin have been able to reproduce this anymore on V3/release/3.0.0. I'm closing this, but if anyone can still reproduce it on an up to date bot, please still comment.
Whoo boy, we have a reproduce now.

Thanks? Soulrfit <@142006827155062784>

<details><summary>yaml content collapsed in here.</summary><p>

Guild acl:
```yaml
COG:
  Admin:
    default: false
    334061410915057664: true
    411874257522393098: true
  Alias:
    default: false
    334061410915057664: true
    411874257522393098: true
  Audio:
    default: false
    334061410915057664: true
    411874257522393098: true
  Cleanup:
    default: false
    334061410915057664: true
    411874257522393098: true
  Core:
    default: false
    334061410915057664: true
    411874257522393098: true
  ErrorLogs:
    default: false
    334061410915057664: true
    411874257522393098: true
  General:
    default: false
    334061410915057664: true
    411874257522393098: true
  Mod:
    default: false
    334061410915057664: true
    411874257522393098: true
  ModLog:
    default: false
    334061410915057664: true
    411874257522393098: true
  NoEpic:
    default: false
    334061410915057664: true
    411874257522393098: true
  Reports:
    464137944815501312: true
  RoleManagement:
    default: false
    334061410915057664: true
    411874257522393098: true
  Spoopy:
    411874257522393098: true
  Streams:
    default: false
    334061410915057664: true
    411874257522393098: true
  SysInfo:
    default: false
    334061410915057664: true
    411874257522393098: true
COMMAND:
  cleanup:
    default: false
    411874257522393098: true
    334061410915057664: true
    415935589368594432: false
    441001661532602378: false
    362235556937334795: false
  customcom:
    default: false
    411874257522393098: true
    334061410915057664: true
  customcom list:
    362235742648532992: false
    362236197860409344: false
    362236325425971201: false
    440978693125439489: true
    520130928169975838: false
  genname:
    374987880592048128: false
  help:
    default: false
    334061410915057664: true
    411874257522393098: true
    440978693125439489: true
  info:
    default: true
  massrole search:
    411874257522393098: true
  now:
    440978693125439489: true
  pause:
    440978693125439489: true
  play:
    440978693125439489: true
  prev:
    440978693125439489: true
  search:
    440978693125439489: true
  skip:
    440978693125439489: true
  spoopy:
    411874257522393098: true
  spoopy check:
    441001661532602378: true
    441002581704310784: true
  stop:
    440978693125439489: true
  uinfo:
    334061410915057664: false
  voiceban:
    411874257522393098: true
```

There is no global acl set.


</p></details>
Ah I misunderstood what the bug was when I last investigated this issue. I thought the bug was that default cog rules were taking precedence over actual model-based rules for commands. I now realise it's for default deny cog, default allow command.

This will also be the case for default deny supercommand, default allow subcommand.

I can see why this is occurring - the reason subcommand rules are able to take precedence over cog/supercommand rules is because when a rule is set, the parent's rules are modified too. This is not the case for default rules, and in fact they essentially follow the same process that was used before the permissions rewrite. However, I don't think it will be hard to incorporate them into the new technique.

Resolving patch:
diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py
--- a/redbot/core/commands/commands.py
+++ b/redbot/core/commands/commands.py
@@ -5,7 +5,7 @@
 """
 import inspect
 import weakref
-from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
+from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
 
 import discord
 from discord.ext import commands
@@ -50,20 +50,54 @@ def __init__(self, *args, **kwargs):
             checks=getattr(decorated, "__requires_checks__", []),
         )
 
-    def allow_for(self, model_id: int, guild_id: int) -> None:
-        """Actively allow this command for the given model."""
+    def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
+        """Actively allow this command for the given model.
+
+        Parameters
+        ----------
+        model_id : Union[int, str]
+            Must be an `int` if supplying an ID. `str` is only valid
+            for "default".
+        guild_id : int
+            The guild ID to allow this cog or command in. For global
+            rules, use ``0``.
+
+        """
         self.requires.set_rule(model_id, PermState.ACTIVE_ALLOW, guild_id=guild_id)
 
-    def deny_to(self, model_id: int, guild_id: int) -> None:
-        """Actively deny this command to the given model."""
+    def deny_to(self, model_id: Union[int, str], guild_id: int) -> None:
+        """Actively deny this command to the given model.
+
+        Parameters
+        ----------
+        model_id : Union[int, str]
+            Must be an `int` if supplying an ID. `str` is only valid
+            for "default".
+        guild_id : int
+            The guild ID to deny this cog or command in. For global
+            rules, use ``0``.
+
+        """
         cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
         if cur_rule is PermState.PASSIVE_ALLOW:
             self.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
         else:
             self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
 
-    def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
-        """Clear the rule which is currently set for this model."""
+    def clear_rule_for(
+        self, model_id: Union[int, str], guild_id: int
+    ) -> Tuple[PermState, PermState]:
+        """Clear the rule which is currently set for this model.
+
+        Parameters
+        ----------
+        model_id : Union[int, str]
+            Must be an `int` if supplying an ID. `str` is only valid
+            for "default".
+        guild_id : int
+            The guild ID. For global rules, use ``0``.
+
+        """
         cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
         if cur_rule is PermState.ACTIVE_ALLOW:
             new_rule = PermState.NORMAL
@@ -84,15 +118,17 @@ def set_default_rule(self, rule: Optional[bool], guild_id: int) -> None:
         rule : Optional[bool]
             The rule to set as default. If ``True`` for allow,
             ``False`` for deny and ``None`` for normal.
-        guild_id : Optional[int]
-            Specify to set the default rule for a specific guild.
-            When ``None``, this will set the global default rule.
+        guild_id : int
+            The guild to set the default rule in. When ``0``, this will
+            set the global default rule.
 
         """
-        if guild_id:
-            self.requires.set_default_guild_rule(guild_id, PermState.from_bool(rule))
-        else:
-            self.requires.default_global_rule = PermState.from_bool(rule)
+        if rule is None:
+            self.clear_rule_for(Requires.DEFAULT, guild_id=guild_id)
+        elif rule is True:
+            self.allow_for(Requires.DEFAULT, guild_id=guild_id)
+        elif rule is False:
+            self.deny_to(Requires.DEFAULT, guild_id=guild_id)
 
 
 class Command(CogCommandMixin, commands.Command):
@@ -335,7 +371,7 @@ def enable_in(self, guild: discord.Guild) -> bool:
         else:
             return True
 
-    def allow_for(self, model_id: int, guild_id: int) -> None:
+    def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
         super().allow_for(model_id, guild_id=guild_id)
         parents = self.parents
         if self.instance is not None:
@@ -347,7 +383,9 @@ def allow_for(self, model_id: int, guild_id: int) -> None:
             elif cur_rule is PermState.ACTIVE_DENY:
                 parent.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
 
-    def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
+    def clear_rule_for(
+        self, model_id: Union[int, str], guild_id: int
+    ) -> Tuple[PermState, PermState]:
         old_rule, new_rule = super().clear_rule_for(model_id, guild_id=guild_id)
         if old_rule is PermState.ACTIVE_ALLOW:
             parents = self.parents
@@ -396,8 +434,28 @@ class CogGroupMixin:
     all_commands: Dict[str, Command]
 
     def reevaluate_rules_for(
-        self, model_id: int, guild_id: Optional[int]
+        self, model_id: Union[str, int], guild_id: Optional[int]
     ) -> Tuple[PermState, bool]:
+        """Re-evaluate a rule by checking subcommand rules.
+
+        This is called when a subcommand is no longer actively allowed.
+
+        Parameters
+        ----------
+        model_id : Union[int, str]
+            Must be an `int` if supplying an ID. `str` is only valid
+            for "default".
+        guild_id : int
+            The guild ID. For global rules, use ``0``.
+
+        Returns
+        -------
+        Tuple[PermState, bool]
+            A 2-tuple containing the new rule and a bool indicating
+            whether or not the rule was changed as a result of this
+            call.
+
+        """
         cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
         if cur_rule in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
             # These three states are unaffected by subcommand rules
diff --git a/redbot/core/commands/requires.py b/redbot/core/commands/requires.py
--- a/redbot/core/commands/requires.py
+++ b/redbot/core/commands/requires.py
@@ -19,6 +19,7 @@
     TYPE_CHECKING,
     TypeVar,
     Tuple,
+    ClassVar,
 )
 
 import discord
@@ -284,6 +285,14 @@ class Requires:
 
     """
 
+    DEFAULT: ClassVar[str] = "default"
+    """The key for the default rule in a rules dict."""
+
+    GLOBAL: ClassVar[int] = 0
+    """Should be used in place of a guild ID when setting/getting
+    global rules.
+    """
+
     def __init__(
         self,
         privilege_level: Optional[PrivilegeLevel],
@@ -307,10 +316,8 @@ def __init__(
             self.bot_perms.update(**bot_perms)
         else:
             self.bot_perms = bot_perms
-        self.default_global_rule: PermState = PermState.NORMAL
-        self._global_rules: _IntKeyDict[PermState] = _IntKeyDict()
-        self._default_guild_rules: _IntKeyDict[PermState] = _IntKeyDict()
-        self._guild_rules: _IntKeyDict[_IntKeyDict[PermState]] = _IntKeyDict()
+        self._global_rules: _RulesDict = _RulesDict()
+        self._guild_rules: _IntKeyDict[_RulesDict] = _IntKeyDict[_RulesDict]()
 
     @staticmethod
     def get_decorator(
@@ -334,16 +341,17 @@ def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
 
         return decorator
 
-    def get_rule(self, model: Union[int, PermissionModel], guild_id: int) -> PermState:
+    def get_rule(self, model: Union[int, str, PermissionModel], guild_id: int) -> PermState:
         """Get the rule for a particular model.
 
         Parameters
         ----------
-        model : PermissionModel
-            The model to get the rule for.
+        model : Union[int, str, PermissionModel]
+            The model to get the rule for. `str` is only valid for
+            `Requires.DEFAULT`.
         guild_id : int
-            The ID of the guild for the rule's scope. Set to ``0``
-            for a global rule.
+            The ID of the guild for the rule's scope. Set to
+            `Requires.GLOBAL` for a global rule.
 
         Returns
         -------
@@ -352,31 +360,32 @@ def get_rule(self, model: Union[int, PermissionModel], guild_id: int) -> PermSta
             for an explanation.
 
         """
-        if not isinstance(model, int):
+        if not isinstance(model, (str, int)):
             model = model.id
         if guild_id:
-            rules = self._guild_rules.get(guild_id, _IntKeyDict())
+            rules = self._guild_rules.get(guild_id, _RulesDict())
         else:
             rules = self._global_rules
         return rules.get(model, PermState.NORMAL)
 
-    def set_rule(self, model_id: int, rule: PermState, guild_id: int) -> None:
+    def set_rule(self, model_id: Union[str, int], rule: PermState, guild_id: int) -> None:
         """Set the rule for a particular model.
 
         Parameters
         ----------
-        model_id : PermissionModel
-            The model to add a rule for.
+        model_id : Union[str, int]
+            The model to add a rule for. `str` is only valid for
+            `Requires.DEFAULT`.
         rule : PermState
             Which state this rule should be set as. See the `PermState`
             class for an explanation.
         guild_id : int
-            The ID of the guild for the rule's scope. Set to ``0``
-            for a global rule.
+            The ID of the guild for the rule's scope. Set to
+            `Requires.GLOBAL` for a global rule.
 
         """
         if guild_id:
-            rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
+            rules = self._guild_rules.setdefault(guild_id, _RulesDict())
         else:
             rules = self._global_rules
         if rule is PermState.NORMAL:
@@ -387,27 +396,24 @@ class for an explanation.
     def clear_all_rules(self, guild_id: int) -> None:
         """Clear all rules of a particular scope.
 
+        This will preserve the default rule, if set.
+
         Parameters
         ----------
         guild_id : int
-            The guild ID to clear rules for. If ``0``, this will
-            clear all global rules and leave all guild rules
-            untouched.
+            The guild ID to clear rules for. If set to
+            `Requires.GLOBAL`, this will clear all global rules and
+            leave all guild rules untouched.
 
         """
         if guild_id:
-            rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
+            rules = self._guild_rules.setdefault(guild_id, _RulesDict())
         else:
             rules = self._global_rules
+        default = rules.get(self.DEFAULT, None)
         rules.clear()
-
-    def get_default_guild_rule(self, guild_id: int) -> PermState:
-        """Get the default rule for a guild."""
-        return self._default_guild_rules.get(guild_id, PermState.NORMAL)
-
-    def set_default_guild_rule(self, guild_id: int, rule: PermState) -> None:
-        """Set the default rule for a guild."""
-        self._default_guild_rules[guild_id] = rule
+        if default is not None:
+            rules[self.DEFAULT] = default
 
     async def verify(self, ctx: "Context") -> bool:
         """Check if the given context passes the requirements.
@@ -470,9 +476,9 @@ async def _transition_state(self, ctx: "Context") -> bool:
             # We must check what would happen normally, if no explicit rules were set.
             default_rule = PermState.NORMAL
             if ctx.guild is not None:
-                default_rule = self.get_default_guild_rule(guild_id=ctx.guild.id)
+                default_rule = self.get_rule(self.DEFAULT, guild_id=ctx.guild.id)
             if default_rule is PermState.NORMAL:
-                default_rule = self.default_global_rule
+                default_rule = self.get_rule(self.DEFAULT, self.GLOBAL)
 
             if default_rule == PermState.ACTIVE_DENY:
                 would_invoke = False
@@ -510,7 +516,7 @@ def _get_rule_from_ctx(self, ctx: "Context") -> PermState:
             rule = self._global_rules.get(author.id)
             if rule is not None:
                 return rule
-            return self.default_global_rule
+            return self.get_rule(self.DEFAULT, self.GLOBAL)
 
         rules_chain = [self._global_rules]
         guild_rules = self._guild_rules.get(ctx.guild.id)
@@ -534,9 +540,9 @@ def _get_rule_from_ctx(self, ctx: "Context") -> PermState:
                     return rule
             del model_chain[-1]  # We don't check for the guild in guild rules
 
-        default_rule = self.get_default_guild_rule(guild.id)
+        default_rule = self.get_rule(self.DEFAULT, guild.id)
         if default_rule is PermState.NORMAL:
-            default_rule = self.default_global_rule
+            default_rule = self.get_rule(self.DEFAULT, self.GLOBAL)
         return default_rule
 
     async def _verify_checks(self, ctx: "Context") -> bool:
@@ -706,6 +712,20 @@ def __setitem__(self, key: Any, value: _T) -> None:
         return super().__setitem__(key, value)
 
 
+class _RulesDict(Dict[Union[int, str], PermState]):
+    """Dict subclass which throws a KeyError when an invalid key is used."""
+
+    def __getitem__(self, key: Any) -> PermState:
+        if key != Requires.DEFAULT and not isinstance(key, int):
+            raise TypeError(f'Expected "{Requires.DEFAULT}" or int key, not "{key}"')
+        return super().__getitem__(key)
+
+    def __setitem__(self, key: Any, value: PermState) -> None:
+        if key != Requires.DEFAULT and not isinstance(key, int):
+            raise TypeError(f'Expected "{Requires.DEFAULT}" or int key, not "{key}"')
+        return super().__setitem__(key, value)
+
+
 def _validate_perms_dict(perms: Dict[str, bool]) -> None:
     for perm, value in perms.items():
         try:


Test patch: