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 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,5 @@
1
+ """Sharing contracts — public interface for other modules."""
2
+
3
+ from sharing.contracts.schemas import ShareGrant, ShareOut
4
+
5
+ __all__ = ["ShareGrant", "ShareOut"]
@@ -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
@@ -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)
@@ -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
+ }
@@ -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
+ }
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ sharing = sharing.module:SharingModule
@@ -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.