simple-module-sharing 0.0.38__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simple_module_sharing-0.0.38/.gitignore +17 -0
- simple_module_sharing-0.0.38/LICENSE +21 -0
- simple_module_sharing-0.0.38/PKG-INFO +73 -0
- simple_module_sharing-0.0.38/README.md +54 -0
- simple_module_sharing-0.0.38/package.json +18 -0
- simple_module_sharing-0.0.38/pyproject.toml +46 -0
- simple_module_sharing-0.0.38/sharing/__init__.py +0 -0
- simple_module_sharing-0.0.38/sharing/audience.py +44 -0
- simple_module_sharing-0.0.38/sharing/constants.py +65 -0
- simple_module_sharing-0.0.38/sharing/contracts/__init__.py +5 -0
- simple_module_sharing-0.0.38/sharing/contracts/schemas.py +36 -0
- simple_module_sharing-0.0.38/sharing/deps.py +31 -0
- simple_module_sharing-0.0.38/sharing/endpoints/__init__.py +0 -0
- simple_module_sharing-0.0.38/sharing/endpoints/api.py +69 -0
- simple_module_sharing-0.0.38/sharing/endpoints/views.py +75 -0
- simple_module_sharing-0.0.38/sharing/locales/de.json +33 -0
- simple_module_sharing-0.0.38/sharing/locales/en.json +33 -0
- simple_module_sharing-0.0.38/sharing/models.py +51 -0
- simple_module_sharing-0.0.38/sharing/module.py +86 -0
- simple_module_sharing-0.0.38/sharing/pages/Admin.tsx +100 -0
- simple_module_sharing-0.0.38/sharing/pages/components/ShareTable.tsx +86 -0
- simple_module_sharing-0.0.38/sharing/pages/types.ts +14 -0
- simple_module_sharing-0.0.38/sharing/policy.py +109 -0
- simple_module_sharing-0.0.38/sharing/present.py +49 -0
- simple_module_sharing-0.0.38/sharing/py.typed +0 -0
- simple_module_sharing-0.0.38/sharing/service.py +165 -0
- simple_module_sharing-0.0.38/sharing/services.py +22 -0
- simple_module_sharing-0.0.38/sharing/settings.py +17 -0
- simple_module_sharing-0.0.38/tests/_sharing_support.py +67 -0
- simple_module_sharing-0.0.38/tests/test_sharing_http.py +103 -0
- simple_module_sharing-0.0.38/tests/test_sharing_service.py +145 -0
- simple_module_sharing-0.0.38/tests/test_sharing_tenant_audience.py +87 -0
- simple_module_sharing-0.0.38/tsconfig.json +10 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.db
|
|
8
|
+
*.sqlite3
|
|
9
|
+
.env
|
|
10
|
+
|
|
11
|
+
# Built frontend assets — shipped inside the wheel via force-include but
|
|
12
|
+
# NOT source-controlled. Re-run `smpy module build` before `uv build` to
|
|
13
|
+
# repopulate.
|
|
14
|
+
*/static/dist/
|
|
15
|
+
|
|
16
|
+
# smpy module verify/build cache (throwaway scaffolded host)
|
|
17
|
+
.smpy/
|
|
@@ -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.
|
|
@@ -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,54 @@
|
|
|
1
|
+
# simple_module_sharing
|
|
2
|
+
|
|
3
|
+
A generic share table for
|
|
4
|
+
[simple_module](https://github.com/antosubash/simple_module_python) apps:
|
|
5
|
+
give one user `view` or `edit` access to one row of another module, with an
|
|
6
|
+
optional expiry, without that module shipping a bespoke share model. It plugs
|
|
7
|
+
into the framework's resource-scoped access (`ResourcePolicy`,
|
|
8
|
+
`OwnedResourcePolicy`, `RequiresResource`), so detail routes, lists and counts
|
|
9
|
+
all honour a share in SQL.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install simple_module_sharing
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Add `simple_module_sharing` to your host's dependencies; the
|
|
18
|
+
`[project.entry-points.simple_module]` entry point is discovered automatically
|
|
19
|
+
at boot. Run `alembic upgrade heads` to create `sharing_resource_share`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Declare the resource type in the owning module and hand the policy a
|
|
24
|
+
`share_exists` factory:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from sharing.policy import share_exists_for
|
|
28
|
+
|
|
29
|
+
NOTE_POLICY = OwnedResourcePolicy(
|
|
30
|
+
Note.owner_id,
|
|
31
|
+
bypass_perm=PERM_NOTES_MANAGE,
|
|
32
|
+
share_exists=share_exists_for("notes.note", Note.id),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_resource_types(self, registry):
|
|
37
|
+
registry.register(
|
|
38
|
+
ResourceType(name="notes.note", policy=NOTE_POLICY, select_by_id=select_by_id(Note))
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Route | Purpose | Permission |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `POST /api/sharing/shares` | Grant or update a share | `sharing.share` + policy `can("share")` |
|
|
45
|
+
| `GET /api/sharing/shares?resource_type=&resource_id=` | Shares on one row | `sharing.share` + policy `can("share")` |
|
|
46
|
+
| `DELETE /api/sharing/shares/{id}` | Revoke (hard delete) | `sharing.share`; granter, grantee or `sharing.manage` |
|
|
47
|
+
| `GET /admin/sharing/` | Admin list of every share in the tenant | `sharing.view` |
|
|
48
|
+
|
|
49
|
+
Shares are tenant-scoped, expired shares never match, and grantees are
|
|
50
|
+
validated through the user directory. See `docs/modules/sharing.md`.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -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,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_sharing"
|
|
3
|
+
version = "0.0.38"
|
|
4
|
+
description = "Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "sharing", "permissions", "access-control"]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"simple_module_core==0.0.38",
|
|
13
|
+
"simple_module_db==0.0.38",
|
|
14
|
+
"simple_module_hosting==0.0.38",
|
|
15
|
+
"simple_module_inertia==0.0.38",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.entry-points.simple_module]
|
|
19
|
+
sharing = "sharing.module:SharingModule"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
23
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
24
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
25
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
# The distribution name (simple_module_sharing) doesn't match the package
|
|
32
|
+
# directory (sharing), so hatchling can't infer what to ship — without
|
|
33
|
+
# this the wheel builds empty and the entry point fails to import.
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["sharing"]
|
|
36
|
+
|
|
37
|
+
# Ship the module-root package.json inside the wheel so the host can
|
|
38
|
+
# discover JS deps via importlib.resources after a pip install.
|
|
39
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
40
|
+
"package.json" = "sharing/package.json"
|
|
41
|
+
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
simple_module_core = { workspace = true }
|
|
44
|
+
simple_module_db = { workspace = true }
|
|
45
|
+
simple_module_hosting = { workspace = true }
|
|
46
|
+
simple_module_inertia = { workspace = true }
|
|
File without changes
|
|
@@ -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}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Stable identifiers for the sharing module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from simple_module_core.resources import ACTION_EDIT, ACTION_VIEW
|
|
8
|
+
|
|
9
|
+
MODULE_NAME: Final = "Sharing"
|
|
10
|
+
MODULE_PACKAGE: Final = "sharing"
|
|
11
|
+
LOCALE_NAMESPACE: Final = "sharing"
|
|
12
|
+
|
|
13
|
+
PERM_GROUP: Final = "Sharing"
|
|
14
|
+
PERM_SHARE: Final = "sharing.share"
|
|
15
|
+
"""Use the grant/revoke API at all; the resource's own policy still decides."""
|
|
16
|
+
PERM_VIEW: Final = "sharing.view"
|
|
17
|
+
"""See every share in the tenant (the admin page)."""
|
|
18
|
+
PERM_MANAGE: Final = "sharing.manage"
|
|
19
|
+
"""Revoke any share in the tenant."""
|
|
20
|
+
USER_ROLE: Final = "user"
|
|
21
|
+
|
|
22
|
+
PAGE_ADMIN: Final = "Sharing/Admin"
|
|
23
|
+
|
|
24
|
+
API_PREFIX: Final = "/api/sharing"
|
|
25
|
+
VIEW_PREFIX: Final = "/admin/sharing"
|
|
26
|
+
# Trailing slash: the index route is registered at "/" under the prefix.
|
|
27
|
+
ADMIN_URL: Final = f"{VIEW_PREFIX}/"
|
|
28
|
+
AUDIT_URL_TEMPLATE: Final = f"{VIEW_PREFIX}/?share={{id}}"
|
|
29
|
+
|
|
30
|
+
MENU_LABEL: Final = "Sharing"
|
|
31
|
+
MENU_LABEL_KEY: Final = "sharing.nav.title"
|
|
32
|
+
MENU_ICON: Final = "share"
|
|
33
|
+
MENU_ORDER: Final = 60
|
|
34
|
+
MENU_GROUP: Final = "Access"
|
|
35
|
+
MENU_GROUP_KEY: Final = "ui.nav_groups.access"
|
|
36
|
+
AUDIT_LABEL: Final = "Share"
|
|
37
|
+
AUDIT_LABEL_KEY: Final = "sharing.audit.share"
|
|
38
|
+
|
|
39
|
+
# Prefix is load-bearing: all modules share one schema.
|
|
40
|
+
TABLE_SHARE: Final = "sharing_resource_share"
|
|
41
|
+
UQ_SHARE: Final = "uq_sharing_resource_share_subject"
|
|
42
|
+
IX_SHARE_RESOURCE: Final = "ix_sharing_resource_share_resource"
|
|
43
|
+
|
|
44
|
+
RESOURCE_TYPE_MAX: Final = 100
|
|
45
|
+
RESOURCE_ID_MAX: Final = 100
|
|
46
|
+
SUBJECT_TYPE_MAX: Final = 16
|
|
47
|
+
SUBJECT_ID_MAX: Final = 255
|
|
48
|
+
ROLE_MAX: Final = 32
|
|
49
|
+
|
|
50
|
+
SUBJECT_USER: Final = "user"
|
|
51
|
+
"""The only subject kind so far; the column leaves room for groups or tenants."""
|
|
52
|
+
|
|
53
|
+
# A role is the action it grants plus everything it implies.
|
|
54
|
+
ROLE_IMPLIES: Final[dict[str, tuple[str, ...]]] = {
|
|
55
|
+
ACTION_VIEW: (ACTION_VIEW,),
|
|
56
|
+
ACTION_EDIT: (ACTION_VIEW, ACTION_EDIT),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ERR_UNKNOWN_TYPE: Final = "Unknown resource type"
|
|
60
|
+
ERR_NOT_FOUND: Final = "Not found"
|
|
61
|
+
ERR_FORBIDDEN: Final = "You may not share this resource"
|
|
62
|
+
ERR_UNKNOWN_GRANTEE: Final = "Unknown user"
|
|
63
|
+
ERR_BAD_ROLE: Final = "This role cannot be granted on this resource type"
|
|
64
|
+
ERR_EXPIRED: Final = "expires_at must be in the future"
|
|
65
|
+
ERR_SELF: Final = "You cannot share a resource with yourself"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sharing DTOs — the module's public surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from simple_module_core.resources import ACTION_VIEW
|
|
9
|
+
from sqlmodel import Field, SQLModel
|
|
10
|
+
|
|
11
|
+
from sharing.constants import RESOURCE_ID_MAX, RESOURCE_TYPE_MAX, ROLE_MAX, SUBJECT_ID_MAX
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ShareGrant(SQLModel):
|
|
15
|
+
"""Grant (or update) one user's access to one resource."""
|
|
16
|
+
|
|
17
|
+
resource_type: str = Field(min_length=1, max_length=RESOURCE_TYPE_MAX)
|
|
18
|
+
resource_id: str = Field(min_length=1, max_length=RESOURCE_ID_MAX)
|
|
19
|
+
subject_id: str = Field(min_length=1, max_length=SUBJECT_ID_MAX)
|
|
20
|
+
role: str = Field(default=ACTION_VIEW, max_length=ROLE_MAX)
|
|
21
|
+
expires_at: datetime | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShareOut(SQLModel):
|
|
25
|
+
id: uuid.UUID
|
|
26
|
+
resource_type: str
|
|
27
|
+
resource_id: str
|
|
28
|
+
subject_type: str
|
|
29
|
+
subject_id: str
|
|
30
|
+
subject_label: str | None = None
|
|
31
|
+
role: str
|
|
32
|
+
granted_by: str
|
|
33
|
+
granted_by_label: str | None = None
|
|
34
|
+
expires_at: datetime | None = None
|
|
35
|
+
expired: bool = False
|
|
36
|
+
created_at: datetime | None = None
|
|
@@ -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
|
+
}
|