simple-module-sharing 0.0.41__tar.gz → 0.0.42__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/PKG-INFO +5 -5
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/package.json +4 -4
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/pyproject.toml +5 -5
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/contracts/__init__.py +2 -1
- simple_module_sharing-0.0.42/sharing/contracts/cleanup.py +25 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/policy.py +18 -8
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/service.py +2 -2
- simple_module_sharing-0.0.42/tests/test_sharing_custom_roles.py +235 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_http.py +48 -0
- simple_module_sharing-0.0.42/tests/test_sharing_resource_cleanup.py +75 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin.test.tsx +5 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/.gitignore +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/LICENSE +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/README.md +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/__init__.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/audience.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/constants.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/contracts/schemas.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/deps.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/__init__.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/api.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/views.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/locales/de.json +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/locales/en.json +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/models.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/module.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/Admin.tsx +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/components/ShareTable.tsx +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/types.ts +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/present.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/py.typed +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/services.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/settings.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/tenant_cleanup.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/user_cleanup.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/_sharing_support.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_service.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_audience.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_cleanup.py +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin_errors.test.tsx +0 -0
- {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_sharing
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.42
|
|
4
4
|
Summary: Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -11,10 +11,10 @@ License-Expression: MIT
|
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: access-control,permissions,sharing,simple-module
|
|
13
13
|
Requires-Python: >=3.12
|
|
14
|
-
Requires-Dist: simple-module-core==0.0.
|
|
15
|
-
Requires-Dist: simple-module-db==0.0.
|
|
16
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
17
|
-
Requires-Dist: simple-module-inertia==0.0.
|
|
14
|
+
Requires-Dist: simple-module-core==0.0.42
|
|
15
|
+
Requires-Dist: simple-module-db==0.0.42
|
|
16
|
+
Requires-Dist: simple-module-hosting==0.0.42
|
|
17
|
+
Requires-Dist: simple-module-inertia==0.0.42
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
20
20
|
# simple_module_sharing
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simple-module-py/sharing",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Frontend assets for the Sharing module",
|
|
6
6
|
"peerDependencies": {
|
|
7
|
+
"@inertiajs/react": "^3.9.1",
|
|
8
|
+
"@simple-module-py/ui": "*",
|
|
7
9
|
"react": "^19.0.0",
|
|
8
|
-
"react-dom": "^19.
|
|
9
|
-
"@inertiajs/react": "^3.7.0",
|
|
10
|
-
"@simple-module-py/ui": "*"
|
|
10
|
+
"react-dom": "^19.3.1"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@simple-module-py/tsconfig": "*"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_sharing"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.42"
|
|
4
4
|
description = "Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,10 +9,10 @@ requires-python = ">=3.12"
|
|
|
9
9
|
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
10
|
keywords = ["simple-module", "sharing", "permissions", "access-control"]
|
|
11
11
|
dependencies = [
|
|
12
|
-
"simple_module_core==0.0.
|
|
13
|
-
"simple_module_db==0.0.
|
|
14
|
-
"simple_module_hosting==0.0.
|
|
15
|
-
"simple_module_inertia==0.0.
|
|
12
|
+
"simple_module_core==0.0.42",
|
|
13
|
+
"simple_module_db==0.0.42",
|
|
14
|
+
"simple_module_hosting==0.0.42",
|
|
15
|
+
"simple_module_inertia==0.0.42",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.entry-points.simple_module]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Sharing contracts — public interface for other modules."""
|
|
2
2
|
|
|
3
|
+
from sharing.contracts.cleanup import forget_resource
|
|
3
4
|
from sharing.contracts.schemas import ShareGrant, ShareOut
|
|
4
5
|
|
|
5
|
-
__all__ = ["ShareGrant", "ShareOut"]
|
|
6
|
+
__all__ = ["ShareGrant", "ShareOut", "forget_resource"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Transactional cleanup for modules that own shared resources (#683)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlmodel import col, delete
|
|
7
|
+
|
|
8
|
+
from sharing.models import ResourceShare
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def forget_resource(
|
|
12
|
+
db: AsyncSession, resource_type: str, resource_id: str | Iterable[str]
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Delete shares for canonical resource ids in the caller's tenant/transaction.
|
|
15
|
+
|
|
16
|
+
For resource owners only: no permission or resource-existence check is made.
|
|
17
|
+
The caller must bind the owning tenant and commit or roll back its session.
|
|
18
|
+
"""
|
|
19
|
+
ids = [resource_id] if isinstance(resource_id, str) else list(resource_id)
|
|
20
|
+
await db.execute(
|
|
21
|
+
delete(ResourceShare).where(
|
|
22
|
+
col(ResourceShare.resource_type) == resource_type,
|
|
23
|
+
col(ResourceShare.resource_id).in_(ids),
|
|
24
|
+
)
|
|
25
|
+
)
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import uuid
|
|
17
|
-
from collections.abc import Callable
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
18
|
from datetime import UTC, datetime
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
@@ -26,9 +26,16 @@ from sharing.constants import ROLE_IMPLIES, SUBJECT_USER
|
|
|
26
26
|
from sharing.models import ResourceShare
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def
|
|
29
|
+
def resolve_roles(roles: Mapping[str, tuple[str, ...]] | None) -> Mapping[str, tuple[str, ...]]:
|
|
30
|
+
"""*roles* as declared on a resource type; ``None`` means the default view/edit roles."""
|
|
31
|
+
return ROLE_IMPLIES if roles is None else roles
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def roles_granting(
|
|
35
|
+
action: str, *, roles: Mapping[str, tuple[str, ...]] | None = None
|
|
36
|
+
) -> tuple[str, ...]:
|
|
30
37
|
"""Every share role that implies *action*."""
|
|
31
|
-
return tuple(role for role, implied in
|
|
38
|
+
return tuple(role for role, implied in resolve_roles(roles).items() if action in implied)
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
def _is_uuid(column: Any) -> bool:
|
|
@@ -72,18 +79,20 @@ def active_share_clause(now: datetime | None = None) -> Any:
|
|
|
72
79
|
|
|
73
80
|
|
|
74
81
|
def share_exists_for(
|
|
75
|
-
resource_type: str, id_col: Any
|
|
82
|
+
resource_type: str, id_col: Any, *, roles: Mapping[str, tuple[str, ...]] | None = None
|
|
76
83
|
) -> Callable[[ResourcePrincipal, str], Any | None]:
|
|
77
84
|
"""A ``share_exists`` factory for ``OwnedResourcePolicy``.
|
|
78
85
|
|
|
79
86
|
Correlated on *id_col*; tenant scoping comes from the share table's
|
|
80
|
-
``MultiTenantMixin`` filter, and expired shares never match.
|
|
87
|
+
``MultiTenantMixin`` filter, and expired shares never match. *roles* is the
|
|
88
|
+
type's ``ResourceType.share_roles`` (``None``: the default view/edit roles);
|
|
89
|
+
pass the same mapping to both so SQL access and grant validation agree.
|
|
81
90
|
"""
|
|
82
91
|
key = id_text(id_col)
|
|
83
92
|
|
|
84
93
|
def share_exists(principal: ResourcePrincipal, action: str) -> Any | None:
|
|
85
|
-
|
|
86
|
-
if not
|
|
94
|
+
granting_roles = roles_granting(action, roles=roles)
|
|
95
|
+
if not granting_roles or principal.user_id is None:
|
|
87
96
|
return None
|
|
88
97
|
return exists(
|
|
89
98
|
select(ResourceShare.id).where(
|
|
@@ -91,7 +100,7 @@ def share_exists_for(
|
|
|
91
100
|
ResourceShare.resource_id == key,
|
|
92
101
|
ResourceShare.subject_type == SUBJECT_USER,
|
|
93
102
|
ResourceShare.subject_id == principal.user_id,
|
|
94
|
-
ResourceShare.role.in_(
|
|
103
|
+
ResourceShare.role.in_(granting_roles),
|
|
95
104
|
active_share_clause(),
|
|
96
105
|
)
|
|
97
106
|
)
|
|
@@ -103,6 +112,7 @@ __all__ = [
|
|
|
103
112
|
"active_share_clause",
|
|
104
113
|
"canonical_id",
|
|
105
114
|
"id_text",
|
|
115
|
+
"resolve_roles",
|
|
106
116
|
"roles_granting",
|
|
107
117
|
"row_id",
|
|
108
118
|
"share_exists_for",
|
|
@@ -39,7 +39,7 @@ from sharing.constants import (
|
|
|
39
39
|
)
|
|
40
40
|
from sharing.contracts.schemas import ShareGrant
|
|
41
41
|
from sharing.models import ResourceShare
|
|
42
|
-
from sharing.policy import row_id
|
|
42
|
+
from sharing.policy import resolve_roles, row_id
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
class SharingError(Exception):
|
|
@@ -89,7 +89,7 @@ class SharingService:
|
|
|
89
89
|
|
|
90
90
|
async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> ResourceShare:
|
|
91
91
|
resource_type = self._type(data.resource_type)
|
|
92
|
-
if data.role not in resource_type.
|
|
92
|
+
if data.role not in resolve_roles(resource_type.share_roles):
|
|
93
93
|
raise SharingError(422, ERR_BAD_ROLE)
|
|
94
94
|
if data.expires_at is not None and _aware(data.expires_at) <= utcnow():
|
|
95
95
|
raise SharingError(422, ERR_EXPIRED)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Per-type role registration, SQL access and delegated sharing (#707)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from _sharing_support import FakeDirectory, SharingTestNote, create_note_table
|
|
10
|
+
from sharing.constants import ROLE_MAX
|
|
11
|
+
from sharing.contracts.schemas import ShareGrant
|
|
12
|
+
from sharing.policy import roles_granting, share_exists_for
|
|
13
|
+
from sharing.service import SharingError, SharingService
|
|
14
|
+
from simple_module_core.datetimes import utcnow
|
|
15
|
+
from simple_module_core.resources import (
|
|
16
|
+
ACTION_EDIT,
|
|
17
|
+
ACTION_SHARE,
|
|
18
|
+
ACTION_VIEW,
|
|
19
|
+
ResourcePrincipal,
|
|
20
|
+
ResourceType,
|
|
21
|
+
ResourceTypeError,
|
|
22
|
+
ResourceTypeRegistry,
|
|
23
|
+
)
|
|
24
|
+
from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id
|
|
25
|
+
from simple_module_db.tenancy import tenant_context
|
|
26
|
+
from sqlmodel import select
|
|
27
|
+
|
|
28
|
+
TYPE = "sharing_test.custom"
|
|
29
|
+
OWNER, MANAGER, READER, OTHER = (str(uuid.UUID(int=n)) for n in (11, 12, 13, 14))
|
|
30
|
+
ROLES = {
|
|
31
|
+
"validator": (ACTION_VIEW, "validate"),
|
|
32
|
+
"manager": (ACTION_VIEW, ACTION_SHARE),
|
|
33
|
+
"reviewer": (ACTION_VIEW, ACTION_EDIT, "review"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def declaration(**kwargs):
|
|
38
|
+
policy = OwnedResourcePolicy(
|
|
39
|
+
SharingTestNote.owner_id,
|
|
40
|
+
bypass_perm=None,
|
|
41
|
+
share_exists=share_exists_for(TYPE, SharingTestNote.id, roles=kwargs.get("share_roles")),
|
|
42
|
+
)
|
|
43
|
+
return ResourceType(TYPE, policy, select_by_id(SharingTestNote), **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("actions", [(), (ACTION_EDIT,), (ACTION_SHARE,), ("validate",)])
|
|
47
|
+
def test_registration_rejects_roles_without_view(actions):
|
|
48
|
+
resource = declaration(share_roles={"broken": actions})
|
|
49
|
+
with pytest.raises(ResourceTypeError, match="view"):
|
|
50
|
+
ResourceTypeRegistry().register(resource)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
("action", "expected"),
|
|
55
|
+
[
|
|
56
|
+
(ACTION_VIEW, ("validator", "manager", "reviewer")),
|
|
57
|
+
(ACTION_SHARE, ("manager",)),
|
|
58
|
+
("validate", ("validator",)),
|
|
59
|
+
(ACTION_EDIT, ("reviewer",)),
|
|
60
|
+
("delete", ()),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_roles_granting_uses_declared_mapping(action, expected):
|
|
64
|
+
assert roles_granting(action, roles=ROLES) == expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_empty_roles_do_not_fall_back_to_defaults():
|
|
68
|
+
assert roles_granting(ACTION_VIEW, roles={}) == ()
|
|
69
|
+
assert (
|
|
70
|
+
share_exists_for(TYPE, SharingTestNote.id, roles={})(
|
|
71
|
+
ResourcePrincipal(MANAGER), ACTION_VIEW
|
|
72
|
+
)
|
|
73
|
+
is None
|
|
74
|
+
)
|
|
75
|
+
ResourceTypeRegistry().register(declaration(share_roles={}))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_default_roles_remain_view_and_edit():
|
|
79
|
+
assert roles_granting(ACTION_VIEW) == ("view", "edit")
|
|
80
|
+
assert roles_granting(ACTION_EDIT) == ("edit",)
|
|
81
|
+
assert roles_granting(ACTION_SHARE) == ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("subset", [(ACTION_VIEW,), (ACTION_EDIT,), ()])
|
|
85
|
+
def test_legacy_subsets_warn_and_translate_to_roles(subset):
|
|
86
|
+
with pytest.warns(DeprecationWarning, match="shareable_actions.*0.1.0"):
|
|
87
|
+
resource = declaration(shareable_actions=subset)
|
|
88
|
+
ResourceTypeRegistry().register(resource)
|
|
89
|
+
assert tuple(resource.share_roles) == subset
|
|
90
|
+
if ACTION_EDIT in subset:
|
|
91
|
+
assert resource.share_roles[ACTION_EDIT] == (ACTION_VIEW, ACTION_EDIT)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
async def custom_env(db_session, request):
|
|
96
|
+
await create_note_table(await db_session.connection())
|
|
97
|
+
note = SharingTestNote(owner_id=uuid.UUID(OWNER), title="custom")
|
|
98
|
+
db_session.add(note)
|
|
99
|
+
await db_session.flush()
|
|
100
|
+
resource = declaration(share_roles=getattr(request, "param", ROLES))
|
|
101
|
+
registry = ResourceTypeRegistry()
|
|
102
|
+
registry.register(resource)
|
|
103
|
+
service = SharingService(db_session, registry, FakeDirectory(OWNER, MANAGER, READER, OTHER))
|
|
104
|
+
return service, note, resource
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def grant_data(note, subject=MANAGER, role="manager"):
|
|
108
|
+
return ShareGrant(resource_type=TYPE, resource_id=str(note.id), subject_id=subject, role=role)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def visible(service, resource, user, action):
|
|
112
|
+
stmt = resource.policy.filter(ResourcePrincipal(user), action, select(SharingTestNote))
|
|
113
|
+
return [row.title for row in (await service.db.execute(stmt)).scalars()]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.parametrize(
|
|
117
|
+
("role", "action", "allowed"),
|
|
118
|
+
[
|
|
119
|
+
("validator", "validate", True),
|
|
120
|
+
("validator", ACTION_EDIT, False),
|
|
121
|
+
("reviewer", "review", True),
|
|
122
|
+
("reviewer", "validate", False),
|
|
123
|
+
("manager", ACTION_SHARE, True),
|
|
124
|
+
("manager", ACTION_EDIT, False),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
async def test_registered_roles_and_sql_agree(custom_env, role, action, allowed):
|
|
128
|
+
service, note, resource = custom_env
|
|
129
|
+
with tenant_context("t1"):
|
|
130
|
+
await service.grant(ResourcePrincipal(OWNER), grant_data(note, role=role))
|
|
131
|
+
assert await visible(service, resource, MANAGER, ACTION_VIEW) == ["custom"]
|
|
132
|
+
assert await visible(service, resource, MANAGER, action) == (["custom"] if allowed else [])
|
|
133
|
+
assert await visible(service, resource, OTHER, ACTION_VIEW) == []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def test_manager_lists_grants_any_role_and_revokes(custom_env):
|
|
137
|
+
service, note, _ = custom_env
|
|
138
|
+
with tenant_context("t1"):
|
|
139
|
+
manager = await service.grant(ResourcePrincipal(OWNER), grant_data(note))
|
|
140
|
+
who = ResourcePrincipal(MANAGER)
|
|
141
|
+
assert [s.id for s in await service.for_resource(who, TYPE, str(note.id))] == [manager.id]
|
|
142
|
+
for role in ROLES:
|
|
143
|
+
granted = await service.grant(who, grant_data(note, READER, role))
|
|
144
|
+
assert granted.role == role
|
|
145
|
+
await service.revoke(who, granted.id)
|
|
146
|
+
assert [s.id for s in await service.for_resource(who, TYPE, str(note.id))] == [manager.id]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("operation", ["grant", "list", "revoke"])
|
|
150
|
+
async def test_non_manager_cannot_manage_other_shares(custom_env, operation):
|
|
151
|
+
service, note, _ = custom_env
|
|
152
|
+
with tenant_context("t1"):
|
|
153
|
+
await service.grant(ResourcePrincipal(OWNER), grant_data(note, role="reviewer"))
|
|
154
|
+
target = await service.grant(
|
|
155
|
+
ResourcePrincipal(OWNER), grant_data(note, READER, "validator")
|
|
156
|
+
)
|
|
157
|
+
who = ResourcePrincipal(MANAGER)
|
|
158
|
+
with pytest.raises(SharingError) as exc:
|
|
159
|
+
if operation == "grant":
|
|
160
|
+
await service.grant(who, grant_data(note, OTHER))
|
|
161
|
+
elif operation == "list":
|
|
162
|
+
await service.for_resource(who, TYPE, str(note.id))
|
|
163
|
+
else:
|
|
164
|
+
await service.revoke(who, target.id)
|
|
165
|
+
assert exc.value.status_code == 403
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.parametrize("role", [ACTION_VIEW, ACTION_EDIT, "validate", "unknown"])
|
|
169
|
+
async def test_grant_accepts_only_declared_role_keys(custom_env, role):
|
|
170
|
+
service, note, _ = custom_env
|
|
171
|
+
with tenant_context("t1"), pytest.raises(SharingError) as exc:
|
|
172
|
+
await service.grant(ResourcePrincipal(OWNER), grant_data(note, role=role))
|
|
173
|
+
assert exc.value.status_code == 422
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_custom_roles_expire_and_remain_tenant_scoped(custom_env):
|
|
177
|
+
service, note, resource = custom_env
|
|
178
|
+
who = ResourcePrincipal(MANAGER)
|
|
179
|
+
with tenant_context("t1"):
|
|
180
|
+
share = await service.grant(ResourcePrincipal(OWNER), grant_data(note))
|
|
181
|
+
with tenant_context("t2"), pytest.raises(SharingError) as exc:
|
|
182
|
+
await service.for_resource(who, TYPE, str(note.id))
|
|
183
|
+
assert exc.value.status_code == 404
|
|
184
|
+
with tenant_context("t1"):
|
|
185
|
+
share.expires_at = utcnow() - timedelta(seconds=1)
|
|
186
|
+
await service.db.flush()
|
|
187
|
+
assert await visible(service, resource, MANAGER, ACTION_VIEW) == []
|
|
188
|
+
with pytest.raises(SharingError) as exc:
|
|
189
|
+
await service.for_resource(who, TYPE, str(note.id))
|
|
190
|
+
assert exc.value.status_code == 404
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.parametrize("custom_env", [{}], indirect=True)
|
|
194
|
+
async def test_empty_mapping_refuses_default_grants(custom_env):
|
|
195
|
+
service, note, _ = custom_env
|
|
196
|
+
with tenant_context("t1"), pytest.raises(SharingError) as exc:
|
|
197
|
+
await service.grant(ResourcePrincipal(OWNER), grant_data(note, role=ACTION_VIEW))
|
|
198
|
+
assert exc.value.status_code == 422
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.mark.parametrize("subset", [(ACTION_VIEW,), (ACTION_EDIT,), ()])
|
|
202
|
+
async def test_legacy_subset_still_controls_grants(custom_env, subset):
|
|
203
|
+
service, note, _ = custom_env
|
|
204
|
+
with pytest.warns(DeprecationWarning):
|
|
205
|
+
resource = declaration(shareable_actions=subset)
|
|
206
|
+
registry = ResourceTypeRegistry()
|
|
207
|
+
registry.register(resource)
|
|
208
|
+
service.registry = registry
|
|
209
|
+
with tenant_context("t1"):
|
|
210
|
+
for role in (ACTION_VIEW, ACTION_EDIT):
|
|
211
|
+
if role in subset:
|
|
212
|
+
share = await service.grant(ResourcePrincipal(OWNER), grant_data(note, role=role))
|
|
213
|
+
assert share.role == role
|
|
214
|
+
else:
|
|
215
|
+
with pytest.raises(SharingError) as exc:
|
|
216
|
+
await service.grant(ResourcePrincipal(OWNER), grant_data(note, role=role))
|
|
217
|
+
assert exc.value.status_code == 422
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_explicit_roles_take_precedence_over_legacy_subset():
|
|
221
|
+
with pytest.warns(DeprecationWarning):
|
|
222
|
+
resource = declaration(share_roles=ROLES, shareable_actions=(ACTION_VIEW,))
|
|
223
|
+
assert resource.share_roles == ROLES
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize("role", ["Manager", "x" * 33, "has-dash", "", "manager\n"])
|
|
227
|
+
def test_role_ids_must_fit_the_share_column(role):
|
|
228
|
+
# A role the 32-character ``role`` column can't store would only fail at grant time.
|
|
229
|
+
with pytest.raises(ResourceTypeError, match="lowercase identifier"):
|
|
230
|
+
ResourceTypeRegistry().register(declaration(share_roles={role: (ACTION_VIEW,)}))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_core_role_pattern_matches_share_column_width():
|
|
234
|
+
# ``simple_module_core.resources._ROLE_ID`` hard-codes the width (core can't import sharing).
|
|
235
|
+
assert ROLE_MAX == 32
|
|
@@ -6,7 +6,11 @@ import uuid
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
from _sharing_support import NOTE_TYPE, SharingTestNote, create_note_table, register_note_type
|
|
9
|
+
from sharing.constants import PERM_SHARE
|
|
10
|
+
from sharing.policy import share_exists_for
|
|
11
|
+
from simple_module_core.resources import ACTION_SHARE, ACTION_VIEW, ResourceType
|
|
9
12
|
from simple_module_core.tenancy import TenantRole
|
|
13
|
+
from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id
|
|
10
14
|
from simple_module_test.tenant_client import session_client
|
|
11
15
|
|
|
12
16
|
SHARES = "/api/sharing/shares"
|
|
@@ -101,3 +105,47 @@ async def test_admin_page_lists_tenant_shares(note_app, tenant_client):
|
|
|
101
105
|
assert revoked.status_code == 303
|
|
102
106
|
# A plain tenant owner holds sharing.share but not the admin page.
|
|
103
107
|
assert (await owner.client.get("/admin/sharing/")).status_code == 403
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_custom_manager_uses_api_but_still_needs_permission(app, tenant_client, monkeypatch):
|
|
111
|
+
roles = {"manager": (ACTION_VIEW, ACTION_SHARE), "reviewer": (ACTION_VIEW, "review")}
|
|
112
|
+
policy = OwnedResourcePolicy(
|
|
113
|
+
SharingTestNote.owner_id,
|
|
114
|
+
bypass_perm=None,
|
|
115
|
+
share_exists=share_exists_for(NOTE_TYPE, SharingTestNote.id, roles=roles),
|
|
116
|
+
)
|
|
117
|
+
app.state.sm.resource_types.register(
|
|
118
|
+
ResourceType(NOTE_TYPE, policy, select_by_id(SharingTestNote), share_roles=roles)
|
|
119
|
+
)
|
|
120
|
+
async with app.state.sm.db.engine.begin() as conn:
|
|
121
|
+
await create_note_table(conn)
|
|
122
|
+
async with (
|
|
123
|
+
tenant_client() as owner,
|
|
124
|
+
tenant_client(tenant_id=owner.tenant_id) as manager,
|
|
125
|
+
tenant_client(tenant_id=owner.tenant_id) as reader,
|
|
126
|
+
):
|
|
127
|
+
note_id = await _note(app, owner.user_id)
|
|
128
|
+
response = await owner.client.post(
|
|
129
|
+
SHARES, json=_body(note_id, manager.user_id, role="manager")
|
|
130
|
+
)
|
|
131
|
+
assert response.status_code == 201, response.text
|
|
132
|
+
query = {"resource_type": NOTE_TYPE, "resource_id": note_id}
|
|
133
|
+
assert (await manager.client.get(SHARES, params=query)).status_code == 200
|
|
134
|
+
response = await manager.client.post(
|
|
135
|
+
SHARES, json=_body(note_id, reader.user_id, role="reviewer")
|
|
136
|
+
)
|
|
137
|
+
assert response.status_code == 201, response.text
|
|
138
|
+
share_id = response.json()["id"]
|
|
139
|
+
assert (await manager.client.delete(f"{SHARES}/{share_id}")).status_code == 204
|
|
140
|
+
# Removing the API permission must refuse even an active resource manager.
|
|
141
|
+
registry = app.state.sm.permissions
|
|
142
|
+
denied_map = {
|
|
143
|
+
role: [key for key in keys if key != PERM_SHARE]
|
|
144
|
+
for role, keys in registry.role_map.items()
|
|
145
|
+
}
|
|
146
|
+
monkeypatch.setattr(type(registry), "role_map", property(lambda self: denied_map))
|
|
147
|
+
assert (await manager.client.get(SHARES, params=query)).status_code == 403
|
|
148
|
+
assert (
|
|
149
|
+
await manager.client.post(SHARES, json=_body(note_id, reader.user_id, role="manager"))
|
|
150
|
+
).status_code == 403
|
|
151
|
+
assert (await manager.client.delete(f"{SHARES}/{share_id}")).status_code == 403
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Resource owners can remove shares transactionally in the bound tenant (#683)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sharing import contracts
|
|
5
|
+
from sharing.models import ResourceShare
|
|
6
|
+
from simple_module_db import TenantIsolationError, all_tenants, tenant_context
|
|
7
|
+
from sqlmodel import select
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _seed(db):
|
|
11
|
+
for tenant in ("acme", "other"):
|
|
12
|
+
with tenant_context(tenant):
|
|
13
|
+
for kind, resource in (
|
|
14
|
+
("test.note", "note"),
|
|
15
|
+
("test.note", "next"),
|
|
16
|
+
("test.other", "note"),
|
|
17
|
+
):
|
|
18
|
+
for subject in ("reader", "editor"):
|
|
19
|
+
db.add(
|
|
20
|
+
ResourceShare(
|
|
21
|
+
resource_type=kind,
|
|
22
|
+
resource_id=resource,
|
|
23
|
+
subject_id=subject,
|
|
24
|
+
role="view",
|
|
25
|
+
granted_by="owner",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
await db.flush()
|
|
29
|
+
await db.commit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("resource_ids", ["note", ["note", "next"], []])
|
|
33
|
+
async def test_forget_resource_is_scoped_idempotent_and_transactional(db_session, resource_ids):
|
|
34
|
+
forget = contracts.forget_resource
|
|
35
|
+
await _seed(db_session)
|
|
36
|
+
ids = resource_ids if isinstance(resource_ids, str) else list(resource_ids)
|
|
37
|
+
count = 2 if ids == "note" else 2 * len(ids)
|
|
38
|
+
with tenant_context("acme"):
|
|
39
|
+
await forget(db_session, "test.note", ids)
|
|
40
|
+
with all_tenants():
|
|
41
|
+
assert len((await db_session.execute(select(ResourceShare))).scalars().all()) == 12 - count
|
|
42
|
+
await db_session.rollback()
|
|
43
|
+
with all_tenants():
|
|
44
|
+
assert len((await db_session.execute(select(ResourceShare))).scalars().all()) == 12
|
|
45
|
+
with tenant_context("acme"):
|
|
46
|
+
await forget(db_session, "test.note", ids)
|
|
47
|
+
await forget(db_session, "test.note", ids)
|
|
48
|
+
await db_session.commit()
|
|
49
|
+
with all_tenants():
|
|
50
|
+
rows = (await db_session.execute(select(ResourceShare))).scalars().all()
|
|
51
|
+
assert len(rows) == 12 - count
|
|
52
|
+
assert sum(row.tenant_id == "other" for row in rows) == 6
|
|
53
|
+
assert sum(row.resource_type == "test.other" for row in rows) == 4
|
|
54
|
+
removed = {ids} if isinstance(ids, str) else set(ids)
|
|
55
|
+
assert not any(
|
|
56
|
+
row.tenant_id == "acme" and row.resource_type == "test.note" and row.resource_id in removed
|
|
57
|
+
for row in rows
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_forget_resource_fails_closed_without_tenant(db_state, db_session):
|
|
62
|
+
db_state.tenant_strict = True
|
|
63
|
+
with pytest.raises(TenantIsolationError):
|
|
64
|
+
await contracts.forget_resource(db_session, "test.note", "note")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_forget_resource_accepts_one_shot_iterables(db_session):
|
|
68
|
+
await _seed(db_session)
|
|
69
|
+
with tenant_context("acme"):
|
|
70
|
+
await contracts.forget_resource(db_session, "test.note", iter(["note", "next"]))
|
|
71
|
+
rows = (await db_session.execute(select(ResourceShare))).scalars().all()
|
|
72
|
+
assert [(row.resource_type, row.resource_id) for row in rows] == [
|
|
73
|
+
("test.other", "note"),
|
|
74
|
+
("test.other", "note"),
|
|
75
|
+
]
|
{simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin.test.tsx
RENAMED
|
@@ -40,6 +40,11 @@ const props: ComponentProps<typeof Admin> = {
|
|
|
40
40
|
afterEach(() => vi.clearAllMocks());
|
|
41
41
|
|
|
42
42
|
describe('sharing Admin', () => {
|
|
43
|
+
it('shows custom role ids unchanged', () => {
|
|
44
|
+
render(<Admin {...props} shares={[{ ...props.shares[0], role: 'collection_manager' }]} />);
|
|
45
|
+
expect(screen.getByText('collection_manager')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
43
48
|
it('renders labels and hides revoke from read-only viewers', () => {
|
|
44
49
|
render(<Admin {...props} can_revoke={false} />);
|
|
45
50
|
expect(screen.getByText('Friend')).toBeInTheDocument();
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_audience.py
RENAMED
|
File without changes
|
{simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_cleanup.py
RENAMED
|
File without changes
|
{simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin_errors.test.tsx
RENAMED
|
File without changes
|
|
File without changes
|