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.
Files changed (41) hide show
  1. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/PKG-INFO +5 -5
  2. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/package.json +4 -4
  3. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/pyproject.toml +5 -5
  4. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/contracts/__init__.py +2 -1
  5. simple_module_sharing-0.0.42/sharing/contracts/cleanup.py +25 -0
  6. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/policy.py +18 -8
  7. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/service.py +2 -2
  8. simple_module_sharing-0.0.42/tests/test_sharing_custom_roles.py +235 -0
  9. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_http.py +48 -0
  10. simple_module_sharing-0.0.42/tests/test_sharing_resource_cleanup.py +75 -0
  11. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin.test.tsx +5 -0
  12. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/.gitignore +0 -0
  13. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/LICENSE +0 -0
  14. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/README.md +0 -0
  15. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/__init__.py +0 -0
  16. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/audience.py +0 -0
  17. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/constants.py +0 -0
  18. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/contracts/schemas.py +0 -0
  19. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/deps.py +0 -0
  20. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/__init__.py +0 -0
  21. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/api.py +0 -0
  22. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/endpoints/views.py +0 -0
  23. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/locales/de.json +0 -0
  24. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/locales/en.json +0 -0
  25. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/models.py +0 -0
  26. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/module.py +0 -0
  27. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/Admin.tsx +0 -0
  28. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/components/ShareTable.tsx +0 -0
  29. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/pages/types.ts +0 -0
  30. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/present.py +0 -0
  31. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/py.typed +0 -0
  32. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/services.py +0 -0
  33. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/settings.py +0 -0
  34. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/tenant_cleanup.py +0 -0
  35. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/sharing/user_cleanup.py +0 -0
  36. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/_sharing_support.py +0 -0
  37. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_service.py +0 -0
  38. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_audience.py +0 -0
  39. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests/test_sharing_tenant_cleanup.py +0 -0
  40. {simple_module_sharing-0.0.41 → simple_module_sharing-0.0.42}/tests-js/sharing_Admin_errors.test.tsx +0 -0
  41. {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.41
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.41
15
- Requires-Dist: simple-module-db==0.0.41
16
- Requires-Dist: simple-module-hosting==0.0.41
17
- Requires-Dist: simple-module-inertia==0.0.41
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.41",
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.0.0",
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.41"
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.41",
13
- "simple_module_db==0.0.41",
14
- "simple_module_hosting==0.0.41",
15
- "simple_module_inertia==0.0.41",
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 roles_granting(action: str) -> tuple[str, ...]:
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 ROLE_IMPLIES.items() if action in implied)
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
- roles = roles_granting(action)
86
- if not roles or principal.user_id is None:
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_(roles),
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.shareable_actions:
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
+ ]
@@ -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();