Repository: mlte-team/mlte
Pull request #642: Bug/log in model permissions
Author: login: sei-secheverria
Created: 2025-04-24T14:42:28Z | Merged: 2025-04-25T18:51:08Z

Addresses #633 by adding explicit support for hyphens in resource ids, permissions were not working properly when a resource id contained a hyphen. Also added unit tests for the Policy class.


Diff:
diff --git a/mlte/store/user/policy.py b/mlte/store/user/policy.py
index 719cbf316..0894840d5 100644
--- a/mlte/store/user/policy.py
+++ b/mlte/store/user/policy.py
@@ -6,7 +6,7 @@
 
 from __future__ import annotations
 
-from typing import Any, Optional, Union
+from typing import Optional, Union
 
 import mlte.store.error as errors
 from mlte.store.artifact.store import ArtifactStoreSession
@@ -45,14 +45,18 @@ class Policy:
     EDIT_GROUP_PREFIX = "edit"
     READ_GROUP_PREFIX = "read"
 
+    SEPARATOR = "-"
+    SEPARATOR_REPLACEMENT = "___"
+    """Used when building group name."""
+
     @staticmethod
     def _build_group_name(
-        prefix: str, resource_type: ResourceType, resource_id: Any
+        prefix: str, resource_type: ResourceType, resource_id: Optional[str]
     ):
         """Builds group ids for the given prefix and resource id."""
-        name = f"{prefix}-{resource_type}"
+        name = f"{prefix}{Policy.SEPARATOR}{resource_type}"
         if resource_id is not None:
-            name = f"{name}-{resource_id}"
+            name = f"{name}{Policy.SEPARATOR}{resource_id.replace(Policy.SEPARATOR, Policy.SEPARATOR_REPLACEMENT)}"
         return name
 
     # -----------------------------------------------------------------------------
@@ -195,12 +199,20 @@ def save_to_store(self, user_store: UserStoreSession) -> None:
     def remove_from_store(self, user_store: UserStoreSession) -> None:
         """Delete groups and permissions for a resource."""
         # TODO: This is not atomic. Error deleting one part may leave the rest dangling.
+        permissions: dict[str, bool] = {}
         for group in self.groups:
             for permission in group.permissions:
-                user_store.permission_mapper.delete(permission.to_str())
+                # Store permissions in dict for later removal, to avoid trying to re-remove already deleted ones.
+                permissions[permission.to_str()] = True
 
             user_store.group_mapper.delete(group.name)
 
+        # Now remove all permissions.
+        # TODO: note that this main leave other groups using these permissions dangling. Not trivial to check if
+        # a permission is no longer used. Even worse, we may want to leave some of them, even with no groups.
+        for permission_str in permissions:
+            user_store.permission_mapper.delete(permission_str)
+
     def assign_to_user(self, user: Union[UserWithPassword, BasicUser]):
         """
         Add to this user object the groups from this policy he is not a member of.
@@ -219,3 +231,6 @@ def assign_to_user(self, user: Union[UserWithPassword, BasicUser]):
 
             if not user_already_in_group:
                 user.groups.append(group)
+
+    def __str__(self) -> str:
+        return f"Resource {self.resource_type}, Id: {self.resource_id}, Read: {self.read_group}, Edit: {self.edit_group}, Create: {self.create_group}, Groups: {self.groups}"
diff --git a/mlte/user/model.py b/mlte/user/model.py
index f81a2627d..edcfa208c 100644
--- a/mlte/user/model.py
+++ b/mlte/user/model.py
@@ -6,7 +6,7 @@
 
 from __future__ import annotations
 
-from typing import List, Optional, Union
+from typing import ClassVar, List, Optional, Union
 
 from strenum import StrEnum
 
@@ -189,6 +189,10 @@ def get_group_names(groups: List[Group]) -> List[Group]:
 class Permission(BaseModel):
     """Permissions for manipulating resources."""
 
+    SEPARATOR: ClassVar[str] = "-"
+    SEPARATOR_REPLACEMENT: ClassVar[str] = "___"
+    """Used when serializing to string."""
+
     resource_type: ResourceType
     """The type of resource resource."""
 
@@ -200,20 +204,22 @@ class Permission(BaseModel):
 
     def to_str(self) -> str:
         """Serialize the permission to a string"""
-        serialized = f"{self.resource_type}-{self.method}"
+        serialized = f"{self.resource_type}{Permission.SEPARATOR}{self.method}"
         if self.resource_id is not None:
-            serialized = f"{serialized}-{self.resource_id}"
+            serialized = f"{serialized}{Permission.SEPARATOR}{self.resource_id.replace(Permission.SEPARATOR, Permission.SEPARATOR_REPLACEMENT)}"
         return serialized
 
     @staticmethod
     def from_str(permission_str: str) -> Permission:
         """Creates a permission from its string serialization."""
         # Source str can have 2 or 3 parts, depending if resource id was None.
-        parts = permission_str.split("-")
+        parts = permission_str.split(Permission.SEPARATOR)
         type = parts[0]
         method = parts[1]
         if len(parts) > 2:
-            resource_id = parts[2]
+            resource_id = parts[2].replace(
+                Permission.SEPARATOR_REPLACEMENT, Permission.SEPARATOR
+            )
         else:
             resource_id = None
 
diff --git a/test/store/test_policy.py b/test/store/test_policy.py
new file mode 100644
index 000000000..ba79e70cb
--- /dev/null
+++ b/test/store/test_policy.py
@@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
+    ):
+        """Tests that policies can be properly saved and removed."""
+        store: UserStore = request.getfixturevalue(f"user_{store_fixture_name}")
+        policy = Policy(resource_type, id, read, edit, create)
+
+        with ManagedUserSession(store.session()) as user_store:
+            if not id and (create or read or edit):
+                # Store is iniitialized with non-id policies with all permissions
+                assert Policy.is_stored(policy, user_store)
+            elif policy.groups:
+                # Only need to save if current policy has any groups.
+                assert not policy.is_stored(user_store)
+                policy.save_to_store(user_store)
+
+            assert policy.is_stored(user_store)
+
+            # Only test remove if policy has any groups.
+            if policy.groups:
+                policy.remove_from_store(user_store)
+                assert not policy.is_stored(user_store)
+
+    @staticmethod
+    def test_assign_to_user(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Checks that a policy is properly applied to a user object."""
+        policy = Policy(resource_type, id, read, edit, create)
+        user = BasicUser(username="test")
+
+        policy.assign_to_user(user)
+
+        for group in policy.groups:
+            assert group in user.groups
+
+
+@pytest.mark.parametrize("art_store_fixture_name", artifact_stores())
+@pytest.mark.parametrize("user_store_fixture_name", user_stores())
+def test_create_policy_if_needed(
+    art_store_fixture_name: str,
+    user_store_fixture_name: str,
+    request: pytest.FixtureRequest,
+):
+    """Checks that policies for models created outside of backend are properly updated."""
+    model_id = "m1"
+    policy = Policy(ResourceType.MODEL, resource_id=model_id)
+    artifact_store: ArtifactStore = request.getfixturevalue(
+        f"artifact_{art_store_fixture_name}"
+    )
+    user_store: UserStore = request.getfixturevalue(
+        f"user_{user_store_fixture_name}"
+    )
+
+    with ManagedArtifactSession(
+        artifact_store.session()
+    ) as artifact_store_session:
+        with ManagedUserSession(user_store.session()) as user_store_session:
+            # Create a model which will not have permissions.
+            artifact_store_session.create_model(Model(identifier=model_id))
+
+            # Add policies.
+            create_model_policies_if_needed(
+                artifact_store_session, user_store_session
+            )
+
+            # Check policies now exist.
+            assert policy.is_stored(user_store_session)
+
+            # Check we can call create_model_policies again with no issues.
+            create_model_policies_if_needed(
+                artifact_store_session, user_store_session
+            )


Reviews:
user: login: sei-aderr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T15:45:03Z
user: login: sei-secheverria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:15:23Z
user: login: sei-secheverria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:16:02Z
user: login: sei-secheverria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:17:10Z
user: login: sei-secheverria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:20:17Z
user: login: sei-aderr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T20:52:41Z
user: login: sei-secheverria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T02:52:16Z
user: login: sei-aderr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-25T13:51:48Z
user: login: sei-aderr
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-25T18:22:22Z

Review comments:
diff_hunk: @@ -189,6 +189,10 @@ def get_group_names(groups: List[Group]) -> List[Group]:
 class Permission(BaseModel):
     """Permissions for manipulating resources."""
 
+    SEPARATOR: ClassVar[str] = "-"
path: mlte/user/model.py
user: login: sei-aderr
body: Is there a reason this one is defined as a ClassVar and the policy one is not?
created_at: 2025-04-24T15:28:06Z
author_association: COLLABORATOR
diff_hunk: @@ -195,12 +199,20 @@ def save_to_store(self, user_store: UserStoreSession) -> None:
     def remove_from_store(self, user_store: UserStoreSession) -> None:
         """Delete groups and permissions for a resource."""
         # TODO: This is not atomic. Error deleting one part may leave the rest dangling.
+        permissions: dict[str, bool] = {}
         for group in self.groups:
             for permission in group.permissions:
-                user_store.permission_mapper.delete(permission.to_str())
+                # Store permissions in dict for later removal, to avoid trying to re-remove already deleted ones.
+                permissions[permission.to_str()] = True
 
             user_store.group_mapper.delete(group.name)
 
+        # Now remove all permissions.
+        # TODO: note that this main leave other groups using these permissions dangling. Not trivial to check if
path: mlte/store/user/policy.py
user: login: sei-aderr
body: This is a tough one. Be sure to make a task relating to this so that we don't forgot to look into it later. Feels like it should be a solved problem in the world of permission management but not immediately clear what that solution would be. 
created_at: 2025-04-24T15:31:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
path: test/store/test_policy.py
user: login: sei-aderr
body: Didn't know you could stack them like this to get all of the combinations. A nice implementation
created_at: 2025-04-24T15:33:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
path: test/store/test_policy.py
user: login: sei-aderr
body: Is the TestPolicy defined as a class so that you can pass this set of stacked `parametrize` to all of the static methods underneath without having to repeat the `parametrize` each time?
created_at: 2025-04-24T15:43:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
path: test/store/test_policy.py
user: login: sei-aderr
body: What is this pytest object being used to get a UserStore?
created_at: 2025-04-24T15:44:25Z
author_association: COLLABORATOR
diff_hunk: @@ -189,6 +189,10 @@ def get_group_names(groups: List[Group]) -> List[Group]:
 class Permission(BaseModel):
     """Permissions for manipulating resources."""
 
+    SEPARATOR: ClassVar[str] = "-"
path: mlte/user/model.py
user: login: sei-secheverria
body: Yes, when you are extending a Pydantic model, which we are here by deriving from BaseModel, every attribute becomes a model field that will be stored and has to be handled in constructor and serialization. To mark an attribute as a class attribute and not a part of the model to be serialized, this is the way Pydanting does it.
created_at: 2025-04-24T20:15:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,12 +199,20 @@ def save_to_store(self, user_store: UserStoreSession) -> None:
     def remove_from_store(self, user_store: UserStoreSession) -> None:
         """Delete groups and permissions for a resource."""
         # TODO: This is not atomic. Error deleting one part may leave the rest dangling.
+        permissions: dict[str, bool] = {}
         for group in self.groups:
             for permission in group.permissions:
-                user_store.permission_mapper.delete(permission.to_str())
+                # Store permissions in dict for later removal, to avoid trying to re-remove already deleted ones.
+                permissions[permission.to_str()] = True
 
             user_store.group_mapper.delete(group.name)
 
+        # Now remove all permissions.
+        # TODO: note that this main leave other groups using these permissions dangling. Not trivial to check if
path: mlte/store/user/policy.py
user: login: sei-secheverria
body: Yep, I'll make a task for that. I have some ideas, it just would be a substantial amount of effort, and there are currently no clear use cases where this would actually happen (yet).
created_at: 2025-04-24T20:16:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
path: test/store/test_policy.py
user: login: sei-secheverria
body: Right. I looked into ways to avoid repeating the parametrized args and to reuse them for several, but not all, tests in a module, and this seemed to be the simplest option. There are other ways, but they either apply to all tests in the module, or have other downsides.
created_at: 2025-04-24T20:17:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
path: test/store/test_policy.py
user: login: sei-secheverria
body: We are doing that in several ways, it is one of pytest weird built in ways to do stuff. In this case, this allows us to pass store names as strings, and the magical request object lets us create objets that have been decorated as fixtures out of those strings. I basically copied that pattern from code Kyle had already added, there may be a more efficient  or clearer way to do it, but this is how we are doing it everywhere we want to test something with multiple store implementations.
created_at: 2025-04-24T20:20:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
path: test/store/test_policy.py
user: login: sei-aderr
body: Is there a functional difference to this implementation to get the stores and how I set it up for the custom lists and the store I copied that from? Those ones just make the list of the store types and then have one function as a switch for each type. It looks very similar but could be a difference I'm missing
created_at: 2025-04-24T20:52:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
path: test/store/test_policy.py
user: login: sei-secheverria
body: I had not realized it, but we do have two methods. The catalog and custom lists store are using the switch you mention, and the artifact and user ones are using the fixture call one. I am not entirely sure why we ended up with both versions, likely not intentional. It would be interesting to decide which one is better. It could be argued that this option (with the pytest.RequestFixture) is a bit harder to understand at first sight on each test, but it requires less code, as it doesn't require that switch (the switch is implicit in the fixture name and using request to call that fixture by its name), and is easier to scale. The other, switch option is simpler to follow when looking at a test, but it requires that switch, which means maintaing more code that may not be needed. We could discuss this in more detail.
created_at: 2025-04-25T02:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+"""Unit tests for the Policy class."""
+
+import pytest
+
+from mlte.context.model import Model
+from mlte.store.artifact.store import ArtifactStore, ManagedArtifactSession
+from mlte.store.user.policy import Policy, create_model_policies_if_needed
+from mlte.store.user.store import UserStore
+from mlte.store.user.store_session import ManagedUserSession
+from mlte.user.model import BasicUser, MethodType, ResourceType
+from test.store.artifact.fixture import artifact_stores
+from test.store.artifact.fixture import fs_store as artifact_fs_store  # noqa
+from test.store.artifact.fixture import (  # noqa
+    http_store as artifact_http_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    memory_store as artifact_memory_store,
+)
+from test.store.artifact.fixture import (  # noqa
+    rdbs_store as artifact_rdbs_store,
+)
+from test.store.user.fixture import fs_store as user_fs_store  # noqa
+from test.store.user.fixture import memory_store as user_memory_store  # noqa
+from test.store.user.fixture import rdbs_store as user_rdbs_store  # noqa
+from test.store.user.fixture import user_stores
+
+
+@pytest.mark.parametrize(
+    "resource_type", [resource_type for resource_type in ResourceType]
+)
+@pytest.mark.parametrize("id", [None, "test-1"])
+@pytest.mark.parametrize("read", [True, False])
+@pytest.mark.parametrize("edit", [True, False])
+@pytest.mark.parametrize("create", [True, False])
+class TestPolicy:
+
+    @staticmethod
+    def test_creation(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+    ):
+        """Tests that policies contain the expected groups and permissions."""
+        policy = Policy(resource_type, id, read, edit, create)
+
+        # Check all created permissions are for the given type and ids.
+        found_permissions: dict[MethodType, bool] = {
+            method_type: False for method_type in MethodType
+        }
+        for group in policy.groups:
+            for permission in group.permissions:
+                assert permission.resource_type == resource_type
+                assert permission.resource_id == id
+                found_permissions[permission.method] = True
+
+        # Check that permissions were created depending on the booleans.
+        if read:
+            assert found_permissions[MethodType.GET]
+        if edit:
+            assert found_permissions[MethodType.PUT]
+            assert found_permissions[MethodType.DELETE]
+        if create and not id:
+            assert found_permissions[MethodType.POST]
+
+    @staticmethod
+    @pytest.mark.parametrize("store_fixture_name", user_stores())
+    def test_save_remove(
+        resource_type: ResourceType,
+        id: str,
+        read: bool,
+        edit: bool,
+        create: bool,
+        store_fixture_name: str,
+        request: pytest.FixtureRequest,
path: test/store/test_policy.py
user: login: sei-aderr
body: Yeah, can see the arguments for both. I don't have a super strong opinion either way. I am find to leave this one as-is and then just make a task to evaluate and make them consistent at a later date.
created_at: 2025-04-25T13:51:48Z
author_association: COLLABORATOR

PR comments:
user: login: sei-aderr
created_at: 2025-04-25T17:34:50Z
body: So in the end the issue was not related to how the permissions were created (library/frontend) but instead just an error with the naming of the artifacts that was causing issues and creation method wasn't an issue?
author_association: COLLABORATOR
user: login: sei-secheverria
created_at: 2025-04-25T18:21:20Z
body: Yes, the issue was hyphens in resource names. The groups and permissions are named automatically based on several things, including resource ids when they are groups/permissions for specific resource ids. The way I was building those names was based on hyphens, and when there were hyphens in the id, the split would not work properly, and some methods would fail to work as expected. Adding support to replace hyphens in resouce ids with another character has fixed the issue.
author_association: CONTRIBUTOR