simple-module-sharing 0.0.39__tar.gz → 0.0.40__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 (38) hide show
  1. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/PKG-INFO +5 -5
  2. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/package.json +1 -1
  3. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/pyproject.toml +5 -5
  4. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/module.py +6 -0
  5. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/service.py +14 -7
  6. simple_module_sharing-0.0.40/sharing/user_cleanup.py +25 -0
  7. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests/test_sharing_service.py +21 -0
  8. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/.gitignore +0 -0
  9. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/LICENSE +0 -0
  10. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/README.md +0 -0
  11. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/__init__.py +0 -0
  12. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/audience.py +0 -0
  13. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/constants.py +0 -0
  14. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/contracts/__init__.py +0 -0
  15. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/contracts/schemas.py +0 -0
  16. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/deps.py +0 -0
  17. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/endpoints/__init__.py +0 -0
  18. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/endpoints/api.py +0 -0
  19. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/endpoints/views.py +0 -0
  20. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/locales/de.json +0 -0
  21. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/locales/en.json +0 -0
  22. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/models.py +0 -0
  23. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/pages/Admin.tsx +0 -0
  24. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/pages/components/ShareTable.tsx +0 -0
  25. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/pages/types.ts +0 -0
  26. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/policy.py +0 -0
  27. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/present.py +0 -0
  28. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/py.typed +0 -0
  29. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/services.py +0 -0
  30. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/settings.py +0 -0
  31. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/sharing/tenant_cleanup.py +0 -0
  32. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests/_sharing_support.py +0 -0
  33. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests/test_sharing_http.py +0 -0
  34. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests/test_sharing_tenant_audience.py +0 -0
  35. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests/test_sharing_tenant_cleanup.py +0 -0
  36. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests-js/sharing_Admin.test.tsx +0 -0
  37. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tests-js/sharing_Admin_errors.test.tsx +0 -0
  38. {simple_module_sharing-0.0.39 → simple_module_sharing-0.0.40}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_sharing
3
- Version: 0.0.39
3
+ Version: 0.0.40
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.39
15
- Requires-Dist: simple-module-db==0.0.39
16
- Requires-Dist: simple-module-hosting==0.0.39
17
- Requires-Dist: simple-module-inertia==0.0.39
14
+ Requires-Dist: simple-module-core==0.0.40
15
+ Requires-Dist: simple-module-db==0.0.40
16
+ Requires-Dist: simple-module-hosting==0.0.40
17
+ Requires-Dist: simple-module-inertia==0.0.40
18
18
  Description-Content-Type: text/markdown
19
19
 
20
20
  # simple_module_sharing
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/sharing",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Sharing module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_sharing"
3
- version = "0.0.39"
3
+ version = "0.0.40"
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.39",
13
- "simple_module_db==0.0.39",
14
- "simple_module_hosting==0.0.39",
15
- "simple_module_inertia==0.0.39",
12
+ "simple_module_core==0.0.40",
13
+ "simple_module_db==0.0.40",
14
+ "simple_module_hosting==0.0.40",
15
+ "simple_module_inertia==0.0.40",
16
16
  ]
17
17
 
18
18
  [project.entry-points.simple_module]
@@ -35,6 +35,12 @@ class SharingModule(ModuleBase):
35
35
 
36
36
  bus.subscribe_durable(TenantDeleted, purge_tenant_data)
37
37
 
38
+ from simple_module_core.user_events import UserDeleted
39
+
40
+ from sharing.user_cleanup import purge_user_shares
41
+
42
+ bus.subscribe_durable(UserDeleted, purge_user_shares)
43
+
38
44
  def register_settings(self, app: FastAPI) -> None:
39
45
  from sharing.services import SharingServices
40
46
  from sharing.settings import SharingSettings
@@ -9,9 +9,10 @@ grantee is someone the user directory knows. Rows are tenant-scoped by
9
9
  from __future__ import annotations
10
10
 
11
11
  import uuid
12
- from datetime import UTC, datetime
13
12
  from typing import Any
14
13
 
14
+ from simple_module_core.datetimes import aware as _aware
15
+ from simple_module_core.datetimes import utcnow
15
16
  from simple_module_core.resources import (
16
17
  ACTION_SHARE,
17
18
  ResourcePrincipal,
@@ -20,6 +21,7 @@ from simple_module_core.resources import (
20
21
  )
21
22
  from simple_module_core.user_directory import UserDirectory
22
23
  from simple_module_db.resource_policy import load_authorized
24
+ from sqlalchemy.exc import IntegrityError
23
25
  from sqlalchemy.ext.asyncio import AsyncSession
24
26
  from sqlmodel import select
25
27
 
@@ -49,10 +51,6 @@ class SharingError(Exception):
49
51
  self.detail = detail
50
52
 
51
53
 
52
- def _aware(value: datetime) -> datetime:
53
- return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
54
-
55
-
56
54
  class SharingService:
57
55
  def __init__(
58
56
  self,
@@ -93,7 +91,7 @@ class SharingService:
93
91
  resource_type = self._type(data.resource_type)
94
92
  if data.role not in resource_type.shareable_actions:
95
93
  raise SharingError(422, ERR_BAD_ROLE)
96
- if data.expires_at is not None and _aware(data.expires_at) <= datetime.now(UTC):
94
+ if data.expires_at is not None and _aware(data.expires_at) <= utcnow():
97
95
  raise SharingError(422, ERR_EXPIRED)
98
96
  row = await self._authorize(principal, resource_type, data.resource_id)
99
97
  grantee = (await self.directory.get_many(self.db, [data.subject_id])).get(data.subject_id)
@@ -112,8 +110,17 @@ class SharingService:
112
110
  subject_id=grantee.id,
113
111
  role=data.role,
114
112
  granted_by=principal.user_id or "",
113
+ expires_at=data.expires_at,
115
114
  )
116
- self.db.add(share)
115
+ try:
116
+ async with self.db.begin_nested():
117
+ self.db.add(share)
118
+ await self.db.flush()
119
+ except IntegrityError:
120
+ # Another grant may have won the unique-key race.
121
+ share = await self._existing(data.resource_type, resource_id, grantee.id)
122
+ if share is None:
123
+ raise
117
124
  share.role = data.role
118
125
  share.expires_at = data.expires_at
119
126
  share.granted_by = principal.user_id or ""
@@ -0,0 +1,25 @@
1
+ """Revoke shares addressed to a deleted principal across tenants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from simple_module_core.durable_events import DurableContext
8
+ from simple_module_db import all_tenants
9
+ from sqlmodel import col, delete
10
+
11
+ from sharing.constants import SUBJECT_USER
12
+ from sharing.models import ResourceShare
13
+
14
+ if TYPE_CHECKING:
15
+ from simple_module_core.user_events import UserDeleted
16
+
17
+
18
+ async def purge_user_shares(event: UserDeleted, ctx: DurableContext) -> None:
19
+ with all_tenants():
20
+ await ctx.db.execute(
21
+ delete(ResourceShare).where(
22
+ col(ResourceShare.subject_type) == SUBJECT_USER,
23
+ col(ResourceShare.subject_id) == str(event.user_id),
24
+ )
25
+ )
@@ -204,3 +204,24 @@ async def test_expired_share_can_be_renewed_without_duplicate_grants(env):
204
204
  assert renewed.id == share.id
205
205
  assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]
206
206
  assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
207
+
208
+
209
+ async def test_duplicate_insert_race_reuses_winner_and_preserves_transaction(env, monkeypatch):
210
+ service, note_id = env
211
+ with tenant_context("t1"):
212
+ winner = await service.grant(_who(OWNER), _grant(note_id))
213
+ original = service._existing
214
+ calls = 0
215
+
216
+ async def miss_once(*args):
217
+ nonlocal calls
218
+ calls += 1
219
+ return None if calls == 1 else await original(*args)
220
+
221
+ # Simulate a concurrent winner becoming visible after our initial lookup.
222
+ monkeypatch.setattr(service, "_existing", miss_once)
223
+ result = await service.grant(_who(OWNER), _grant(note_id, role=ACTION_EDIT))
224
+ assert result.id == winner.id
225
+ assert result.role == ACTION_EDIT
226
+ assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
227
+ assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]