diff --git a/CHANGELOG.md b/CHANGELOG.md index 342e29dac..1b1a4b8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project are documented in this file. The format is b - Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. - `mail` follows the module layout: `mail.core.{constants,settings,state,data_compatibility}` and `mail.delivery.{sender,runtime}`. `mail.settings` and `mail.constants` still import, with a `DeprecationWarning`, until 0.0.46. - `site_lock` follows the module layout: `site_lock.core.{constants,settings,state}` and `site_lock.web.{middleware,page,rate_limit}` (the unlock template moves with the page). `site_lock.settings` still imports, with a `DeprecationWarning`, until 0.0.46. +- `sharing` follows the module layout: `sharing.core.{constants,settings,services,service,policy,audience,tenant_cleanup,user_cleanup}`, `sharing.web.{deps,present,endpoints}`, and the page components and types move to `ui/`. `sharing.policy`, `sharing.constants` and `sharing.endpoints.api` still import, with a `DeprecationWarning`, until 0.0.46; new code imports `sharing.core.policy`. - `dashboard` follows the module layout: `dashboard.core.constants`, `dashboard.web.{endpoints,stats,doctor}`, and the page components move from `pages/components/` to `ui/components/`. Nothing outside the module imported the old paths, so no aliases remain. - `feature_flags` follows the module layout: `feature_flags.core.{constants,service,invalidation,tenant_cleanup}`, `feature_flags.web.{deps,endpoints}`, and the page components and types move to `ui/`. `feature_flags.constants` still imports, with a `DeprecationWarning`, until 0.0.46. diff --git a/docs/module-layout.md b/docs/module-layout.md index 2eab70afe..7687039e8 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -120,6 +120,12 @@ loads non-package tests by basename, even when they live in different folders. Existing flat test names are grandfathered only while their module remains in `LEGACY_FLAT`; migration renames them with the module prefix. +A helper shared by several test folders stays at `tests/` root as +`__support.py`. The root `conftest.py` puts that directory on +`sys.path`, so `from __support import ...` keeps working from nested +folders; a module that has helpers needs that `conftest.py` even when it holds +no fixtures. + The root `pyproject.toml:137` lists `modules//tests` in `testpaths`, and `framework/cli/simple_module_cli/framework_repo.py` wires that root. Pytest recursively collects nested areas without listing each child directory. @@ -157,7 +163,10 @@ The alias makes both paths the same module object, so mocks and `monkeypatch` strings keep working. Files containing both `deprecated_module(` and `sys.modules[__name__]` are auto-discovered by the expiry test and ignored by the root-layout test, so the module can leave `LEGACY_FLAT` while they -remain. A shim that is a plain re-export instead (not an alias) must be added +remain. When the old path was inside a package that moved (for example +`orders.endpoints.api`), keep that package with a docstring-only `__init__.py` +and alias each module in it; a package holding only aliases is ignored by the +root-layout test too. A shim that is a plain re-export instead (not an alias) must be added by hand to the expiry test's `_SHIM_MODULES` tuple and `_EXPECTED` set and kept in the module's frozen legacy entries until removed. A completely migrated module leaves `LEGACY_FLAT` once its Python tests are nested. @@ -209,7 +218,7 @@ when a later module PR moves files. | oidc | 16 | 24 | flat | | permissions | 12 | 14 | flat | | settings | 31 | 43 | flat | -| sharing | 13 | 6 | flat | +| sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | | tenants | 19 | 23 | flat | | users | 31 | 97 | flat | diff --git a/docs/modules/sharing.md b/docs/modules/sharing.md index 33ff95388..74d1c09fd 100644 --- a/docs/modules/sharing.md +++ b/docs/modules/sharing.md @@ -20,7 +20,7 @@ own share model. Nothing depends on it: the framework only sees the ```python from simple_module_core.resources import ResourceType from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id -from sharing.policy import share_exists_for +from sharing.core.policy import share_exists_for NOTE_TYPE = "notes.note" NOTE_POLICY = OwnedResourcePolicy( @@ -37,7 +37,7 @@ class NotesModule(ModuleBase): ) ``` -A module that uses `sharing.policy` must list `Sharing` in `depends_on`. +A module that uses `sharing.core.policy` must list `Sharing` in `depends_on`. By default, `ROLE_IMPLIES` supplies two roles: `view` grants view; `edit` grants view and edit. Set the keyword-only `ResourceType.share_roles` to a mapping of diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 1baf9bd0f..2e78fa4ad 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -111,12 +111,6 @@ reload.py service.py services.py settings.py store.py tenant_scope.py tenant_view.py """.split() ), - "sharing": frozenset( - """ - audience.py constants.py deps.py endpoints policy.py present.py service.py - services.py settings.py tenant_cleanup.py user_cleanup.py - """.split() - ), "tenants": frozenset( """ _deletion.py _membership_ops.py _owner_guard.py components constants.py deps.py @@ -144,6 +138,12 @@ def _is_layout_shim(path: Path) -> bool: return "deprecated_module(" in text and "sys.modules[__name__]" in text +def _is_shim_package(path: Path) -> bool: + """An old package path kept only for its aliases: shims plus a docstring __init__.""" + files = [f for f in path.glob("*.py") if f.name != "__init__.py"] + return bool(files) and all(_is_layout_shim(f) for f in files) + + @pytest.mark.parametrize("module", sorted(p.name for p in MODULES.iterdir() if p.is_dir())) def test_module_root_layout(module: str) -> None: package = MODULES / module / module @@ -152,7 +152,7 @@ def test_module_root_layout(module: str) -> None: p.name for p in package.iterdir() if (p.suffix == ".py" and not _is_layout_shim(p)) - or (p.is_dir() and p.name != "__pycache__") + or (p.is_dir() and p.name != "__pycache__" and not _is_shim_package(p)) } allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) legacy = LEGACY_FLAT.get(module, frozenset()) diff --git a/modules/sharing/README.md b/modules/sharing/README.md index c3a3afebc..6092c458a 100644 --- a/modules/sharing/README.md +++ b/modules/sharing/README.md @@ -24,7 +24,7 @@ Declare the resource type in the owning module and hand the policy a `share_exists` factory: ```python -from sharing.policy import share_exists_for +from sharing.core.policy import share_exists_for NOTE_POLICY = OwnedResourcePolicy( Note.owner_id, diff --git a/modules/sharing/sharing/constants.py b/modules/sharing/sharing/constants.py index 5a694731a..4c83a1e05 100644 --- a/modules/sharing/sharing/constants.py +++ b/modules/sharing/sharing/constants.py @@ -1,65 +1,11 @@ -"""Stable identifiers for the sharing module.""" +"""Moved to :mod:`sharing.core.constants`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from typing import Final +import importlib +import sys -from simple_module_core.resources import ACTION_EDIT, ACTION_VIEW +from simple_module_core.deprecation import deprecated_module -MODULE_NAME: Final = "Sharing" -MODULE_PACKAGE: Final = "sharing" -LOCALE_NAMESPACE: Final = "sharing" - -PERM_GROUP: Final = "Sharing" -PERM_SHARE: Final = "sharing.share" -"""Use the grant/revoke API at all; the resource's own policy still decides.""" -PERM_VIEW: Final = "sharing.view" -"""See every share in the tenant (the admin page).""" -PERM_MANAGE: Final = "sharing.manage" -"""Revoke any share in the tenant.""" -USER_ROLE: Final = "user" - -PAGE_ADMIN: Final = "Sharing/Admin" - -API_PREFIX: Final = "/api/sharing" -VIEW_PREFIX: Final = "/admin/sharing" -# Trailing slash: the index route is registered at "/" under the prefix. -ADMIN_URL: Final = f"{VIEW_PREFIX}/" -AUDIT_URL_TEMPLATE: Final = f"{VIEW_PREFIX}/?share={{id}}" - -MENU_LABEL: Final = "Sharing" -MENU_LABEL_KEY: Final = "sharing.nav.title" -MENU_ICON: Final = "share" -MENU_ORDER: Final = 60 -MENU_GROUP: Final = "Access" -MENU_GROUP_KEY: Final = "ui.nav_groups.access" -AUDIT_LABEL: Final = "Share" -AUDIT_LABEL_KEY: Final = "sharing.audit.share" - -# Prefix is load-bearing: all modules share one schema. -TABLE_SHARE: Final = "sharing_resource_share" -UQ_SHARE: Final = "uq_sharing_resource_share_subject" -IX_SHARE_RESOURCE: Final = "ix_sharing_resource_share_resource" - -RESOURCE_TYPE_MAX: Final = 100 -RESOURCE_ID_MAX: Final = 100 -SUBJECT_TYPE_MAX: Final = 16 -SUBJECT_ID_MAX: Final = 255 -ROLE_MAX: Final = 32 - -SUBJECT_USER: Final = "user" -"""The only subject kind so far; the column leaves room for groups or tenants.""" - -# A role is the action it grants plus everything it implies. -ROLE_IMPLIES: Final[dict[str, tuple[str, ...]]] = { - ACTION_VIEW: (ACTION_VIEW,), - ACTION_EDIT: (ACTION_VIEW, ACTION_EDIT), -} - -ERR_UNKNOWN_TYPE: Final = "Unknown resource type" -ERR_NOT_FOUND: Final = "Not found" -ERR_FORBIDDEN: Final = "You may not share this resource" -ERR_UNKNOWN_GRANTEE: Final = "Unknown user" -ERR_BAD_ROLE: Final = "This role cannot be granted on this resource type" -ERR_EXPIRED: Final = "expires_at must be in the future" -ERR_SELF: Final = "You cannot share a resource with yourself" +deprecated_module("sharing.constants", remove_in="0.0.46", use="sharing.core.constants") +sys.modules[__name__] = importlib.import_module("sharing.core.constants") diff --git a/modules/sharing/sharing/contracts/schemas.py b/modules/sharing/sharing/contracts/schemas.py index bd0aa7a0d..3eb5401aa 100644 --- a/modules/sharing/sharing/contracts/schemas.py +++ b/modules/sharing/sharing/contracts/schemas.py @@ -8,7 +8,7 @@ from simple_module_core.resources import ACTION_VIEW from sqlmodel import Field, SQLModel -from sharing.constants import RESOURCE_ID_MAX, RESOURCE_TYPE_MAX, ROLE_MAX, SUBJECT_ID_MAX +from sharing.core.constants import RESOURCE_ID_MAX, RESOURCE_TYPE_MAX, ROLE_MAX, SUBJECT_ID_MAX class ShareGrant(SQLModel): diff --git a/modules/sharing/sharing/core/__init__.py b/modules/sharing/sharing/core/__init__.py new file mode 100644 index 000000000..b917af8cc --- /dev/null +++ b/modules/sharing/sharing/core/__init__.py @@ -0,0 +1,5 @@ +"""What every sharing feature builds on. + +Constants, settings, the services container, the share service, policies, +audiences and cleanup hooks. +""" diff --git a/modules/sharing/sharing/audience.py b/modules/sharing/sharing/core/audience.py similarity index 100% rename from modules/sharing/sharing/audience.py rename to modules/sharing/sharing/core/audience.py diff --git a/modules/sharing/sharing/core/constants.py b/modules/sharing/sharing/core/constants.py new file mode 100644 index 000000000..5a694731a --- /dev/null +++ b/modules/sharing/sharing/core/constants.py @@ -0,0 +1,65 @@ +"""Stable identifiers for the sharing module.""" + +from __future__ import annotations + +from typing import Final + +from simple_module_core.resources import ACTION_EDIT, ACTION_VIEW + +MODULE_NAME: Final = "Sharing" +MODULE_PACKAGE: Final = "sharing" +LOCALE_NAMESPACE: Final = "sharing" + +PERM_GROUP: Final = "Sharing" +PERM_SHARE: Final = "sharing.share" +"""Use the grant/revoke API at all; the resource's own policy still decides.""" +PERM_VIEW: Final = "sharing.view" +"""See every share in the tenant (the admin page).""" +PERM_MANAGE: Final = "sharing.manage" +"""Revoke any share in the tenant.""" +USER_ROLE: Final = "user" + +PAGE_ADMIN: Final = "Sharing/Admin" + +API_PREFIX: Final = "/api/sharing" +VIEW_PREFIX: Final = "/admin/sharing" +# Trailing slash: the index route is registered at "/" under the prefix. +ADMIN_URL: Final = f"{VIEW_PREFIX}/" +AUDIT_URL_TEMPLATE: Final = f"{VIEW_PREFIX}/?share={{id}}" + +MENU_LABEL: Final = "Sharing" +MENU_LABEL_KEY: Final = "sharing.nav.title" +MENU_ICON: Final = "share" +MENU_ORDER: Final = 60 +MENU_GROUP: Final = "Access" +MENU_GROUP_KEY: Final = "ui.nav_groups.access" +AUDIT_LABEL: Final = "Share" +AUDIT_LABEL_KEY: Final = "sharing.audit.share" + +# Prefix is load-bearing: all modules share one schema. +TABLE_SHARE: Final = "sharing_resource_share" +UQ_SHARE: Final = "uq_sharing_resource_share_subject" +IX_SHARE_RESOURCE: Final = "ix_sharing_resource_share_resource" + +RESOURCE_TYPE_MAX: Final = 100 +RESOURCE_ID_MAX: Final = 100 +SUBJECT_TYPE_MAX: Final = 16 +SUBJECT_ID_MAX: Final = 255 +ROLE_MAX: Final = 32 + +SUBJECT_USER: Final = "user" +"""The only subject kind so far; the column leaves room for groups or tenants.""" + +# A role is the action it grants plus everything it implies. +ROLE_IMPLIES: Final[dict[str, tuple[str, ...]]] = { + ACTION_VIEW: (ACTION_VIEW,), + ACTION_EDIT: (ACTION_VIEW, ACTION_EDIT), +} + +ERR_UNKNOWN_TYPE: Final = "Unknown resource type" +ERR_NOT_FOUND: Final = "Not found" +ERR_FORBIDDEN: Final = "You may not share this resource" +ERR_UNKNOWN_GRANTEE: Final = "Unknown user" +ERR_BAD_ROLE: Final = "This role cannot be granted on this resource type" +ERR_EXPIRED: Final = "expires_at must be in the future" +ERR_SELF: Final = "You cannot share a resource with yourself" diff --git a/modules/sharing/sharing/core/policy.py b/modules/sharing/sharing/core/policy.py new file mode 100644 index 000000000..b7f578bd6 --- /dev/null +++ b/modules/sharing/sharing/core/policy.py @@ -0,0 +1,119 @@ +"""Plug shares into a module's ``OwnedResourcePolicy`` (GH #526). + +:: + + from sharing.core.policy import share_exists_for + + NOTE_POLICY = OwnedResourcePolicy( + Note.owner_id, + bypass_perm=PERM_NOTES_MANAGE, + share_exists=share_exists_for(NOTE_TYPE, Note.id), + ) +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +from typing import Any + +from simple_module_core.resources import ResourcePrincipal +from sqlalchemy import String, cast, func, inspect, or_ +from sqlmodel import exists, select + +from sharing.core.constants import ROLE_IMPLIES, SUBJECT_USER +from sharing.models import ResourceShare + + +def resolve_roles(roles: Mapping[str, tuple[str, ...]] | None) -> Mapping[str, tuple[str, ...]]: + """*roles* as declared on a resource type; ``None`` means the default view/edit roles.""" + return ROLE_IMPLIES if roles is None else roles + + +def roles_granting( + action: str, *, roles: Mapping[str, tuple[str, ...]] | None = None +) -> tuple[str, ...]: + """Every share role that implies *action*.""" + return tuple(role for role, implied in resolve_roles(roles).items() if action in implied) + + +def _is_uuid(column: Any) -> bool: + try: + return column.type.python_type is uuid.UUID + except (AttributeError, NotImplementedError): + return False + + +def id_text(column: Any) -> Any: + """*column* rendered as the canonical text :func:`canonical_id` stores. + + UUIDs become 32 lowercase hex digits on every dialect (Postgres casts with + hyphens, SQLite stores without), everything else its plain text. + """ + text = cast(column, String) + if _is_uuid(column): + return func.replace(func.lower(text), "-", "") + return text + + +def canonical_id(value: Any) -> str: + """The stored ``resource_id`` for a key value (matches :func:`id_text`).""" + if isinstance(value, uuid.UUID): + return value.hex + return str(value) + + +def row_id(obj: Any) -> str: + """The canonical id of a loaded single-key row.""" + identity = inspect(obj).identity + if not identity or len(identity) != 1: + raise ValueError("shared resources need a single-column primary key") + return canonical_id(identity[0]) + + +def active_share_clause(now: datetime | None = None) -> Any: + """Shares that have not expired, as of *now* (a bound value, not ``NOW()``).""" + moment = now or datetime.now(UTC) + return or_(ResourceShare.expires_at.is_(None), ResourceShare.expires_at > moment) + + +def share_exists_for( + resource_type: str, id_col: Any, *, roles: Mapping[str, tuple[str, ...]] | None = None +) -> Callable[[ResourcePrincipal, str], Any | None]: + """A ``share_exists`` factory for ``OwnedResourcePolicy``. + + Correlated on *id_col*; tenant scoping comes from the share table's + ``MultiTenantMixin`` filter, and expired shares never match. *roles* is the + type's ``ResourceType.share_roles`` (``None``: the default view/edit roles); + pass the same mapping to both so SQL access and grant validation agree. + """ + key = id_text(id_col) + + def share_exists(principal: ResourcePrincipal, action: str) -> Any | None: + granting_roles = roles_granting(action, roles=roles) + if not granting_roles or principal.user_id is None: + return None + return exists( + select(ResourceShare.id).where( + ResourceShare.resource_type == resource_type, + ResourceShare.resource_id == key, + ResourceShare.subject_type == SUBJECT_USER, + ResourceShare.subject_id == principal.user_id, + ResourceShare.role.in_(granting_roles), + active_share_clause(), + ) + ) + + return share_exists + + +__all__ = [ + "active_share_clause", + "canonical_id", + "id_text", + "resolve_roles", + "roles_granting", + "row_id", + "share_exists_for", +] diff --git a/modules/sharing/sharing/service.py b/modules/sharing/sharing/core/service.py similarity index 97% rename from modules/sharing/sharing/service.py rename to modules/sharing/sharing/core/service.py index 006ce28d6..95cee0277 100644 --- a/modules/sharing/sharing/service.py +++ b/modules/sharing/sharing/core/service.py @@ -25,8 +25,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select -from sharing.audience import Audience, OpenAudience -from sharing.constants import ( +from sharing.contracts.schemas import ShareGrant +from sharing.core.audience import Audience, OpenAudience +from sharing.core.constants import ( ERR_BAD_ROLE, ERR_EXPIRED, ERR_FORBIDDEN, @@ -37,9 +38,8 @@ PERM_MANAGE, SUBJECT_USER, ) -from sharing.contracts.schemas import ShareGrant +from sharing.core.policy import resolve_roles, row_id from sharing.models import ResourceShare -from sharing.policy import resolve_roles, row_id class SharingError(Exception): diff --git a/modules/sharing/sharing/services.py b/modules/sharing/sharing/core/services.py similarity index 89% rename from modules/sharing/sharing/services.py rename to modules/sharing/sharing/core/services.py index d363c4f90..4d938bbcb 100644 --- a/modules/sharing/sharing/services.py +++ b/modules/sharing/sharing/core/services.py @@ -12,7 +12,7 @@ from dataclasses import dataclass -from sharing.settings import SharingSettings +from sharing.core.settings import SharingSettings @dataclass diff --git a/modules/sharing/sharing/settings.py b/modules/sharing/sharing/core/settings.py similarity index 100% rename from modules/sharing/sharing/settings.py rename to modules/sharing/sharing/core/settings.py diff --git a/modules/sharing/sharing/tenant_cleanup.py b/modules/sharing/sharing/core/tenant_cleanup.py similarity index 100% rename from modules/sharing/sharing/tenant_cleanup.py rename to modules/sharing/sharing/core/tenant_cleanup.py diff --git a/modules/sharing/sharing/user_cleanup.py b/modules/sharing/sharing/core/user_cleanup.py similarity index 93% rename from modules/sharing/sharing/user_cleanup.py rename to modules/sharing/sharing/core/user_cleanup.py index 3626161e9..53dfbd6c4 100644 --- a/modules/sharing/sharing/user_cleanup.py +++ b/modules/sharing/sharing/core/user_cleanup.py @@ -8,7 +8,7 @@ from simple_module_db import all_tenants from sqlmodel import col, delete -from sharing.constants import SUBJECT_USER +from sharing.core.constants import SUBJECT_USER from sharing.models import ResourceShare if TYPE_CHECKING: diff --git a/modules/sharing/sharing/endpoints/__init__.py b/modules/sharing/sharing/endpoints/__init__.py index e69de29bb..42d715a7a 100644 --- a/modules/sharing/sharing/endpoints/__init__.py +++ b/modules/sharing/sharing/endpoints/__init__.py @@ -0,0 +1 @@ +"""Deprecated package path: the endpoints moved to :mod:`sharing.web.endpoints` (module layout).""" diff --git a/modules/sharing/sharing/endpoints/api.py b/modules/sharing/sharing/endpoints/api.py index 987ad3d07..9cd7097e0 100644 --- a/modules/sharing/sharing/endpoints/api.py +++ b/modules/sharing/sharing/endpoints/api.py @@ -1,69 +1,11 @@ -"""REST API for shares — mounted under ``/api/sharing``. - -Gated on ``sharing.share`` (so an API token scoped to it may call these); -the resource's own policy then decides per row. -""" +"""Moved to :mod:`sharing.web.endpoints.api`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import uuid - -from fastapi import APIRouter, Depends, Request -from simple_module_core.resources import ResourcePrincipal -from simple_module_core.user_directory import get_user_directory -from simple_module_hosting.permissions import RequiresPermission -from simple_module_hosting.resource_deps import resource_principal - -from sharing.constants import PERM_SHARE -from sharing.contracts.schemas import ShareGrant, ShareOut -from sharing.deps import SharingServiceDep, http_error -from sharing.present import present_shares -from sharing.service import SharingError - -router = APIRouter(prefix="/shares", dependencies=[Depends(RequiresPermission(PERM_SHARE))]) - - -@router.get("", response_model=list[ShareOut]) -async def list_shares( - request: Request, - resource_type: str, - resource_id: str, - service: SharingServiceDep, - who: ResourcePrincipal = Depends(resource_principal), -) -> list[ShareOut]: - try: - shares = await service.for_resource(who, resource_type, resource_id) - except SharingError as exc: - raise http_error(exc) from None - return await present_shares( - get_user_directory(request.app), service.db, shares, service.audience - ) - - -@router.post("", response_model=ShareOut, status_code=201) -async def grant_share( - request: Request, - data: ShareGrant, - service: SharingServiceDep, - who: ResourcePrincipal = Depends(resource_principal), -) -> ShareOut: - try: - share = await service.grant(who, data) - except SharingError as exc: - raise http_error(exc) from None - (out,) = await present_shares( - get_user_directory(request.app), service.db, [share], service.audience - ) - return out +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -@router.delete("/{share_id}", status_code=204) -async def revoke_share( - share_id: uuid.UUID, - service: SharingServiceDep, - who: ResourcePrincipal = Depends(resource_principal), -) -> None: - try: - await service.revoke(who, share_id) - except SharingError as exc: - raise http_error(exc) from None +deprecated_module("sharing.endpoints.api", remove_in="0.0.46", use="sharing.web.endpoints.api") +sys.modules[__name__] = importlib.import_module("sharing.web.endpoints.api") diff --git a/modules/sharing/sharing/models.py b/modules/sharing/sharing/models.py index 9f0c4fdf6..4a1e06681 100644 --- a/modules/sharing/sharing/models.py +++ b/modules/sharing/sharing/models.py @@ -10,7 +10,7 @@ from sqlalchemy import Index, UniqueConstraint from sqlmodel import Field -from sharing.constants import ( +from sharing.core.constants import ( IX_SHARE_RESOURCE, RESOURCE_ID_MAX, RESOURCE_TYPE_MAX, diff --git a/modules/sharing/sharing/module.py b/modules/sharing/sharing/module.py index 99e2b8469..db3af9ffe 100644 --- a/modules/sharing/sharing/module.py +++ b/modules/sharing/sharing/module.py @@ -12,7 +12,7 @@ from simple_module_core.module_settings import register_module_settings from simple_module_core.tenancy import TenantRole, tenant_role -from sharing import constants as c +from sharing.core import constants as c if TYPE_CHECKING: from fastapi import APIRouter, FastAPI @@ -31,27 +31,27 @@ class SharingModule(ModuleBase): def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: from simple_module_core.tenant_events import TenantDeleted - from sharing.tenant_cleanup import purge_tenant_data + from sharing.core.tenant_cleanup import purge_tenant_data bus.subscribe_durable(TenantDeleted, purge_tenant_data) from simple_module_core.user_events import UserDeleted - from sharing.user_cleanup import purge_user_shares + from sharing.core.user_cleanup import purge_user_shares bus.subscribe_durable(UserDeleted, purge_user_shares) def register_settings(self, app: FastAPI) -> None: - from sharing.services import SharingServices - from sharing.settings import SharingSettings + from sharing.core.services import SharingServices + from sharing.core.settings import SharingSettings register_module_settings( app, c.MODULE_PACKAGE, SharingSettings, lambda s: SharingServices(settings=s) ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from sharing.endpoints.api import router as api - from sharing.endpoints.views import router as views + from sharing.web.endpoints.api import router as api + from sharing.web.endpoints.views import router as views api_router.include_router(api) view_router.include_router(views) diff --git a/modules/sharing/sharing/pages/Admin.tsx b/modules/sharing/sharing/pages/Admin.tsx index 6da38ca4b..51b49c06a 100644 --- a/modules/sharing/sharing/pages/Admin.tsx +++ b/modules/sharing/sharing/pages/Admin.tsx @@ -8,8 +8,8 @@ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import { Building2, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; -import { ShareTable } from './components/ShareTable'; -import type { Share } from './types'; +import { ShareTable } from '../ui/components/ShareTable'; +import type { Share } from '../ui/types'; interface Props { shares: Share[]; diff --git a/modules/sharing/sharing/policy.py b/modules/sharing/sharing/policy.py index 493dec692..3087cae24 100644 --- a/modules/sharing/sharing/policy.py +++ b/modules/sharing/sharing/policy.py @@ -1,119 +1,11 @@ -"""Plug shares into a module's ``OwnedResourcePolicy`` (GH #526). - -:: - - from sharing.policy import share_exists_for - - NOTE_POLICY = OwnedResourcePolicy( - Note.owner_id, - bypass_perm=PERM_NOTES_MANAGE, - share_exists=share_exists_for(NOTE_TYPE, Note.id), - ) -""" +"""Moved to :mod:`sharing.core.policy`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import uuid -from collections.abc import Callable, Mapping -from datetime import UTC, datetime -from typing import Any - -from simple_module_core.resources import ResourcePrincipal -from sqlalchemy import String, cast, func, inspect, or_ -from sqlmodel import exists, select - -from sharing.constants import ROLE_IMPLIES, SUBJECT_USER -from sharing.models import ResourceShare - - -def resolve_roles(roles: Mapping[str, tuple[str, ...]] | None) -> Mapping[str, tuple[str, ...]]: - """*roles* as declared on a resource type; ``None`` means the default view/edit roles.""" - return ROLE_IMPLIES if roles is None else roles - - -def roles_granting( - action: str, *, roles: Mapping[str, tuple[str, ...]] | None = None -) -> tuple[str, ...]: - """Every share role that implies *action*.""" - return tuple(role for role, implied in resolve_roles(roles).items() if action in implied) - - -def _is_uuid(column: Any) -> bool: - try: - return column.type.python_type is uuid.UUID - except (AttributeError, NotImplementedError): - return False - - -def id_text(column: Any) -> Any: - """*column* rendered as the canonical text :func:`canonical_id` stores. - - UUIDs become 32 lowercase hex digits on every dialect (Postgres casts with - hyphens, SQLite stores without), everything else its plain text. - """ - text = cast(column, String) - if _is_uuid(column): - return func.replace(func.lower(text), "-", "") - return text - - -def canonical_id(value: Any) -> str: - """The stored ``resource_id`` for a key value (matches :func:`id_text`).""" - if isinstance(value, uuid.UUID): - return value.hex - return str(value) - - -def row_id(obj: Any) -> str: - """The canonical id of a loaded single-key row.""" - identity = inspect(obj).identity - if not identity or len(identity) != 1: - raise ValueError("shared resources need a single-column primary key") - return canonical_id(identity[0]) - - -def active_share_clause(now: datetime | None = None) -> Any: - """Shares that have not expired, as of *now* (a bound value, not ``NOW()``).""" - moment = now or datetime.now(UTC) - return or_(ResourceShare.expires_at.is_(None), ResourceShare.expires_at > moment) - - -def share_exists_for( - resource_type: str, id_col: Any, *, roles: Mapping[str, tuple[str, ...]] | None = None -) -> Callable[[ResourcePrincipal, str], Any | None]: - """A ``share_exists`` factory for ``OwnedResourcePolicy``. - - Correlated on *id_col*; tenant scoping comes from the share table's - ``MultiTenantMixin`` filter, and expired shares never match. *roles* is the - type's ``ResourceType.share_roles`` (``None``: the default view/edit roles); - pass the same mapping to both so SQL access and grant validation agree. - """ - key = id_text(id_col) - - def share_exists(principal: ResourcePrincipal, action: str) -> Any | None: - granting_roles = roles_granting(action, roles=roles) - if not granting_roles or principal.user_id is None: - return None - return exists( - select(ResourceShare.id).where( - ResourceShare.resource_type == resource_type, - ResourceShare.resource_id == key, - ResourceShare.subject_type == SUBJECT_USER, - ResourceShare.subject_id == principal.user_id, - ResourceShare.role.in_(granting_roles), - active_share_clause(), - ) - ) - - return share_exists +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = [ - "active_share_clause", - "canonical_id", - "id_text", - "resolve_roles", - "roles_granting", - "row_id", - "share_exists_for", -] +deprecated_module("sharing.policy", remove_in="0.0.46", use="sharing.core.policy") +sys.modules[__name__] = importlib.import_module("sharing.core.policy") diff --git a/modules/sharing/sharing/pages/components/ShareTable.tsx b/modules/sharing/sharing/ui/components/ShareTable.tsx similarity index 100% rename from modules/sharing/sharing/pages/components/ShareTable.tsx rename to modules/sharing/sharing/ui/components/ShareTable.tsx diff --git a/modules/sharing/sharing/pages/types.ts b/modules/sharing/sharing/ui/types.ts similarity index 100% rename from modules/sharing/sharing/pages/types.ts rename to modules/sharing/sharing/ui/types.ts diff --git a/modules/sharing/sharing/web/__init__.py b/modules/sharing/sharing/web/__init__.py new file mode 100644 index 000000000..eae03e489 --- /dev/null +++ b/modules/sharing/sharing/web/__init__.py @@ -0,0 +1 @@ +"""The HTTP side: request deps, share presentation and the REST and Inertia endpoints.""" diff --git a/modules/sharing/sharing/deps.py b/modules/sharing/sharing/web/deps.py similarity index 88% rename from modules/sharing/sharing/deps.py rename to modules/sharing/sharing/web/deps.py index 40377a14e..9cbcf40a1 100644 --- a/modules/sharing/sharing/deps.py +++ b/modules/sharing/sharing/web/deps.py @@ -9,8 +9,8 @@ from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession -from sharing.audience import Audience -from sharing.service import SharingError, SharingService +from sharing.core.audience import Audience +from sharing.core.service import SharingError, SharingService async def get_sharing_service( diff --git a/modules/sharing/sharing/web/endpoints/__init__.py b/modules/sharing/sharing/web/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/sharing/sharing/web/endpoints/api.py b/modules/sharing/sharing/web/endpoints/api.py new file mode 100644 index 000000000..d40efa9ef --- /dev/null +++ b/modules/sharing/sharing/web/endpoints/api.py @@ -0,0 +1,69 @@ +"""REST API for shares — mounted under ``/api/sharing``. + +Gated on ``sharing.share`` (so an API token scoped to it may call these); +the resource's own policy then decides per row. +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Request +from simple_module_core.resources import ResourcePrincipal +from simple_module_core.user_directory import get_user_directory +from simple_module_hosting.permissions import RequiresPermission +from simple_module_hosting.resource_deps import resource_principal + +from sharing.contracts.schemas import ShareGrant, ShareOut +from sharing.core.constants import PERM_SHARE +from sharing.core.service import SharingError +from sharing.web.deps import SharingServiceDep, http_error +from sharing.web.present import present_shares + +router = APIRouter(prefix="/shares", dependencies=[Depends(RequiresPermission(PERM_SHARE))]) + + +@router.get("", response_model=list[ShareOut]) +async def list_shares( + request: Request, + resource_type: str, + resource_id: str, + service: SharingServiceDep, + who: ResourcePrincipal = Depends(resource_principal), +) -> list[ShareOut]: + try: + shares = await service.for_resource(who, resource_type, resource_id) + except SharingError as exc: + raise http_error(exc) from None + return await present_shares( + get_user_directory(request.app), service.db, shares, service.audience + ) + + +@router.post("", response_model=ShareOut, status_code=201) +async def grant_share( + request: Request, + data: ShareGrant, + service: SharingServiceDep, + who: ResourcePrincipal = Depends(resource_principal), +) -> ShareOut: + try: + share = await service.grant(who, data) + except SharingError as exc: + raise http_error(exc) from None + (out,) = await present_shares( + get_user_directory(request.app), service.db, [share], service.audience + ) + return out + + +@router.delete("/{share_id}", status_code=204) +async def revoke_share( + share_id: uuid.UUID, + service: SharingServiceDep, + who: ResourcePrincipal = Depends(resource_principal), +) -> None: + try: + await service.revoke(who, share_id) + except SharingError as exc: + raise http_error(exc) from None diff --git a/modules/sharing/sharing/endpoints/views.py b/modules/sharing/sharing/web/endpoints/views.py similarity index 91% rename from modules/sharing/sharing/endpoints/views.py rename to modules/sharing/sharing/web/endpoints/views.py index 017380a97..954f0cf76 100644 --- a/modules/sharing/sharing/endpoints/views.py +++ b/modules/sharing/sharing/web/endpoints/views.py @@ -16,11 +16,11 @@ from sqlmodel import col, select from starlette.responses import RedirectResponse -from sharing.constants import ADMIN_URL, PAGE_ADMIN, PERM_MANAGE, PERM_VIEW -from sharing.deps import SharingServiceDep, http_error +from sharing.core.constants import ADMIN_URL, PAGE_ADMIN, PERM_MANAGE, PERM_VIEW +from sharing.core.service import SharingError from sharing.models import ResourceShare -from sharing.present import present_shares -from sharing.service import SharingError +from sharing.web.deps import SharingServiceDep, http_error +from sharing.web.present import present_shares router = APIRouter() diff --git a/modules/sharing/sharing/present.py b/modules/sharing/sharing/web/present.py similarity index 96% rename from modules/sharing/sharing/present.py rename to modules/sharing/sharing/web/present.py index 42a66095c..c5a077a25 100644 --- a/modules/sharing/sharing/present.py +++ b/modules/sharing/sharing/web/present.py @@ -8,8 +8,8 @@ from simple_module_core.user_directory import UserDirectory, resolve_user_labels -from sharing.audience import Audience, OpenAudience from sharing.contracts.schemas import ShareOut +from sharing.core.audience import Audience, OpenAudience from sharing.models import ResourceShare diff --git a/modules/sharing/tests/_sharing_support.py b/modules/sharing/tests/_sharing_support.py index c6f28d0ad..40b77cfb5 100644 --- a/modules/sharing/tests/_sharing_support.py +++ b/modules/sharing/tests/_sharing_support.py @@ -6,7 +6,7 @@ from collections.abc import Collection from typing import Any -from sharing.policy import share_exists_for +from sharing.core.policy import share_exists_for from simple_module_core.resources import ResourceType, ResourceTypeRegistry from simple_module_core.user_directory import DirectoryUser from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id diff --git a/modules/sharing/tests/conftest.py b/modules/sharing/tests/conftest.py new file mode 100644 index 000000000..af57a6b72 --- /dev/null +++ b/modules/sharing/tests/conftest.py @@ -0,0 +1,4 @@ +"""Fixtures shared by every sharing test folder. + +Its presence puts this directory on ``sys.path``, so ``_sharing_support`` imports. +""" diff --git a/modules/sharing/tests/test_sharing_tenant_audience.py b/modules/sharing/tests/core/audience/test_sharing_tenant_audience.py similarity index 100% rename from modules/sharing/tests/test_sharing_tenant_audience.py rename to modules/sharing/tests/core/audience/test_sharing_tenant_audience.py diff --git a/modules/sharing/tests/test_sharing_resource_cleanup.py b/modules/sharing/tests/core/cleanup/test_sharing_resource_cleanup.py similarity index 100% rename from modules/sharing/tests/test_sharing_resource_cleanup.py rename to modules/sharing/tests/core/cleanup/test_sharing_resource_cleanup.py diff --git a/modules/sharing/tests/test_sharing_tenant_cleanup.py b/modules/sharing/tests/core/cleanup/test_sharing_tenant_cleanup.py similarity index 100% rename from modules/sharing/tests/test_sharing_tenant_cleanup.py rename to modules/sharing/tests/core/cleanup/test_sharing_tenant_cleanup.py diff --git a/modules/sharing/tests/test_sharing_custom_roles.py b/modules/sharing/tests/core/service/test_sharing_custom_roles.py similarity index 98% rename from modules/sharing/tests/test_sharing_custom_roles.py rename to modules/sharing/tests/core/service/test_sharing_custom_roles.py index eea3349dc..56acb7506 100644 --- a/modules/sharing/tests/test_sharing_custom_roles.py +++ b/modules/sharing/tests/core/service/test_sharing_custom_roles.py @@ -7,10 +7,10 @@ import pytest from _sharing_support import FakeDirectory, SharingTestNote, create_note_table -from sharing.constants import ROLE_MAX from sharing.contracts.schemas import ShareGrant -from sharing.policy import roles_granting, share_exists_for -from sharing.service import SharingError, SharingService +from sharing.core.constants import ROLE_MAX +from sharing.core.policy import roles_granting, share_exists_for +from sharing.core.service import SharingError, SharingService from simple_module_core.datetimes import utcnow from simple_module_core.resources import ( ACTION_EDIT, diff --git a/modules/sharing/tests/test_sharing_service.py b/modules/sharing/tests/core/service/test_sharing_service.py similarity index 98% rename from modules/sharing/tests/test_sharing_service.py rename to modules/sharing/tests/core/service/test_sharing_service.py index 3bbf4761c..3514a113f 100644 --- a/modules/sharing/tests/test_sharing_service.py +++ b/modules/sharing/tests/core/service/test_sharing_service.py @@ -14,10 +14,10 @@ create_note_table, register_note_type, ) -from sharing.constants import PERM_MANAGE from sharing.contracts.schemas import ShareGrant +from sharing.core.constants import PERM_MANAGE +from sharing.core.service import SharingError, SharingService from sharing.models import ResourceShare -from sharing.service import SharingError, SharingService from simple_module_core.resources import ( ACTION_EDIT, ACTION_SHARE, diff --git a/modules/sharing/tests/test_sharing_http.py b/modules/sharing/tests/web/endpoints/test_sharing_http.py similarity index 98% rename from modules/sharing/tests/test_sharing_http.py rename to modules/sharing/tests/web/endpoints/test_sharing_http.py index 520163f1b..055b07215 100644 --- a/modules/sharing/tests/test_sharing_http.py +++ b/modules/sharing/tests/web/endpoints/test_sharing_http.py @@ -6,8 +6,8 @@ import pytest from _sharing_support import NOTE_TYPE, SharingTestNote, create_note_table, register_note_type -from sharing.constants import PERM_SHARE -from sharing.policy import share_exists_for +from sharing.core.constants import PERM_SHARE +from sharing.core.policy import share_exists_for from simple_module_core.resources import ACTION_SHARE, ACTION_VIEW, ResourceType from simple_module_core.tenancy import TenantRole from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id diff --git a/modules/users/tests/test_users_deletion_events.py b/modules/users/tests/test_users_deletion_events.py index 17c47cf30..c306c3ef3 100644 --- a/modules/users/tests/test_users_deletion_events.py +++ b/modules/users/tests/test_users_deletion_events.py @@ -7,7 +7,7 @@ from background_tasks.outbox_transport import _OutboxWriter from permissions.models import UserPermission from permissions.module import PermissionsModule -from sharing.constants import SUBJECT_USER +from sharing.core.constants import SUBJECT_USER from sharing.models import ResourceShare from sharing.module import SharingModule from simple_module_core.durable_events import event_key