Repository: Cog-Creators/Red-DiscordBot

Issue:
`[p]permissions acl setguild` is broken for Postgres driver
# Command bugs

#### Command name

`[p]permissions acl setguild`

#### What cog is this command from?

Permissions

#### What were you expecting to happen?

I expected ACL to be set properly

#### What actually happened?

The whole ACL for the guild got borked:
![image](https://user-images.githubusercontent.com/6032823/80323132-ccd88500-8829-11ea-8fc6-37e91ceb19f7.png)
- `default` and `176070082584248320` are keys that are result of using `[p]permissions acl setguild`
- `8` represents how it should look - the rule was added with a `[p]permissions addguildrule deny 8 57287406247743488` command right after setting ACL

For the reference, this is how YAML file given to `[p]permissions acl setguild` looks:
```yaml
COG: {}
COMMAND:
  ping:
    default: false
    176070082584248320: true
  roll:
    default: true
    176070082584248320: false
```

#### How can we reproduce this issue?

Use `[p]permissions acl setguild` on a bot using Postgres driver.



Discussion:
I looked into this a little bit after a question I got about it. I can replicate the issue, and it appears to be related to use of set raw on a config base group, but I admittedly did not look into this in large detail and am only relaying the minimal amount of looking into this I've already done since I haven't looked further and am unlikely to do so.

Given what I remember about the internals of the drivers, I'd check for proper filling of the identifier info container class in this interaction as a next step should someone else look into this more.

Yeah @mikeshardmind is right, and the issue is actually rather simple. In `config.Group.set_raw()`, the `IdentifierData.add_identifier()` method is being used rather naively, since the primary key can be incomplete.

I'd say just add another method: `IdentifierData.get_child()`, which recognises whether or not an extra "key" will need to be a primary key or an identifier, then use this in `Group.set_raw()` instead.

It works by coincidence with the JSON driver because JSON doesn't really have a logical distinction between primary keys and identifiers.

Resolving patch:
diff --git a/redbot/core/config.py b/redbot/core/config.py
--- a/redbot/core/config.py
+++ b/redbot/core/config.py
@@ -334,7 +334,7 @@ def __getattr__(self, item: str) -> Union["Group", Value]:
         """
         is_group = self.is_group(item)
         is_value = not is_group and self.is_value(item)
-        new_identifiers = self.identifier_data.add_identifier(item)
+        new_identifiers = self.identifier_data.get_child(item)
         if is_group:
             return Group(
                 identifier_data=new_identifiers,
@@ -381,7 +381,7 @@ async def clear_raw(self, *nested_path: Any):
             dict access. These are casted to `str` for you.
         """
         path = tuple(str(p) for p in nested_path)
-        identifier_data = self.identifier_data.add_identifier(*path)
+        identifier_data = self.identifier_data.get_child(*path)
         await self.driver.clear(identifier_data)
 
     def is_group(self, item: Any) -> bool:
@@ -499,7 +499,7 @@ async def get_raw(self, *nested_path: Any, default=...):
             else:
                 default = poss_default
 
-        identifier_data = self.identifier_data.add_identifier(*path)
+        identifier_data = self.identifier_data.get_child(*path)
         try:
             raw = await self.driver.get(identifier_data)
         except KeyError:
@@ -583,7 +583,7 @@ async def set_raw(self, *nested_path: Any, value):
             The value to store.
         """
         path = tuple(str(p) for p in nested_path)
-        identifier_data = self.identifier_data.add_identifier(*path)
+        identifier_data = self.identifier_data.get_child(*path)
         if isinstance(value, dict):
             value = _str_key_dict(value)
         await self.driver.set(identifier_data, value=value)
diff --git a/redbot/core/drivers/base.py b/redbot/core/drivers/base.py
--- a/redbot/core/drivers/base.py
+++ b/redbot/core/drivers/base.py
@@ -109,6 +109,28 @@ def __eq__(self, other) -> bool:
     def __hash__(self) -> int:
         return hash((self.uuid, self.category, self.primary_key, self.identifiers))
 
+    def get_child(self, *keys: str) -> "IdentifierData":
+        if not all(isinstance(i, str) for i in keys):
+            raise ValueError("Identifiers must be strings.")
+
+        primary_keys = self.primary_key
+        identifiers = self.identifiers
+        num_missing_pkeys = self.primary_key_len - len(self.primary_key)
+        if num_missing_pkeys > 0:
+            primary_keys += keys[:num_missing_pkeys]
+        if len(keys) > num_missing_pkeys:
+            identifiers += keys[num_missing_pkeys:]
+
+        return IdentifierData(
+            self.cog_name,
+            self.uuid,
+            self.category,
+            primary_keys,
+            identifiers,
+            self.primary_key_len,
+            self.is_custom,
+        )
+
     def add_identifier(self, *identifier: str) -> "IdentifierData":
         if not all(isinstance(i, str) for i in identifier):
             raise ValueError("Identifiers must be strings.")


Test patch:
diff --git a/tests/core/test_config.py b/tests/core/test_config.py
--- a/tests/core/test_config.py
+++ b/tests/core/test_config.py
@@ -554,3 +554,109 @@ async def func():
     await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
 
     assert len(await config.foo()) == 15
+
+
+@pytest.mark.asyncio
+async def test_set_with_partial_primary_keys(config):
+    config.init_custom("CUSTOM", 3)
+    await config.custom("CUSTOM", "1").set({"11": {"111": {"foo": "bar"}}})
+    assert await config.custom("CUSTOM", "1", "11", "111").foo() == "bar"
+
+    await config.custom("CUSTOM", "2").set(
+        {
+            "11": {"111": {"foo": "bad"}},
+            "22": {"111": {"foo": "baz"}},
+            "33": {"111": {"foo": "boo"}, "222": {"foo": "boz"}},
+        }
+    )
+    assert await config.custom("CUSTOM", "2", "11", "111").foo() == "bad"
+    assert await config.custom("CUSTOM", "2", "22", "111").foo() == "baz"
+    assert await config.custom("CUSTOM", "2", "33", "111").foo() == "boo"
+    assert await config.custom("CUSTOM", "2", "33", "222").foo() == "boz"
+
+    await config.custom("CUSTOM", "2").set({"22": {}, "33": {"111": {}, "222": {"foo": "biz"}}})
+    with pytest.raises(KeyError):
+        await config.custom("CUSTOM").get_raw("2", "11")
+    with pytest.raises(KeyError):
+        await config.custom("CUSTOM").get_raw("2", "22", "111")
+    with pytest.raises(KeyError):
+        await config.custom("CUSTOM").get_raw("2", "33", "111", "foo")
+    assert await config.custom("CUSTOM", "2", "33", "222").foo() == "biz"
+
+
+@pytest.mark.asyncio
+async def test_raw_with_partial_primary_keys(config):
+    config.init_custom("CUSTOM", 1)
+    await config.custom("CUSTOM").set_raw("primary_key", "identifier", value=True)
+    assert await config.custom("CUSTOM", "primary_key").identifier() is True
+    await config.custom("CUSTOM").set_raw(value={"primary_key": {"identifier": False}})
+    assert await config.custom("CUSTOM", "primary_key").identifier() is False
+
+
+"""
+Following PARAMS can be generated with:
+from functools import reduce
+from pprint import pprint
+def generate_test_args(print_args=True):
+    pkeys = ("1", "2", "3")
+    identifiers = ("foo",)
+    full_dict = {"1": {"2": {"3": {"foo": "bar"}}}}
+    argvalues = [
+        (
+            pkeys[:x],
+            (pkeys[x:] + identifiers)[:y],
+            reduce(lambda d, k: d[k], (pkeys + identifiers)[:x+y], full_dict),
+        )
+        for x in range(len(pkeys) + 1)
+        for y in range(len(pkeys) + len(identifiers) - x + 1)
+    ]
+    if print_args:
+        print("[")
+        for args in argvalues:
+            print(f"    {args!r},")
+        print("]")
+    else:
+        return argvalues
+generate_test_args()
+"""
+PARAMS = [
+    ((), (), {"1": {"2": {"3": {"foo": "bar"}}}}),
+    ((), (1,), {"2": {"3": {"foo": "bar"}}}),
+    ((), (1, 2), {"3": {"foo": "bar"}}),
+    ((), (1, 2, 3), {"foo": "bar"}),
+    ((), (1, 2, 3, "foo"), "bar"),
+    ((1,), (), {"2": {"3": {"foo": "bar"}}}),
+    ((1,), (2,), {"3": {"foo": "bar"}}),
+    ((1,), (2, 3), {"foo": "bar"}),
+    ((1,), (2, 3, "foo"), "bar"),
+    ((1, 2), (), {"3": {"foo": "bar"}}),
+    ((1, 2), (3,), {"foo": "bar"}),
+    ((1, 2), (3, "foo"), "bar"),
+    ((1, 2, 3), (), {"foo": "bar"}),
+    ((1, 2, 3), ("foo",), "bar"),
+]
+
+
+@pytest.mark.parametrize("pkeys, raw_args, result", PARAMS)
+@pytest.mark.asyncio
+async def test_config_custom_partial_pkeys_get(config, pkeys, raw_args, result):
+    # setup
+    config.init_custom("TEST", 3)
+    config.register_custom("TEST")
+    await config.custom("TEST", 1, 2, 3).set({"foo": "bar"})
+
+    group = config.custom("TEST", *pkeys)
+    assert await group.get_raw(*raw_args) == result
+
+
+@pytest.mark.parametrize("pkeys, raw_args, result", PARAMS)
+@pytest.mark.asyncio
+async def test_config_custom_partial_pkeys_set(config, pkeys, raw_args, result):
+    # setup
+    config.init_custom("TEST", 3)
+    config.register_custom("TEST")
+    await config.custom("TEST", 1, 2, 3).set({"foo": "blah"})
+
+    group = config.custom("TEST", *pkeys)
+    await group.set_raw(*raw_args, value=result)
+    assert await group.get_raw(*raw_args) == result