Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Added
- Per-resource-type sharing roles and implied actions (`ResourceType.share_roles`),
including delegated share management: a role implying `ACTION_SHARE` may list,
grant and revoke shares on that resource. Default view/edit roles remain
unchanged; no migration (#707).
- Users auth emails support ordered host template directories through the
restart-required DB setting `mail_template_dirs`, with per-file bundled
fallback and optional autoescaped HTML alternatives (#712).
Expand All @@ -32,6 +36,12 @@ All notable changes to this project are documented in this file. The format is b
- Public `background_tasks.sync_db.worker_tenancy` and `WorkerTenancy` snapshot,
distinguishing an unconfigured worker from single-tenant operation (#685).

### Deprecated
- `ResourceType.shareable_actions`, for removal in 0.1.0; use `share_roles`.
**Behaviour change:** it now defaults to `None`, not `("view", "edit")`, so
read `share_roles` instead. Legacy values that are not lowercase role ids of at
most 32 characters now fail at registration (#707).

### Fixed
- **Behaviour change:** bare UI borders now use the theme border colour in light
and dark themes, including pseudo-elements. Use `border-current` to retain the
Expand Down
1 change: 1 addition & 0 deletions docs/framework/deprecations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ core, so `props.lazy()` exposes `LAZY_REMOVE_IN` and the test registers it.
| `settings.registration`, `settings.module_registry`, `settings.hydrate` | `simple_module_core.module_settings`, `simple_module_core.settings_hydrate`, `simple_module_db.stored_settings` |
| `simple_module_inertia.props.lazy()` | `optional()` |
| one-argument `register_event_handlers(self, bus)` | `register_event_handlers(self, bus, app=None)` |
| `ResourceType.shareable_actions` | `ResourceType.share_roles` |
| `AuthProvider.get_public_paths()` (still honoured by `AuthMiddleware` and the maintenance gate) | `register_public_routes` |

`simple_module_hosting.settings.Settings` is **not** a shim: it is the combined
Expand Down
4 changes: 3 additions & 1 deletion docs/framework/resource-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ nobody bypasses ownership, not even an admin.

`share_exists` is injected as a `(principal, action) -> EXISTS(...) | None`
factory. The framework never imports the `sharing` module (SM009). A module can
supply its own bespoke share table the same way.
supply its own bespoke share table the same way. Custom share roles (for
example a `manager` role that may reshare) are declared per type with
`ResourceType.share_roles`; see [sharing](../modules/sharing.md).

## Detail endpoints: `RequiresResource`

Expand Down
51 changes: 46 additions & 5 deletions docs/modules/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Optional generic share table for [resource-scoped access](/framework/resource-access).
A module whose rows are owned by a user can let the owner share one row with
another user (`view` or `edit`, optionally until a date) without shipping its
another user (a declared role, optionally until a date) without shipping its
own share model. Nothing depends on it: the framework only sees the
`share_exists` factory a module passes to its `OwnedResourcePolicy`.

Expand Down Expand Up @@ -39,10 +39,51 @@ class NotesModule(ModuleBase):

A module that uses `sharing.policy` must list `Sharing` in `depends_on`.

Roles are actions: `view` grants view; `edit` grants view and edit. A type
limits what may be granted with `ResourceType.shareable_actions` (default
`("view", "edit")`). Only the owner, or a holder of the policy's bypass
permission, passes `can("share")` and may grant.
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
role ids to tuples of implied actions. Pass **the same mapping** to
`share_exists_for(..., roles=...)` so grant validation and SQL access agree.
Removing a role from the mapping later revokes the access of every share
stored with that role (the rows stay, but no longer match):

```python
from simple_module_core.resources import ACTION_EDIT, ACTION_SHARE, ACTION_VIEW

NOTE_SHARE_ROLES = {
"viewer": (ACTION_VIEW,),
"contributor": (ACTION_VIEW, ACTION_EDIT),
"manager": (ACTION_VIEW, ACTION_EDIT, ACTION_SHARE),
}
NOTE_POLICY = OwnedResourcePolicy(
Note.owner_id,
bypass_perm=PERM_NOTES_MANAGE,
share_exists=share_exists_for(NOTE_TYPE, Note.id, roles=NOTE_SHARE_ROLES),
)
NOTE_RESOURCE = ResourceType(
name=NOTE_TYPE,
policy=NOTE_POLICY,
select_by_id=select_by_id(Note),
share_roles=NOTE_SHARE_ROLES,
)
```

Register `NOTE_RESOURCE` in `register_resource_types`. Every declared role must
include `ACTION_VIEW`; registration rejects a role without it. `None` uses the
default mapping; an empty mapping permits no share roles. Role ids must match `[a-z][a-z0-9_]{0,31}` (checked at registration) and are stored
and shown as-is on the admin page. `roles_granting(action, roles=...)` returns
the role ids implying that action; custom actions work the same way as built-ins.

The owner, a holder of the policy's bypass permission, or a grantee whose role
implies `ACTION_SHARE` may list, grant and revoke shares on that resource.
There is no role ranking: such a grantee may grant **any** declared role,
including another manager. The API still requires `sharing.share`. Expired
shares and shares from other tenants grant no access.

`ResourceType.shareable_actions` is deprecated for removal in **0.1.0** and
warns when supplied. Existing view/edit subsets still restrict grantable roles;
`edit` continues to imply view. Migrate to `share_roles` and pass that mapping
to the SQL factory too. When both are supplied, `share_roles` takes precedence.
No database migration is needed.

## Routes

Expand Down
47 changes: 44 additions & 3 deletions framework/core/simple_module_core/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@
from __future__ import annotations

import re
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable

from simple_module_core.deprecation import deprecate
from simple_module_core.permissions import grants

ACTION_VIEW = "view"
Expand All @@ -39,7 +40,13 @@
ACTION_SHARE = "share"
"""Granting someone else access; the ``sharing`` module checks it on the granter."""

_SHAREABLE_ACTIONS = deprecate(
"ResourceType.shareable_actions", remove_in="0.1.0", use="ResourceType.share_roles"
)

_TYPE_NAME = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
# A share role id is stored in a 32-character column (``sharing`` ``ROLE_MAX``).
_ROLE_ID = re.compile(r"^[a-z][a-z0-9_]{0,31}$")


@dataclass(frozen=True)
Expand Down Expand Up @@ -87,13 +94,36 @@ class ResourceType:
``name`` is dotted and owned by the module (``"notes.note"``). ``select_by_id``
builds the unfiltered ``SELECT`` for one row from its string id; callers
always pass it through ``policy.filter`` before executing it.

``share_roles`` maps each share role id this type allows to the actions it
implies (each must include ``view``; checked at registration). ``None``
means the ``sharing`` module's default view/edit roles. Pass the same
mapping to ``sharing``'s ``share_exists_for(..., roles=...)``.
``shareable_actions`` is deprecated in favour of ``share_roles``.
"""

name: str
policy: ResourcePolicy
select_by_id: StatementForId
#: Actions a share may grant on this type, beyond :data:`ACTION_VIEW`.
shareable_actions: tuple[str, ...] = field(default=(ACTION_VIEW, ACTION_EDIT))
#: Deprecated role allowlist; retained for view/edit subsets until 0.1.0.
shareable_actions: tuple[str, ...] | None = None
#: Role ids mapped to their implied actions; None uses sharing's view/edit default.
share_roles: Mapping[str, tuple[str, ...]] | None = field(
default=None, kw_only=True, hash=False
)

def __post_init__(self) -> None:
if self.shareable_actions is not None:
_SHAREABLE_ACTIONS.warn(stacklevel=3)
if self.share_roles is None:
object.__setattr__(
self,
"share_roles",
{
role: (ACTION_VIEW,) if role == ACTION_VIEW else (ACTION_VIEW, role)
for role in self.shareable_actions
},
)


class ResourceTypeError(ValueError):
Expand All @@ -115,6 +145,17 @@ def register(self, resource_type: ResourceType) -> None:
raise ResourceTypeError(f"Resource type name {name!r} must look like 'module.thing'")
if name in self._types:
raise ResourceTypeError(f"Resource type {name!r} is registered twice")
if resource_type.share_roles is not None:
for role, actions in resource_type.share_roles.items():
if not isinstance(role, str) or not _ROLE_ID.fullmatch(role):
raise ResourceTypeError(
f"Resource type {name!r} share role {role!r} must be a lowercase "
"identifier of at most 32 characters"
)
if isinstance(actions, str) or ACTION_VIEW not in actions:
raise ResourceTypeError(
f"Resource type {name!r} share role {role!r} must include {ACTION_VIEW!r}"
)
self._types[name] = resource_type

def get(self, name: str) -> ResourceType | None:
Expand Down
2 changes: 2 additions & 0 deletions host/tests/test_deprecation_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
"simple_module_hosting._registrations",
"simple_module_core.public_routes",
"simple_module_core.versioning",
"simple_module_core.resources",
)
_EXPECTED = {
"ResourceType.shareable_actions",
"auth.deps.require_permission",
"host.routes_legacy (pre-/admin view URL redirects)",
"permissions.deps.invalidate_grants_on_commit",
Expand Down
26 changes: 18 additions & 8 deletions modules/sharing/sharing/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from __future__ import annotations

import uuid
from collections.abc import Callable
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from typing import Any

Expand All @@ -26,9 +26,16 @@
from sharing.models import ResourceShare


def roles_granting(action: str) -> tuple[str, ...]:
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 ROLE_IMPLIES.items() if action in implied)
return tuple(role for role, implied in resolve_roles(roles).items() if action in implied)


def _is_uuid(column: Any) -> bool:
Expand Down Expand Up @@ -72,26 +79,28 @@ def active_share_clause(now: datetime | None = None) -> Any:


def share_exists_for(
resource_type: str, id_col: Any
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.
``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:
roles = roles_granting(action)
if not roles or principal.user_id is 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_(roles),
ResourceShare.role.in_(granting_roles),
active_share_clause(),
)
)
Expand All @@ -103,6 +112,7 @@ def share_exists(principal: ResourcePrincipal, action: str) -> Any | None:
"active_share_clause",
"canonical_id",
"id_text",
"resolve_roles",
"roles_granting",
"row_id",
"share_exists_for",
Expand Down
4 changes: 2 additions & 2 deletions modules/sharing/sharing/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
)
from sharing.contracts.schemas import ShareGrant
from sharing.models import ResourceShare
from sharing.policy import row_id
from sharing.policy import resolve_roles, row_id


class SharingError(Exception):
Expand Down Expand Up @@ -89,7 +89,7 @@ async def _authorize(

async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> ResourceShare:
resource_type = self._type(data.resource_type)
if data.role not in resource_type.shareable_actions:
if data.role not in resolve_roles(resource_type.share_roles):
raise SharingError(422, ERR_BAD_ROLE)
if data.expires_at is not None and _aware(data.expires_at) <= utcnow():
raise SharingError(422, ERR_EXPIRED)
Expand Down
5 changes: 5 additions & 0 deletions modules/sharing/tests-js/sharing_Admin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const props: ComponentProps<typeof Admin> = {
afterEach(() => vi.clearAllMocks());

describe('sharing Admin', () => {
it('shows custom role ids unchanged', () => {
render(<Admin {...props} shares={[{ ...props.shares[0], role: 'collection_manager' }]} />);
expect(screen.getByText('collection_manager')).toBeInTheDocument();
});

it('renders labels and hides revoke from read-only viewers', () => {
render(<Admin {...props} can_revoke={false} />);
expect(screen.getByText('Friend')).toBeInTheDocument();
Expand Down
Loading
Loading