simple-module-sharing 0.0.38__py3-none-any.whl
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.
- sharing/__init__.py +0 -0
- sharing/audience.py +44 -0
- sharing/constants.py +65 -0
- sharing/contracts/__init__.py +5 -0
- sharing/contracts/schemas.py +36 -0
- sharing/deps.py +31 -0
- sharing/endpoints/__init__.py +0 -0
- sharing/endpoints/api.py +69 -0
- sharing/endpoints/views.py +75 -0
- sharing/locales/de.json +33 -0
- sharing/locales/en.json +33 -0
- sharing/models.py +51 -0
- sharing/module.py +86 -0
- sharing/package.json +18 -0
- sharing/pages/Admin.tsx +100 -0
- sharing/pages/components/ShareTable.tsx +86 -0
- sharing/pages/types.ts +14 -0
- sharing/policy.py +109 -0
- sharing/present.py +49 -0
- sharing/py.typed +0 -0
- sharing/service.py +165 -0
- sharing/services.py +22 -0
- sharing/settings.py +17 -0
- simple_module_sharing-0.0.38.dist-info/METADATA +73 -0
- simple_module_sharing-0.0.38.dist-info/RECORD +28 -0
- simple_module_sharing-0.0.38.dist-info/WHEEL +4 -0
- simple_module_sharing-0.0.38.dist-info/entry_points.txt +2 -0
- simple_module_sharing-0.0.38.dist-info/licenses/LICENSE +21 -0
sharing/__init__.py
ADDED
|
File without changes
|
sharing/audience.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Which user ids a request may act on or name: the active tenant's members.
|
|
2
|
+
|
|
3
|
+
On a multi-tenant app a grantee, and any user label shown, must belong to the
|
|
4
|
+
active tenant; otherwise ``sharing`` would let a caller share with (and probe
|
|
5
|
+
for) accounts in other tenants and read their directory labels. Membership is
|
|
6
|
+
asked through ``simple_module_core.tenancy.tenant_member_ids`` (the
|
|
7
|
+
``tenants`` module publishes it), so this module never imports ``tenants``.
|
|
8
|
+
With no active tenant, or no membership store installed, nobody is in the
|
|
9
|
+
audience (fail closed). A single-tenant app has no such boundary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Collection
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from simple_module_core.tenancy import TenancyMode, tenant_member_ids
|
|
18
|
+
from simple_module_db.tenancy import current_tenant_id
|
|
19
|
+
from simple_module_hosting.tenancy import tenancy_mode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Audience:
|
|
23
|
+
"""Filters user ids down to those the current request may see."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, app: Any, db: Any) -> None:
|
|
26
|
+
self.app = app
|
|
27
|
+
self.db = db
|
|
28
|
+
|
|
29
|
+
async def visible(self, user_ids: Collection[str]) -> set[str]:
|
|
30
|
+
wanted = {uid for uid in user_ids if uid}
|
|
31
|
+
if not wanted or tenancy_mode(self.app) is not TenancyMode.MULTI:
|
|
32
|
+
return wanted
|
|
33
|
+
tenant_id = current_tenant_id.get()
|
|
34
|
+
if tenant_id is None:
|
|
35
|
+
return set()
|
|
36
|
+
members = await tenant_member_ids(self.app, self.db, tenant_id, wanted)
|
|
37
|
+
return members or set()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenAudience:
|
|
41
|
+
"""Everyone is visible (single-tenant use, or tests without an app)."""
|
|
42
|
+
|
|
43
|
+
async def visible(self, user_ids: Collection[str]) -> set[str]:
|
|
44
|
+
return {uid for uid in user_ids if uid}
|
sharing/constants.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Stable identifiers for the sharing module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from simple_module_core.resources import ACTION_EDIT, ACTION_VIEW
|
|
8
|
+
|
|
9
|
+
MODULE_NAME: Final = "Sharing"
|
|
10
|
+
MODULE_PACKAGE: Final = "sharing"
|
|
11
|
+
LOCALE_NAMESPACE: Final = "sharing"
|
|
12
|
+
|
|
13
|
+
PERM_GROUP: Final = "Sharing"
|
|
14
|
+
PERM_SHARE: Final = "sharing.share"
|
|
15
|
+
"""Use the grant/revoke API at all; the resource's own policy still decides."""
|
|
16
|
+
PERM_VIEW: Final = "sharing.view"
|
|
17
|
+
"""See every share in the tenant (the admin page)."""
|
|
18
|
+
PERM_MANAGE: Final = "sharing.manage"
|
|
19
|
+
"""Revoke any share in the tenant."""
|
|
20
|
+
USER_ROLE: Final = "user"
|
|
21
|
+
|
|
22
|
+
PAGE_ADMIN: Final = "Sharing/Admin"
|
|
23
|
+
|
|
24
|
+
API_PREFIX: Final = "/api/sharing"
|
|
25
|
+
VIEW_PREFIX: Final = "/admin/sharing"
|
|
26
|
+
# Trailing slash: the index route is registered at "/" under the prefix.
|
|
27
|
+
ADMIN_URL: Final = f"{VIEW_PREFIX}/"
|
|
28
|
+
AUDIT_URL_TEMPLATE: Final = f"{VIEW_PREFIX}/?share={{id}}"
|
|
29
|
+
|
|
30
|
+
MENU_LABEL: Final = "Sharing"
|
|
31
|
+
MENU_LABEL_KEY: Final = "sharing.nav.title"
|
|
32
|
+
MENU_ICON: Final = "share"
|
|
33
|
+
MENU_ORDER: Final = 60
|
|
34
|
+
MENU_GROUP: Final = "Access"
|
|
35
|
+
MENU_GROUP_KEY: Final = "ui.nav_groups.access"
|
|
36
|
+
AUDIT_LABEL: Final = "Share"
|
|
37
|
+
AUDIT_LABEL_KEY: Final = "sharing.audit.share"
|
|
38
|
+
|
|
39
|
+
# Prefix is load-bearing: all modules share one schema.
|
|
40
|
+
TABLE_SHARE: Final = "sharing_resource_share"
|
|
41
|
+
UQ_SHARE: Final = "uq_sharing_resource_share_subject"
|
|
42
|
+
IX_SHARE_RESOURCE: Final = "ix_sharing_resource_share_resource"
|
|
43
|
+
|
|
44
|
+
RESOURCE_TYPE_MAX: Final = 100
|
|
45
|
+
RESOURCE_ID_MAX: Final = 100
|
|
46
|
+
SUBJECT_TYPE_MAX: Final = 16
|
|
47
|
+
SUBJECT_ID_MAX: Final = 255
|
|
48
|
+
ROLE_MAX: Final = 32
|
|
49
|
+
|
|
50
|
+
SUBJECT_USER: Final = "user"
|
|
51
|
+
"""The only subject kind so far; the column leaves room for groups or tenants."""
|
|
52
|
+
|
|
53
|
+
# A role is the action it grants plus everything it implies.
|
|
54
|
+
ROLE_IMPLIES: Final[dict[str, tuple[str, ...]]] = {
|
|
55
|
+
ACTION_VIEW: (ACTION_VIEW,),
|
|
56
|
+
ACTION_EDIT: (ACTION_VIEW, ACTION_EDIT),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ERR_UNKNOWN_TYPE: Final = "Unknown resource type"
|
|
60
|
+
ERR_NOT_FOUND: Final = "Not found"
|
|
61
|
+
ERR_FORBIDDEN: Final = "You may not share this resource"
|
|
62
|
+
ERR_UNKNOWN_GRANTEE: Final = "Unknown user"
|
|
63
|
+
ERR_BAD_ROLE: Final = "This role cannot be granted on this resource type"
|
|
64
|
+
ERR_EXPIRED: Final = "expires_at must be in the future"
|
|
65
|
+
ERR_SELF: Final = "You cannot share a resource with yourself"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sharing DTOs — the module's public surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from simple_module_core.resources import ACTION_VIEW
|
|
9
|
+
from sqlmodel import Field, SQLModel
|
|
10
|
+
|
|
11
|
+
from sharing.constants import RESOURCE_ID_MAX, RESOURCE_TYPE_MAX, ROLE_MAX, SUBJECT_ID_MAX
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ShareGrant(SQLModel):
|
|
15
|
+
"""Grant (or update) one user's access to one resource."""
|
|
16
|
+
|
|
17
|
+
resource_type: str = Field(min_length=1, max_length=RESOURCE_TYPE_MAX)
|
|
18
|
+
resource_id: str = Field(min_length=1, max_length=RESOURCE_ID_MAX)
|
|
19
|
+
subject_id: str = Field(min_length=1, max_length=SUBJECT_ID_MAX)
|
|
20
|
+
role: str = Field(default=ACTION_VIEW, max_length=ROLE_MAX)
|
|
21
|
+
expires_at: datetime | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShareOut(SQLModel):
|
|
25
|
+
id: uuid.UUID
|
|
26
|
+
resource_type: str
|
|
27
|
+
resource_id: str
|
|
28
|
+
subject_type: str
|
|
29
|
+
subject_id: str
|
|
30
|
+
subject_label: str | None = None
|
|
31
|
+
role: str
|
|
32
|
+
granted_by: str
|
|
33
|
+
granted_by_label: str | None = None
|
|
34
|
+
expires_at: datetime | None = None
|
|
35
|
+
expired: bool = False
|
|
36
|
+
created_at: datetime | None = None
|
sharing/deps.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""FastAPI dependencies for the sharing module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request
|
|
8
|
+
from simple_module_core.user_directory import get_user_directory
|
|
9
|
+
from simple_module_db.deps import get_db
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from sharing.audience import Audience
|
|
13
|
+
from sharing.service import SharingError, SharingService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_sharing_service(
|
|
17
|
+
request: Request, db: AsyncSession = Depends(get_db)
|
|
18
|
+
) -> SharingService:
|
|
19
|
+
return SharingService(
|
|
20
|
+
db,
|
|
21
|
+
request.app.state.sm.resource_types,
|
|
22
|
+
get_user_directory(request.app),
|
|
23
|
+
Audience(request.app, db),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
SharingServiceDep = Annotated[SharingService, Depends(get_sharing_service)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def http_error(exc: SharingError) -> HTTPException:
|
|
31
|
+
return HTTPException(status_code=exc.status_code, detail=exc.detail)
|
|
File without changes
|
sharing/endpoints/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""REST API for shares — mounted under ``/api/sharing``.
|
|
2
|
+
|
|
3
|
+
Gated on ``sharing.share`` (so an API token scoped to it may call these);
|
|
4
|
+
the resource's own policy then decides per row.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Request
|
|
12
|
+
from simple_module_core.resources import ResourcePrincipal
|
|
13
|
+
from simple_module_core.user_directory import get_user_directory
|
|
14
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
15
|
+
from simple_module_hosting.resource_deps import resource_principal
|
|
16
|
+
|
|
17
|
+
from sharing.constants import PERM_SHARE
|
|
18
|
+
from sharing.contracts.schemas import ShareGrant, ShareOut
|
|
19
|
+
from sharing.deps import SharingServiceDep, http_error
|
|
20
|
+
from sharing.present import present_shares
|
|
21
|
+
from sharing.service import SharingError
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/shares", dependencies=[Depends(RequiresPermission(PERM_SHARE))])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("", response_model=list[ShareOut])
|
|
27
|
+
async def list_shares(
|
|
28
|
+
request: Request,
|
|
29
|
+
resource_type: str,
|
|
30
|
+
resource_id: str,
|
|
31
|
+
service: SharingServiceDep,
|
|
32
|
+
who: ResourcePrincipal = Depends(resource_principal),
|
|
33
|
+
) -> list[ShareOut]:
|
|
34
|
+
try:
|
|
35
|
+
shares = await service.for_resource(who, resource_type, resource_id)
|
|
36
|
+
except SharingError as exc:
|
|
37
|
+
raise http_error(exc) from None
|
|
38
|
+
return await present_shares(
|
|
39
|
+
get_user_directory(request.app), service.db, shares, service.audience
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("", response_model=ShareOut, status_code=201)
|
|
44
|
+
async def grant_share(
|
|
45
|
+
request: Request,
|
|
46
|
+
data: ShareGrant,
|
|
47
|
+
service: SharingServiceDep,
|
|
48
|
+
who: ResourcePrincipal = Depends(resource_principal),
|
|
49
|
+
) -> ShareOut:
|
|
50
|
+
try:
|
|
51
|
+
share = await service.grant(who, data)
|
|
52
|
+
except SharingError as exc:
|
|
53
|
+
raise http_error(exc) from None
|
|
54
|
+
(out,) = await present_shares(
|
|
55
|
+
get_user_directory(request.app), service.db, [share], service.audience
|
|
56
|
+
)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.delete("/{share_id}", status_code=204)
|
|
61
|
+
async def revoke_share(
|
|
62
|
+
share_id: uuid.UUID,
|
|
63
|
+
service: SharingServiceDep,
|
|
64
|
+
who: ResourcePrincipal = Depends(resource_principal),
|
|
65
|
+
) -> None:
|
|
66
|
+
try:
|
|
67
|
+
await service.revoke(who, share_id)
|
|
68
|
+
except SharingError as exc:
|
|
69
|
+
raise http_error(exc) from None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The admin page — mounted under ``/admin/sharing``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Request
|
|
8
|
+
from simple_module_core.resources import ResourcePrincipal
|
|
9
|
+
from simple_module_core.user_directory import get_user_directory
|
|
10
|
+
from simple_module_db import TenantIsolationError, paginate
|
|
11
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
12
|
+
from simple_module_hosting.pagination import LenientPageParamsDep
|
|
13
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
14
|
+
from simple_module_hosting.resource_deps import resource_principal
|
|
15
|
+
from simple_module_inertia import InertiaResponse
|
|
16
|
+
from sqlmodel import col, select
|
|
17
|
+
from starlette.responses import RedirectResponse
|
|
18
|
+
|
|
19
|
+
from sharing.constants import ADMIN_URL, PAGE_ADMIN, PERM_MANAGE, PERM_VIEW
|
|
20
|
+
from sharing.deps import SharingServiceDep, http_error
|
|
21
|
+
from sharing.models import ResourceShare
|
|
22
|
+
from sharing.present import present_shares
|
|
23
|
+
from sharing.service import SharingError
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/", response_model=None, dependencies=[Depends(RequiresPermission(PERM_VIEW))])
|
|
29
|
+
async def admin_index(
|
|
30
|
+
request: Request,
|
|
31
|
+
inertia: InertiaDep,
|
|
32
|
+
service: SharingServiceDep,
|
|
33
|
+
params: LenientPageParamsDep,
|
|
34
|
+
who: ResourcePrincipal = Depends(resource_principal),
|
|
35
|
+
) -> InertiaResponse:
|
|
36
|
+
stmt = select(ResourceShare).order_by(
|
|
37
|
+
col(ResourceShare.created_at).desc(), col(ResourceShare.id)
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
page = await paginate(service.db, stmt, params)
|
|
41
|
+
shares = await present_shares(
|
|
42
|
+
get_user_directory(request.app), service.db, page.items, service.audience
|
|
43
|
+
)
|
|
44
|
+
total, served, tenant_required = page.total, page.page, False
|
|
45
|
+
except TenantIsolationError:
|
|
46
|
+
# Strict tenancy with no active tenant: nothing to list.
|
|
47
|
+
shares, total, served, tenant_required = [], 0, 1, True
|
|
48
|
+
return await inertia.render(
|
|
49
|
+
PAGE_ADMIN,
|
|
50
|
+
{
|
|
51
|
+
"shares": [s.model_dump(mode="json") for s in shares],
|
|
52
|
+
"total": total,
|
|
53
|
+
"page": served,
|
|
54
|
+
"per_page": params.per_page,
|
|
55
|
+
"can_revoke": who.has(PERM_MANAGE),
|
|
56
|
+
"tenant_required": tenant_required,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post(
|
|
62
|
+
"/{share_id}/revoke",
|
|
63
|
+
response_model=None,
|
|
64
|
+
dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
|
|
65
|
+
)
|
|
66
|
+
async def admin_revoke(
|
|
67
|
+
share_id: uuid.UUID,
|
|
68
|
+
service: SharingServiceDep,
|
|
69
|
+
who: ResourcePrincipal = Depends(resource_principal),
|
|
70
|
+
) -> RedirectResponse:
|
|
71
|
+
try:
|
|
72
|
+
await service.revoke(who, share_id)
|
|
73
|
+
except SharingError as exc:
|
|
74
|
+
raise http_error(exc) from None
|
|
75
|
+
return RedirectResponse(ADMIN_URL, status_code=303)
|
sharing/locales/de.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"title": "Freigaben"
|
|
4
|
+
},
|
|
5
|
+
"permission_group": "Freigaben",
|
|
6
|
+
"audit": {
|
|
7
|
+
"share": "Freigabe"
|
|
8
|
+
},
|
|
9
|
+
"admin": {
|
|
10
|
+
"title": "Freigaben",
|
|
11
|
+
"description": "Wer in diesem Arbeitsbereich Zugriff auf welche Ressource erhalten hat.",
|
|
12
|
+
"tenant_required": "Wählen Sie einen Arbeitsbereich, um seine Freigaben zu sehen."
|
|
13
|
+
},
|
|
14
|
+
"list": {
|
|
15
|
+
"resource": "Ressource",
|
|
16
|
+
"subject": "Freigegeben für",
|
|
17
|
+
"role": "Rolle",
|
|
18
|
+
"granted_by": "Freigegeben von",
|
|
19
|
+
"expires": "Läuft ab",
|
|
20
|
+
"actions": "Aktionen",
|
|
21
|
+
"never": "Nie",
|
|
22
|
+
"expired": "Abgelaufen",
|
|
23
|
+
"revoke": "Widerrufen",
|
|
24
|
+
"empty_title": "Noch nichts freigegeben",
|
|
25
|
+
"empty_description": "Freigaben erscheinen hier, sobald jemand einer anderen Person Zugriff auf eine Ressource gibt."
|
|
26
|
+
},
|
|
27
|
+
"revoke": {
|
|
28
|
+
"title": "Diese Freigabe widerrufen?",
|
|
29
|
+
"description": "Die Person verliert sofort den Zugriff auf die Ressource.",
|
|
30
|
+
"confirm": "Widerrufen",
|
|
31
|
+
"cancel": "Abbrechen"
|
|
32
|
+
}
|
|
33
|
+
}
|
sharing/locales/en.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"title": "Sharing"
|
|
4
|
+
},
|
|
5
|
+
"permission_group": "Sharing",
|
|
6
|
+
"audit": {
|
|
7
|
+
"share": "Share"
|
|
8
|
+
},
|
|
9
|
+
"admin": {
|
|
10
|
+
"title": "Shares",
|
|
11
|
+
"description": "Who has been given access to which resource in this workspace.",
|
|
12
|
+
"tenant_required": "Select a workspace to see its shares."
|
|
13
|
+
},
|
|
14
|
+
"list": {
|
|
15
|
+
"resource": "Resource",
|
|
16
|
+
"subject": "Shared with",
|
|
17
|
+
"role": "Role",
|
|
18
|
+
"granted_by": "Granted by",
|
|
19
|
+
"expires": "Expires",
|
|
20
|
+
"actions": "Actions",
|
|
21
|
+
"never": "Never",
|
|
22
|
+
"expired": "Expired",
|
|
23
|
+
"revoke": "Revoke",
|
|
24
|
+
"empty_title": "Nothing shared yet",
|
|
25
|
+
"empty_description": "Shares appear here when someone gives another user access to a resource."
|
|
26
|
+
},
|
|
27
|
+
"revoke": {
|
|
28
|
+
"title": "Revoke this share?",
|
|
29
|
+
"description": "The user loses access to the resource immediately.",
|
|
30
|
+
"confirm": "Revoke",
|
|
31
|
+
"cancel": "Cancel"
|
|
32
|
+
}
|
|
33
|
+
}
|
sharing/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The generic share table (GH #526)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from simple_module_db.base import create_module_base
|
|
9
|
+
from simple_module_db.mixins import AuditMixin, MultiTenantMixin
|
|
10
|
+
from sqlalchemy import Index, UniqueConstraint
|
|
11
|
+
from sqlmodel import Field
|
|
12
|
+
|
|
13
|
+
from sharing.constants import (
|
|
14
|
+
IX_SHARE_RESOURCE,
|
|
15
|
+
RESOURCE_ID_MAX,
|
|
16
|
+
RESOURCE_TYPE_MAX,
|
|
17
|
+
ROLE_MAX,
|
|
18
|
+
SUBJECT_ID_MAX,
|
|
19
|
+
SUBJECT_TYPE_MAX,
|
|
20
|
+
SUBJECT_USER,
|
|
21
|
+
TABLE_SHARE,
|
|
22
|
+
UQ_SHARE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Base = create_module_base("sharing")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResourceShare(Base, MultiTenantMixin, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
29
|
+
"""One subject's access to one resource.
|
|
30
|
+
|
|
31
|
+
``resource_id`` is the canonical text of the row's key (a UUID's ``hex``,
|
|
32
|
+
an int's decimal), so the policy's ``EXISTS`` can compare it on any
|
|
33
|
+
database. Revoking deletes the row; expiry is checked in SQL.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__tablename__ = TABLE_SHARE
|
|
37
|
+
__table_args__ = (
|
|
38
|
+
UniqueConstraint(
|
|
39
|
+
"tenant_id", "resource_type", "resource_id", "subject_type", "subject_id", name=UQ_SHARE
|
|
40
|
+
),
|
|
41
|
+
Index(IX_SHARE_RESOURCE, "tenant_id", "resource_type", "resource_id"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
45
|
+
resource_type: str = Field(max_length=RESOURCE_TYPE_MAX)
|
|
46
|
+
resource_id: str = Field(max_length=RESOURCE_ID_MAX)
|
|
47
|
+
subject_type: str = Field(default=SUBJECT_USER, max_length=SUBJECT_TYPE_MAX)
|
|
48
|
+
subject_id: str = Field(max_length=SUBJECT_ID_MAX, index=True)
|
|
49
|
+
role: str = Field(max_length=ROLE_MAX)
|
|
50
|
+
granted_by: str = Field(max_length=SUBJECT_ID_MAX)
|
|
51
|
+
expires_at: datetime | None = Field(default=None)
|
sharing/module.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Sharing module: a generic share table for resource-scoped access (GH #526)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
10
|
+
from simple_module_core.audit_links import AuditLink
|
|
11
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
12
|
+
from simple_module_core.module_settings import register_module_settings
|
|
13
|
+
from simple_module_core.tenancy import TenantRole, tenant_role
|
|
14
|
+
|
|
15
|
+
from sharing import constants as c
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastapi import APIRouter, FastAPI
|
|
19
|
+
from simple_module_core.audit_links import AuditLinkRegistry
|
|
20
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SharingModule(ModuleBase):
|
|
24
|
+
meta = ModuleMeta(
|
|
25
|
+
name=c.MODULE_NAME,
|
|
26
|
+
route_prefix=c.API_PREFIX,
|
|
27
|
+
view_prefix=c.VIEW_PREFIX,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
31
|
+
from sharing.services import SharingServices
|
|
32
|
+
from sharing.settings import SharingSettings
|
|
33
|
+
|
|
34
|
+
register_module_settings(
|
|
35
|
+
app, c.MODULE_PACKAGE, SharingSettings, lambda s: SharingServices(settings=s)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
39
|
+
from sharing.endpoints.api import router as api
|
|
40
|
+
from sharing.endpoints.views import router as views
|
|
41
|
+
|
|
42
|
+
api_router.include_router(api)
|
|
43
|
+
view_router.include_router(views)
|
|
44
|
+
|
|
45
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
46
|
+
registry.add(
|
|
47
|
+
MenuItem(
|
|
48
|
+
label=c.MENU_LABEL,
|
|
49
|
+
label_key=c.MENU_LABEL_KEY,
|
|
50
|
+
url=c.ADMIN_URL,
|
|
51
|
+
icon=c.MENU_ICON,
|
|
52
|
+
order=c.MENU_ORDER,
|
|
53
|
+
section=MenuSection.ADMIN_SIDEBAR,
|
|
54
|
+
group=c.MENU_GROUP,
|
|
55
|
+
group_key=c.MENU_GROUP_KEY,
|
|
56
|
+
permissions=[c.PERM_VIEW],
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
61
|
+
registry.add_group(
|
|
62
|
+
c.PERM_GROUP,
|
|
63
|
+
[c.PERM_SHARE, c.PERM_VIEW, c.PERM_MANAGE],
|
|
64
|
+
label_key=f"{c.LOCALE_NAMESPACE}.permission_group",
|
|
65
|
+
)
|
|
66
|
+
# Sharing what you own is an ordinary user action; the resource's
|
|
67
|
+
# policy (``can("share")``) is what actually decides.
|
|
68
|
+
registry.map_role(c.USER_ROLE, [c.PERM_SHARE])
|
|
69
|
+
for role in TenantRole:
|
|
70
|
+
registry.map_role(tenant_role(role), [c.PERM_SHARE])
|
|
71
|
+
|
|
72
|
+
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
|
|
73
|
+
from sharing.models import ResourceShare
|
|
74
|
+
|
|
75
|
+
registry.register(
|
|
76
|
+
AuditLink.for_model(
|
|
77
|
+
ResourceShare,
|
|
78
|
+
url_template=c.AUDIT_URL_TEMPLATE,
|
|
79
|
+
label=c.AUDIT_LABEL,
|
|
80
|
+
label_key=c.AUDIT_LABEL_KEY,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
85
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
86
|
+
return {c.LOCALE_NAMESPACE: base}
|
sharing/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/sharing",
|
|
3
|
+
"version": "0.0.38",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Sharing module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^1.43.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
sharing/pages/Admin.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Head, router } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog';
|
|
4
|
+
import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
|
|
5
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
6
|
+
import { PaginationBar } from '@simple-module-py/ui/components/PaginationBar';
|
|
7
|
+
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
|
|
8
|
+
import { Building2, Trash2 } from 'lucide-react';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
import { ShareTable } from './components/ShareTable';
|
|
11
|
+
import type { Share } from './types';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
shares: Share[];
|
|
15
|
+
total: number;
|
|
16
|
+
page: number;
|
|
17
|
+
per_page: number;
|
|
18
|
+
can_revoke: boolean;
|
|
19
|
+
tenant_required: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BASE_URL = '/admin/sharing';
|
|
23
|
+
|
|
24
|
+
/** Every share in the active tenant; admins may revoke any of them. */
|
|
25
|
+
function Admin(props: Props) {
|
|
26
|
+
const { t } = useT();
|
|
27
|
+
const [pending, setPending] = useState<Share | null>(null);
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
// An audit-log link lands here as ?share=<id>; mark that row.
|
|
30
|
+
const highlightId =
|
|
31
|
+
typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('share');
|
|
32
|
+
|
|
33
|
+
function goTo(page: number) {
|
|
34
|
+
router.get(`${BASE_URL}/`, { page }, { preserveScroll: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function confirmRevoke() {
|
|
38
|
+
if (!pending) return;
|
|
39
|
+
setBusy(true);
|
|
40
|
+
router.post(
|
|
41
|
+
`${BASE_URL}/${pending.id}/revoke`,
|
|
42
|
+
{},
|
|
43
|
+
{
|
|
44
|
+
preserveScroll: true,
|
|
45
|
+
onFinish: () => {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
setPending(null);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<Head title={t(keys.sharing.admin.title)} />
|
|
56
|
+
<PageShell
|
|
57
|
+
title={t(keys.sharing.admin.title)}
|
|
58
|
+
description={t(keys.sharing.admin.description)}
|
|
59
|
+
>
|
|
60
|
+
{props.tenant_required ? (
|
|
61
|
+
<InlineBanner
|
|
62
|
+
icon={Building2}
|
|
63
|
+
tone="warning"
|
|
64
|
+
title={t(keys.sharing.admin.tenant_required)}
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<>
|
|
68
|
+
<ShareTable
|
|
69
|
+
shares={props.shares}
|
|
70
|
+
canRevoke={props.can_revoke}
|
|
71
|
+
highlightId={highlightId}
|
|
72
|
+
onRevoke={setPending}
|
|
73
|
+
/>
|
|
74
|
+
<PaginationBar
|
|
75
|
+
page={props.page}
|
|
76
|
+
perPage={props.per_page}
|
|
77
|
+
total={props.total}
|
|
78
|
+
onPageChange={goTo}
|
|
79
|
+
hideWhenSinglePage
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
</PageShell>
|
|
84
|
+
<ConfirmActionDialog
|
|
85
|
+
open={pending !== null}
|
|
86
|
+
onOpenChange={(open) => !open && setPending(null)}
|
|
87
|
+
icon={Trash2}
|
|
88
|
+
title={t(keys.sharing.revoke.title)}
|
|
89
|
+
description={t(keys.sharing.revoke.description)}
|
|
90
|
+
confirmLabel={t(keys.sharing.revoke.confirm)}
|
|
91
|
+
cancelLabel={t(keys.sharing.revoke.cancel)}
|
|
92
|
+
onConfirm={confirmRevoke}
|
|
93
|
+
busy={busy}
|
|
94
|
+
/>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Admin.layout = [AdminLayout];
|
|
100
|
+
export default Admin;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { EmptyState } from '@simple-module-py/ui/components/EmptyState';
|
|
3
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import {
|
|
6
|
+
Table,
|
|
7
|
+
TableBody,
|
|
8
|
+
TableCell,
|
|
9
|
+
TableHead,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableRow,
|
|
12
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
13
|
+
import { formatDayMonthYear } from '@simple-module-py/ui/lib/date-format';
|
|
14
|
+
import { Share2 } from 'lucide-react';
|
|
15
|
+
import type { Share } from '../types';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
shares: Share[];
|
|
19
|
+
canRevoke: boolean;
|
|
20
|
+
highlightId?: string | null;
|
|
21
|
+
onRevoke: (share: Share) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ShareTable({ shares, canRevoke, highlightId, onRevoke }: Props) {
|
|
25
|
+
const { t } = useT();
|
|
26
|
+
const never = t(keys.sharing.list.never);
|
|
27
|
+
|
|
28
|
+
if (shares.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<EmptyState
|
|
31
|
+
icon={Share2}
|
|
32
|
+
title={t(keys.sharing.list.empty_title)}
|
|
33
|
+
description={t(keys.sharing.list.empty_description)}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="overflow-x-auto rounded-lg border">
|
|
40
|
+
<Table>
|
|
41
|
+
<TableHeader>
|
|
42
|
+
<TableRow>
|
|
43
|
+
<TableHead>{t(keys.sharing.list.resource)}</TableHead>
|
|
44
|
+
<TableHead>{t(keys.sharing.list.subject)}</TableHead>
|
|
45
|
+
<TableHead>{t(keys.sharing.list.role)}</TableHead>
|
|
46
|
+
<TableHead>{t(keys.sharing.list.granted_by)}</TableHead>
|
|
47
|
+
<TableHead>{t(keys.sharing.list.expires)}</TableHead>
|
|
48
|
+
{canRevoke && (
|
|
49
|
+
<TableHead className="text-right">{t(keys.sharing.list.actions)}</TableHead>
|
|
50
|
+
)}
|
|
51
|
+
</TableRow>
|
|
52
|
+
</TableHeader>
|
|
53
|
+
<TableBody>
|
|
54
|
+
{shares.map((share) => (
|
|
55
|
+
<TableRow key={share.id} data-state={highlightId === share.id ? 'selected' : undefined}>
|
|
56
|
+
<TableCell>
|
|
57
|
+
<div className="font-medium">{share.resource_type}</div>
|
|
58
|
+
<code className="text-xs text-muted-foreground">{share.resource_id}</code>
|
|
59
|
+
</TableCell>
|
|
60
|
+
<TableCell>{share.subject_label ?? share.subject_id}</TableCell>
|
|
61
|
+
<TableCell>
|
|
62
|
+
<Badge variant="secondary">{share.role}</Badge>
|
|
63
|
+
</TableCell>
|
|
64
|
+
<TableCell>{share.granted_by_label ?? share.granted_by}</TableCell>
|
|
65
|
+
<TableCell>
|
|
66
|
+
{formatDayMonthYear(share.expires_at, never)}
|
|
67
|
+
{share.expired && (
|
|
68
|
+
<Badge variant="outline" className="ml-2">
|
|
69
|
+
{t(keys.sharing.list.expired)}
|
|
70
|
+
</Badge>
|
|
71
|
+
)}
|
|
72
|
+
</TableCell>
|
|
73
|
+
{canRevoke && (
|
|
74
|
+
<TableCell className="text-right">
|
|
75
|
+
<Button variant="ghost" size="sm" onClick={() => onRevoke(share)}>
|
|
76
|
+
{t(keys.sharing.list.revoke)}
|
|
77
|
+
</Button>
|
|
78
|
+
</TableCell>
|
|
79
|
+
)}
|
|
80
|
+
</TableRow>
|
|
81
|
+
))}
|
|
82
|
+
</TableBody>
|
|
83
|
+
</Table>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
sharing/pages/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Share {
|
|
2
|
+
id: string;
|
|
3
|
+
resource_type: string;
|
|
4
|
+
resource_id: string;
|
|
5
|
+
subject_type: string;
|
|
6
|
+
subject_id: string;
|
|
7
|
+
subject_label: string | null;
|
|
8
|
+
role: string;
|
|
9
|
+
granted_by: string;
|
|
10
|
+
granted_by_label: string | null;
|
|
11
|
+
expires_at: string | null;
|
|
12
|
+
expired: boolean;
|
|
13
|
+
created_at: string | null;
|
|
14
|
+
}
|
sharing/policy.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Plug shares into a module's ``OwnedResourcePolicy`` (GH #526).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from sharing.policy import share_exists_for
|
|
6
|
+
|
|
7
|
+
NOTE_POLICY = OwnedResourcePolicy(
|
|
8
|
+
Note.owner_id,
|
|
9
|
+
bypass_perm=PERM_NOTES_MANAGE,
|
|
10
|
+
share_exists=share_exists_for(NOTE_TYPE, Note.id),
|
|
11
|
+
)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from simple_module_core.resources import ResourcePrincipal
|
|
22
|
+
from sqlalchemy import String, cast, func, inspect, or_
|
|
23
|
+
from sqlmodel import exists, select
|
|
24
|
+
|
|
25
|
+
from sharing.constants import ROLE_IMPLIES, SUBJECT_USER
|
|
26
|
+
from sharing.models import ResourceShare
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def roles_granting(action: str) -> tuple[str, ...]:
|
|
30
|
+
"""Every share role that implies *action*."""
|
|
31
|
+
return tuple(role for role, implied in ROLE_IMPLIES.items() if action in implied)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_uuid(column: Any) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
return column.type.python_type is uuid.UUID
|
|
37
|
+
except (AttributeError, NotImplementedError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def id_text(column: Any) -> Any:
|
|
42
|
+
"""*column* rendered as the canonical text :func:`canonical_id` stores.
|
|
43
|
+
|
|
44
|
+
UUIDs become 32 lowercase hex digits on every dialect (Postgres casts with
|
|
45
|
+
hyphens, SQLite stores without), everything else its plain text.
|
|
46
|
+
"""
|
|
47
|
+
text = cast(column, String)
|
|
48
|
+
if _is_uuid(column):
|
|
49
|
+
return func.replace(func.lower(text), "-", "")
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def canonical_id(value: Any) -> str:
|
|
54
|
+
"""The stored ``resource_id`` for a key value (matches :func:`id_text`)."""
|
|
55
|
+
if isinstance(value, uuid.UUID):
|
|
56
|
+
return value.hex
|
|
57
|
+
return str(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def row_id(obj: Any) -> str:
|
|
61
|
+
"""The canonical id of a loaded single-key row."""
|
|
62
|
+
identity = inspect(obj).identity
|
|
63
|
+
if not identity or len(identity) != 1:
|
|
64
|
+
raise ValueError("shared resources need a single-column primary key")
|
|
65
|
+
return canonical_id(identity[0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def active_share_clause(now: datetime | None = None) -> Any:
|
|
69
|
+
"""Shares that have not expired, as of *now* (a bound value, not ``NOW()``)."""
|
|
70
|
+
moment = now or datetime.now(UTC)
|
|
71
|
+
return or_(ResourceShare.expires_at.is_(None), ResourceShare.expires_at > moment)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def share_exists_for(
|
|
75
|
+
resource_type: str, id_col: Any
|
|
76
|
+
) -> Callable[[ResourcePrincipal, str], Any | None]:
|
|
77
|
+
"""A ``share_exists`` factory for ``OwnedResourcePolicy``.
|
|
78
|
+
|
|
79
|
+
Correlated on *id_col*; tenant scoping comes from the share table's
|
|
80
|
+
``MultiTenantMixin`` filter, and expired shares never match.
|
|
81
|
+
"""
|
|
82
|
+
key = id_text(id_col)
|
|
83
|
+
|
|
84
|
+
def share_exists(principal: ResourcePrincipal, action: str) -> Any | None:
|
|
85
|
+
roles = roles_granting(action)
|
|
86
|
+
if not roles or principal.user_id is None:
|
|
87
|
+
return None
|
|
88
|
+
return exists(
|
|
89
|
+
select(ResourceShare.id).where(
|
|
90
|
+
ResourceShare.resource_type == resource_type,
|
|
91
|
+
ResourceShare.resource_id == key,
|
|
92
|
+
ResourceShare.subject_type == SUBJECT_USER,
|
|
93
|
+
ResourceShare.subject_id == principal.user_id,
|
|
94
|
+
ResourceShare.role.in_(roles),
|
|
95
|
+
active_share_clause(),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return share_exists
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"active_share_clause",
|
|
104
|
+
"canonical_id",
|
|
105
|
+
"id_text",
|
|
106
|
+
"roles_granting",
|
|
107
|
+
"row_id",
|
|
108
|
+
"share_exists_for",
|
|
109
|
+
]
|
sharing/present.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Turn share rows into ``ShareOut`` with user labels from the directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from simple_module_core.user_directory import UserDirectory, resolve_user_labels
|
|
10
|
+
|
|
11
|
+
from sharing.audience import Audience, OpenAudience
|
|
12
|
+
from sharing.contracts.schemas import ShareOut
|
|
13
|
+
from sharing.models import ResourceShare
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _expired(share: ResourceShare, now: datetime) -> bool:
|
|
17
|
+
if share.expires_at is None:
|
|
18
|
+
return False
|
|
19
|
+
expires = share.expires_at
|
|
20
|
+
if expires.tzinfo is None:
|
|
21
|
+
expires = expires.replace(tzinfo=UTC)
|
|
22
|
+
return expires <= now
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def present_shares(
|
|
26
|
+
directory: UserDirectory,
|
|
27
|
+
db: Any,
|
|
28
|
+
shares: Sequence[ResourceShare],
|
|
29
|
+
audience: Audience | OpenAudience,
|
|
30
|
+
) -> list[ShareOut]:
|
|
31
|
+
"""One batched directory lookup for every subject and granter on the page.
|
|
32
|
+
|
|
33
|
+
Only users in the request's *audience* (the active tenant's members) are
|
|
34
|
+
named; anyone else shows as their raw id, so no label crosses tenants.
|
|
35
|
+
"""
|
|
36
|
+
ids = [s.subject_id for s in shares] + [s.granted_by for s in shares]
|
|
37
|
+
labels = await resolve_user_labels(directory, db, await audience.visible(ids))
|
|
38
|
+
now = datetime.now(UTC)
|
|
39
|
+
return [
|
|
40
|
+
ShareOut.model_validate(
|
|
41
|
+
share,
|
|
42
|
+
update={
|
|
43
|
+
"subject_label": labels.get(share.subject_id),
|
|
44
|
+
"granted_by_label": labels.get(share.granted_by),
|
|
45
|
+
"expired": _expired(share, now),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
for share in shares
|
|
49
|
+
]
|
sharing/py.typed
ADDED
|
File without changes
|
sharing/service.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Grant, revoke and list shares (GH #526).
|
|
2
|
+
|
|
3
|
+
Every grant is checked three ways: the resource type is registered, the
|
|
4
|
+
granter passes the owning module's policy for ``share`` on that row, and the
|
|
5
|
+
grantee is someone the user directory knows. Rows are tenant-scoped by
|
|
6
|
+
``MultiTenantMixin``; revoking hard-deletes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from simple_module_core.resources import (
|
|
16
|
+
ACTION_SHARE,
|
|
17
|
+
ResourcePrincipal,
|
|
18
|
+
ResourceType,
|
|
19
|
+
ResourceTypeRegistry,
|
|
20
|
+
)
|
|
21
|
+
from simple_module_core.user_directory import UserDirectory
|
|
22
|
+
from simple_module_db.resource_policy import load_authorized
|
|
23
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
24
|
+
from sqlmodel import select
|
|
25
|
+
|
|
26
|
+
from sharing.audience import Audience, OpenAudience
|
|
27
|
+
from sharing.constants import (
|
|
28
|
+
ERR_BAD_ROLE,
|
|
29
|
+
ERR_EXPIRED,
|
|
30
|
+
ERR_FORBIDDEN,
|
|
31
|
+
ERR_NOT_FOUND,
|
|
32
|
+
ERR_SELF,
|
|
33
|
+
ERR_UNKNOWN_GRANTEE,
|
|
34
|
+
ERR_UNKNOWN_TYPE,
|
|
35
|
+
PERM_MANAGE,
|
|
36
|
+
SUBJECT_USER,
|
|
37
|
+
)
|
|
38
|
+
from sharing.contracts.schemas import ShareGrant
|
|
39
|
+
from sharing.models import ResourceShare
|
|
40
|
+
from sharing.policy import row_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SharingError(Exception):
|
|
44
|
+
"""A refused share operation, with the HTTP status it maps to."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
47
|
+
super().__init__(detail)
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
self.detail = detail
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _aware(value: datetime) -> datetime:
|
|
53
|
+
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SharingService:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
db: AsyncSession,
|
|
60
|
+
registry: ResourceTypeRegistry,
|
|
61
|
+
directory: UserDirectory,
|
|
62
|
+
audience: Audience | OpenAudience | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.db = db
|
|
65
|
+
self.registry = registry
|
|
66
|
+
self.directory = directory
|
|
67
|
+
self.audience = audience or OpenAudience()
|
|
68
|
+
|
|
69
|
+
def _type(self, name: str) -> ResourceType:
|
|
70
|
+
resource_type = self.registry.get(name)
|
|
71
|
+
if resource_type is None:
|
|
72
|
+
raise SharingError(422, ERR_UNKNOWN_TYPE)
|
|
73
|
+
return resource_type
|
|
74
|
+
|
|
75
|
+
async def _authorize(
|
|
76
|
+
self, principal: ResourcePrincipal, resource_type: ResourceType, resource_id: str
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""The resource row if *principal* may share it: 404 if invisible, 403 if not."""
|
|
79
|
+
try:
|
|
80
|
+
stmt = resource_type.select_by_id(resource_id)
|
|
81
|
+
except ValueError:
|
|
82
|
+
raise SharingError(404, ERR_NOT_FOUND) from None
|
|
83
|
+
row, allowed = await load_authorized(
|
|
84
|
+
self.db, resource_type.policy, principal, ACTION_SHARE, stmt
|
|
85
|
+
)
|
|
86
|
+
if row is None:
|
|
87
|
+
raise SharingError(404, ERR_NOT_FOUND)
|
|
88
|
+
if not allowed:
|
|
89
|
+
raise SharingError(403, ERR_FORBIDDEN)
|
|
90
|
+
return row
|
|
91
|
+
|
|
92
|
+
async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> ResourceShare:
|
|
93
|
+
resource_type = self._type(data.resource_type)
|
|
94
|
+
if data.role not in resource_type.shareable_actions:
|
|
95
|
+
raise SharingError(422, ERR_BAD_ROLE)
|
|
96
|
+
if data.expires_at is not None and _aware(data.expires_at) <= datetime.now(UTC):
|
|
97
|
+
raise SharingError(422, ERR_EXPIRED)
|
|
98
|
+
row = await self._authorize(principal, resource_type, data.resource_id)
|
|
99
|
+
grantee = (await self.directory.get_many(self.db, [data.subject_id])).get(data.subject_id)
|
|
100
|
+
# One error for "no such user" and "not in your tenant": no enumeration.
|
|
101
|
+
if grantee is None or grantee.id not in await self.audience.visible([grantee.id]):
|
|
102
|
+
raise SharingError(422, ERR_UNKNOWN_GRANTEE)
|
|
103
|
+
if grantee.id == principal.user_id:
|
|
104
|
+
raise SharingError(422, ERR_SELF)
|
|
105
|
+
resource_id = row_id(row)
|
|
106
|
+
share = await self._existing(data.resource_type, resource_id, grantee.id)
|
|
107
|
+
if share is None:
|
|
108
|
+
share = ResourceShare(
|
|
109
|
+
resource_type=data.resource_type,
|
|
110
|
+
resource_id=resource_id,
|
|
111
|
+
subject_type=SUBJECT_USER,
|
|
112
|
+
subject_id=grantee.id,
|
|
113
|
+
role=data.role,
|
|
114
|
+
granted_by=principal.user_id or "",
|
|
115
|
+
)
|
|
116
|
+
self.db.add(share)
|
|
117
|
+
share.role = data.role
|
|
118
|
+
share.expires_at = data.expires_at
|
|
119
|
+
share.granted_by = principal.user_id or ""
|
|
120
|
+
await self.db.flush()
|
|
121
|
+
return share
|
|
122
|
+
|
|
123
|
+
async def _existing(self, rtype: str, rid: str, subject_id: str) -> ResourceShare | None:
|
|
124
|
+
stmt = select(ResourceShare).where(
|
|
125
|
+
ResourceShare.resource_type == rtype,
|
|
126
|
+
ResourceShare.resource_id == rid,
|
|
127
|
+
ResourceShare.subject_type == SUBJECT_USER,
|
|
128
|
+
ResourceShare.subject_id == subject_id,
|
|
129
|
+
)
|
|
130
|
+
return (await self.db.execute(stmt)).scalars().first()
|
|
131
|
+
|
|
132
|
+
async def revoke(self, principal: ResourcePrincipal, share_id: uuid.UUID) -> None:
|
|
133
|
+
"""Delete a share: an admin, its grantee (leaving), or whoever may share the row."""
|
|
134
|
+
share = (
|
|
135
|
+
(await self.db.execute(select(ResourceShare).where(ResourceShare.id == share_id)))
|
|
136
|
+
.scalars()
|
|
137
|
+
.first()
|
|
138
|
+
)
|
|
139
|
+
if share is None:
|
|
140
|
+
raise SharingError(404, ERR_NOT_FOUND)
|
|
141
|
+
if not principal.has(PERM_MANAGE) and share.subject_id != principal.user_id:
|
|
142
|
+
resource_type = self.registry.get(share.resource_type)
|
|
143
|
+
if resource_type is None:
|
|
144
|
+
raise SharingError(404, ERR_NOT_FOUND)
|
|
145
|
+
await self._authorize(principal, resource_type, share.resource_id)
|
|
146
|
+
await self.db.delete(share)
|
|
147
|
+
await self.db.flush()
|
|
148
|
+
|
|
149
|
+
async def for_resource(
|
|
150
|
+
self, principal: ResourcePrincipal, rtype: str, resource_id: str
|
|
151
|
+
) -> list[ResourceShare]:
|
|
152
|
+
"""Every share on one row (expired included), for whoever may share it."""
|
|
153
|
+
row = await self._authorize(principal, self._type(rtype), resource_id)
|
|
154
|
+
stmt = (
|
|
155
|
+
select(ResourceShare)
|
|
156
|
+
.where(
|
|
157
|
+
ResourceShare.resource_type == rtype,
|
|
158
|
+
ResourceShare.resource_id == row_id(row),
|
|
159
|
+
)
|
|
160
|
+
.order_by(ResourceShare.created_at)
|
|
161
|
+
)
|
|
162
|
+
return list((await self.db.execute(stmt)).scalars())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["SharingError", "SharingService"]
|
sharing/services.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Module-scoped state container.
|
|
2
|
+
|
|
3
|
+
Stored as ``app.state.sharing`` by
|
|
4
|
+
:meth:`SharingModule.register_settings`.
|
|
5
|
+
|
|
6
|
+
Not frozen — ``on_startup`` may set fields that depend on the DB or
|
|
7
|
+
other framework services. Convention: set once during boot, treat as
|
|
8
|
+
read-only after.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from sharing.settings import SharingSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SharingServices:
|
|
20
|
+
"""Sharing module singletons."""
|
|
21
|
+
|
|
22
|
+
settings: SharingSettings
|
sharing/settings.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Sharing module settings (DB-backed).
|
|
2
|
+
|
|
3
|
+
Values come from the pydantic defaults below at boot, then get hydrated from
|
|
4
|
+
the settings table by the hosting lifespan before module ``on_startup`` runs
|
|
5
|
+
(``register_module_settings`` in ``module.py`` records the class).
|
|
6
|
+
``DbBackedSettings`` reads no environment variables: the DB is the only
|
|
7
|
+
source. Add fields here as the module grows.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pydantic_settings import SettingsConfigDict
|
|
13
|
+
from simple_module_core.settings_base import DbBackedSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SharingSettings(DbBackedSettings):
|
|
17
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: simple_module_sharing
|
|
3
|
+
Version: 0.0.38
|
|
4
|
+
Summary: Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: access-control,permissions,sharing,simple-module
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: simple-module-core==0.0.38
|
|
15
|
+
Requires-Dist: simple-module-db==0.0.38
|
|
16
|
+
Requires-Dist: simple-module-hosting==0.0.38
|
|
17
|
+
Requires-Dist: simple-module-inertia==0.0.38
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# simple_module_sharing
|
|
21
|
+
|
|
22
|
+
A generic share table for
|
|
23
|
+
[simple_module](https://github.com/antosubash/simple_module_python) apps:
|
|
24
|
+
give one user `view` or `edit` access to one row of another module, with an
|
|
25
|
+
optional expiry, without that module shipping a bespoke share model. It plugs
|
|
26
|
+
into the framework's resource-scoped access (`ResourcePolicy`,
|
|
27
|
+
`OwnedResourcePolicy`, `RequiresResource`), so detail routes, lists and counts
|
|
28
|
+
all honour a share in SQL.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install simple_module_sharing
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add `simple_module_sharing` to your host's dependencies; the
|
|
37
|
+
`[project.entry-points.simple_module]` entry point is discovered automatically
|
|
38
|
+
at boot. Run `alembic upgrade heads` to create `sharing_resource_share`.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Declare the resource type in the owning module and hand the policy a
|
|
43
|
+
`share_exists` factory:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from sharing.policy import share_exists_for
|
|
47
|
+
|
|
48
|
+
NOTE_POLICY = OwnedResourcePolicy(
|
|
49
|
+
Note.owner_id,
|
|
50
|
+
bypass_perm=PERM_NOTES_MANAGE,
|
|
51
|
+
share_exists=share_exists_for("notes.note", Note.id),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_resource_types(self, registry):
|
|
56
|
+
registry.register(
|
|
57
|
+
ResourceType(name="notes.note", policy=NOTE_POLICY, select_by_id=select_by_id(Note))
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Route | Purpose | Permission |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `POST /api/sharing/shares` | Grant or update a share | `sharing.share` + policy `can("share")` |
|
|
64
|
+
| `GET /api/sharing/shares?resource_type=&resource_id=` | Shares on one row | `sharing.share` + policy `can("share")` |
|
|
65
|
+
| `DELETE /api/sharing/shares/{id}` | Revoke (hard delete) | `sharing.share`; granter, grantee or `sharing.manage` |
|
|
66
|
+
| `GET /admin/sharing/` | Admin list of every share in the tenant | `sharing.view` |
|
|
67
|
+
|
|
68
|
+
Shares are tenant-scoped, expired shares never match, and grantees are
|
|
69
|
+
validated through the user directory. See `docs/modules/sharing.md`.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
sharing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sharing/audience.py,sha256=4LciCzqPR_J9lp_lXVWP4lORciiPfKdSRktdQVzua-A,1709
|
|
3
|
+
sharing/constants.py,sha256=Q7TFy72juCzKqT4rqKe3aKtpwzErkr8FuDFbrzHFfF4,2219
|
|
4
|
+
sharing/deps.py,sha256=aPAQOkeDmnTCRwTuFYM0LV7c21XXkXVEJmBDodeZuiE,902
|
|
5
|
+
sharing/models.py,sha256=x3UWtzWxSL9BRNo1tdYlTZnC-tB1UIRCCGPL_G-Wmt4,1701
|
|
6
|
+
sharing/module.py,sha256=n2o8G5hKcUMOZbxZbHbvL4x_Ej1h1eTmQ6DPAXl_jsA,3070
|
|
7
|
+
sharing/policy.py,sha256=faMtP7LU-_RZ6EALHCcZrZ7UPi2H9szreoguFJ76Wp8,3312
|
|
8
|
+
sharing/present.py,sha256=mfcN0nZcz2hLal6DErKZbb5_Ud1AFwRJYaRHUGaPvBc,1597
|
|
9
|
+
sharing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
sharing/service.py,sha256=Hy3Td_vjri9zFQdcQLpQSiPxljc5jmDNv81zfZCmSf4,6215
|
|
11
|
+
sharing/services.py,sha256=wBRDJX0ZJF7M0Ma1OMxJmRfizARCth-U7exX95A49J4,493
|
|
12
|
+
sharing/settings.py,sha256=DQrv2pUQvqbbHrbyNJRJWbx2cMn-eHosSYcUK4nbfFc,628
|
|
13
|
+
sharing/contracts/__init__.py,sha256=u5thDI57rgS9D9l41aWzo0ty7CsIk_f8FMJ5-S53O7Q,162
|
|
14
|
+
sharing/contracts/schemas.py,sha256=RsRb4PUuwQZOfqiAnsa2GC2Cqw-DzYtkbKfDODvcil8,1073
|
|
15
|
+
sharing/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
sharing/endpoints/api.py,sha256=B2YK-rDmDZLvlDC6doINhuNyoow0z6ACEYUhC8Hqhho,2220
|
|
17
|
+
sharing/endpoints/views.py,sha256=qXFylAQo5HcGS21DLUvm3hMv2nep21xLKk4quMH46HY,2681
|
|
18
|
+
sharing/locales/de.json,sha256=28e-fBYtpu4W4nqbTc3OxTvTuSu9o51u0sl4xqG5Z9g,983
|
|
19
|
+
sharing/locales/en.json,sha256=BL2M43fp5PYBBmvNBJo5rPtExJaKvZKDJYFEl3teElI,870
|
|
20
|
+
sharing/pages/Admin.tsx,sha256=TMnd2RpsqBDFaRk5CXxbrsaZeIFMB_e4ojhCK8vjYVQ,3006
|
|
21
|
+
sharing/pages/types.ts,sha256=a3q7_aDjgrTrfb7A2FgsxiTOmUp8yolFURHLSZc1UYk,318
|
|
22
|
+
sharing/pages/components/ShareTable.tsx,sha256=a5_LTX47yI8sH-nhC8qrENO_jjIycyHDjUGp64TsSts,3018
|
|
23
|
+
sharing/package.json,sha256=F0eA9FqGL6NdiNV3AKqME4bsoavJ8hdY3Hzv6m5WY4U,411
|
|
24
|
+
simple_module_sharing-0.0.38.dist-info/METADATA,sha256=sQ1oAesbnX3UnwfmhbAn_JNHoQsfcioKlkf03oUV8NE,2714
|
|
25
|
+
simple_module_sharing-0.0.38.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
26
|
+
simple_module_sharing-0.0.38.dist-info/entry_points.txt,sha256=W0KOCMGuWvijLrAew6EdMlW03zx_FsSfoxwgNw6KoSY,55
|
|
27
|
+
simple_module_sharing-0.0.38.dist-info/licenses/LICENSE,sha256=Yn66lhLklsF5p7pa85_ksQrJ79Q-FgOaUAHevLBjer4,1068
|
|
28
|
+
simple_module_sharing-0.0.38.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|