simple-module-permissions 0.0.1__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.
@@ -0,0 +1,59 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # Auto-generated frontend module manifest (regenerated by the host at boot
40
+ # or via `make gen-pages`).
41
+ host/client_app/modules.manifest.json
42
+ host/client_app/modules.generated.ts
43
+ host/client_app/modules.generated.css
44
+
45
+ # Worktrees
46
+ .worktrees/
47
+
48
+ # Performance profiles
49
+ .memray/
50
+ .benchmarks/
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ .playwright-cli/*
57
+ .playwright-mcp/*
58
+ host/client_app/.playwright-cli/*
59
+ .superpowers/
@@ -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,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_permissions
3
+ Version: 0.0.1
4
+ Summary: RBAC primitives — roles, permissions, @require_permission decorator, admin UI for simple_module
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: authorization,permissions,rbac,simple-module
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: simple-module-core==0.0.1
25
+ Requires-Dist: simple-module-db==0.0.1
26
+ Requires-Dist: simple-module-hosting==0.0.1
27
+ Requires-Dist: simple-module-users==0.0.1
28
+ Description-Content-Type: text/markdown
29
+
30
+ # simple_module_permissions
31
+
32
+ Role-based access control (RBAC) for [simple_module](https://github.com/antosubash/simple_module_python) apps. Users get roles, roles carry permissions, and route handlers declare required permissions at the decorator or dependency layer.
33
+
34
+ Pre-wired into any app scaffolded with `simple-module new`.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install simple_module_permissions
40
+ ```
41
+
42
+ ## What it provides
43
+
44
+ - `Role` and `Permission` SQLModel tables, seeded from module-registered defaults.
45
+ - `@require_permission("orders.read")` route decorator and `HasPermission("...")` dependency.
46
+ - Admin UI at `/permissions/admin` for assigning roles to users.
47
+ - `register_permissions()` hook — every module declares its permission strings at boot, the registry dedupes and persists them.
48
+
49
+ ## Usage
50
+
51
+ Declare permissions at module boot:
52
+
53
+ ```python
54
+ # modules/orders/orders/module.py
55
+ class OrdersModule(ModuleBase):
56
+ meta = ModuleMeta(name="orders")
57
+
58
+ def register_permissions(self):
59
+ return ["orders.read", "orders.write"]
60
+ ```
61
+
62
+ Guard a route:
63
+
64
+ ```python
65
+ from fastapi import APIRouter, Depends
66
+ from permissions.deps import HasPermission # type: ignore[import-not-found]
67
+
68
+ router = APIRouter()
69
+
70
+
71
+ @router.get("/orders", dependencies=[Depends(HasPermission("orders.read"))])
72
+ async def list_orders(): ...
73
+ ```
74
+
75
+ Admin flow: navigate to `/permissions/admin`, create a role, assign permissions, assign the role to users.
76
+
77
+ ## Depends on
78
+
79
+ - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_users`
80
+
81
+ ## License
82
+
83
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,54 @@
1
+ # simple_module_permissions
2
+
3
+ Role-based access control (RBAC) for [simple_module](https://github.com/antosubash/simple_module_python) apps. Users get roles, roles carry permissions, and route handlers declare required permissions at the decorator or dependency layer.
4
+
5
+ Pre-wired into any app scaffolded with `simple-module new`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install simple_module_permissions
11
+ ```
12
+
13
+ ## What it provides
14
+
15
+ - `Role` and `Permission` SQLModel tables, seeded from module-registered defaults.
16
+ - `@require_permission("orders.read")` route decorator and `HasPermission("...")` dependency.
17
+ - Admin UI at `/permissions/admin` for assigning roles to users.
18
+ - `register_permissions()` hook — every module declares its permission strings at boot, the registry dedupes and persists them.
19
+
20
+ ## Usage
21
+
22
+ Declare permissions at module boot:
23
+
24
+ ```python
25
+ # modules/orders/orders/module.py
26
+ class OrdersModule(ModuleBase):
27
+ meta = ModuleMeta(name="orders")
28
+
29
+ def register_permissions(self):
30
+ return ["orders.read", "orders.write"]
31
+ ```
32
+
33
+ Guard a route:
34
+
35
+ ```python
36
+ from fastapi import APIRouter, Depends
37
+ from permissions.deps import HasPermission # type: ignore[import-not-found]
38
+
39
+ router = APIRouter()
40
+
41
+
42
+ @router.get("/orders", dependencies=[Depends(HasPermission("orders.read"))])
43
+ async def list_orders(): ...
44
+ ```
45
+
46
+ Admin flow: navigate to `/permissions/admin`, create a role, assign permissions, assign the role to users.
47
+
48
+ ## Depends on
49
+
50
+ - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_users`
51
+
52
+ ## License
53
+
54
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/permissions",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the Permissions module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
@@ -0,0 +1 @@
1
+ """Permissions module."""
@@ -0,0 +1,13 @@
1
+ """Permission keys declared by this module.
2
+
3
+ Centralised so `register_permissions`, endpoint guards, and tests all
4
+ reference the same literal. Other modules that need to check one of these
5
+ permissions should import the constant rather than duplicating the string.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ PERMISSION_GROUP = "Permissions"
11
+
12
+ PERM_VIEW = "permissions.view"
13
+ PERM_MANAGE = "permissions.manage"
@@ -0,0 +1,21 @@
1
+ """Permissions contracts — public interface for other modules."""
2
+
3
+ from permissions.contracts.schemas import (
4
+ PermissionGroupOut,
5
+ RoleOut,
6
+ RolePermissionsOut,
7
+ RolePermissionsUpdate,
8
+ UserOut,
9
+ UserPermissionsOut,
10
+ UserPermissionsUpdate,
11
+ )
12
+
13
+ __all__ = [
14
+ "PermissionGroupOut",
15
+ "RoleOut",
16
+ "RolePermissionsOut",
17
+ "RolePermissionsUpdate",
18
+ "UserOut",
19
+ "UserPermissionsOut",
20
+ "UserPermissionsUpdate",
21
+ ]
@@ -0,0 +1,65 @@
1
+ """SQLModel DTOs for the Permissions module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from pydantic import ConfigDict
8
+ from sqlmodel import Field, SQLModel
9
+
10
+
11
+ class PermissionGroupOut(SQLModel):
12
+ """A named group of related permission keys (one per module)."""
13
+
14
+ name: str
15
+ permissions: list[str]
16
+
17
+
18
+ class RoleOut(SQLModel):
19
+ """A role as surfaced by the permissions admin UI."""
20
+
21
+ model_config = ConfigDict(from_attributes=True)
22
+
23
+ id: uuid.UUID
24
+ name: str
25
+ description: str | None = None
26
+
27
+
28
+ class RolePermissionsOut(SQLModel):
29
+ """A role together with its currently assigned permission keys."""
30
+
31
+ role: RoleOut
32
+ permissions: list[str]
33
+
34
+
35
+ class RolePermissionsUpdate(SQLModel):
36
+ """Replace the full set of permission keys assigned to a role."""
37
+
38
+ permissions: list[str] = Field(default_factory=list)
39
+
40
+
41
+ class UserOut(SQLModel):
42
+ """A user as surfaced by the permissions admin UI."""
43
+
44
+ model_config = ConfigDict(from_attributes=True)
45
+
46
+ id: uuid.UUID
47
+ email: str
48
+ full_name: str | None = None
49
+
50
+
51
+ class UserPermissionsOut(SQLModel):
52
+ """A user together with their direct and role-inherited permission keys."""
53
+
54
+ user: UserOut
55
+ roles: list[str]
56
+ direct: list[str]
57
+ """Keys granted directly to this user."""
58
+ inherited: list[str]
59
+ """Keys the user holds via any of their roles (excluding duplicates of ``direct``)."""
60
+
61
+
62
+ class UserPermissionsUpdate(SQLModel):
63
+ """Replace the full set of permission keys granted directly to a user."""
64
+
65
+ permissions: list[str] = Field(default_factory=list)
@@ -0,0 +1,72 @@
1
+ """FastAPI dependencies for the Permissions module.
2
+
3
+ In addition to the standard service wiring this module exports a
4
+ :class:`RequiresPermission` dependency that honours *both* role-based
5
+ and direct user grants — the framework's own
6
+ :class:`simple_module_hosting.permissions.RequiresPermission` checks
7
+ only roles, because the framework has no concept of user-direct grants.
8
+ Endpoints that want users to be able to hold individual permissions on
9
+ top of their roles should depend on this version instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+
16
+ from fastapi import Depends, HTTPException, Request
17
+ from simple_module_core.permissions import WILDCARD, PermissionRegistry
18
+ from simple_module_db.deps import get_db
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from permissions.service import PermissionService
22
+
23
+
24
+ def get_permission_registry(request: Request) -> PermissionRegistry:
25
+ return request.app.state.sm.permissions
26
+
27
+
28
+ async def get_permission_service(
29
+ db: AsyncSession = Depends(get_db),
30
+ registry: PermissionRegistry = Depends(get_permission_registry),
31
+ ) -> PermissionService:
32
+ return PermissionService(db, registry)
33
+
34
+
35
+ def assigned_by(request: Request) -> str | None:
36
+ """Authenticated user id string, for audit columns."""
37
+ user = getattr(request.state, "user", None)
38
+ return str(user.id) if user is not None else None
39
+
40
+
41
+ class RequiresPermission:
42
+ """FastAPI dependency enforcing a permission across roles *and* user grants.
43
+
44
+ Behaves like the framework's ``simple_module_hosting.RequiresPermission``
45
+ but additionally consults the ``permissions_user_permission`` table, so
46
+ a direct grant on a single user takes effect without inventing a role.
47
+ """
48
+
49
+ def __init__(self, permission: str) -> None:
50
+ self.permission = permission
51
+
52
+ async def __call__(
53
+ self,
54
+ request: Request,
55
+ service: PermissionService = Depends(get_permission_service),
56
+ ) -> None:
57
+ user = getattr(request.state, "user", None)
58
+ if user is None:
59
+ raise HTTPException(status_code=401, detail="Authentication required")
60
+
61
+ role_perms: set[str] = getattr(request.state, "resolved_permissions", set()) or set()
62
+ if WILDCARD in role_perms or self.permission in role_perms:
63
+ return
64
+
65
+ direct = await service.get_user_direct_keys(uuid.UUID(str(user.id)))
66
+ if self.permission in direct:
67
+ return
68
+
69
+ raise HTTPException(
70
+ status_code=403,
71
+ detail=f"Permission required: {self.permission}",
72
+ )
@@ -0,0 +1,102 @@
1
+ """REST API endpoints for Permissions administration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+
9
+ from permissions.constants import PERM_MANAGE, PERM_VIEW
10
+ from permissions.contracts.schemas import (
11
+ PermissionGroupOut,
12
+ RolePermissionsOut,
13
+ RolePermissionsUpdate,
14
+ UserPermissionsOut,
15
+ UserPermissionsUpdate,
16
+ )
17
+ from permissions.deps import RequiresPermission, assigned_by, get_permission_service
18
+ from permissions.service import PermissionService
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ @router.get(
24
+ "/",
25
+ response_model=list[PermissionGroupOut],
26
+ dependencies=[Depends(RequiresPermission(PERM_VIEW))],
27
+ )
28
+ async def list_registered(
29
+ service: PermissionService = Depends(get_permission_service),
30
+ ) -> list[PermissionGroupOut]:
31
+ """All permission keys auto-discovered from installed modules, grouped."""
32
+ return service.list_registered_groups()
33
+
34
+
35
+ # ── Role-scoped endpoints ──────────────────────────────────────
36
+
37
+
38
+ @router.get(
39
+ "/roles/{role_id}",
40
+ response_model=RolePermissionsOut,
41
+ dependencies=[Depends(RequiresPermission(PERM_VIEW))],
42
+ )
43
+ async def get_role_permissions(
44
+ role_id: uuid.UUID,
45
+ service: PermissionService = Depends(get_permission_service),
46
+ ) -> RolePermissionsOut:
47
+ result = await service.get_role_permissions(role_id)
48
+ if result is None:
49
+ raise HTTPException(status_code=404, detail="Role not found")
50
+ return result
51
+
52
+
53
+ @router.put(
54
+ "/roles/{role_id}",
55
+ response_model=RolePermissionsOut,
56
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
57
+ )
58
+ async def set_role_permissions(
59
+ role_id: uuid.UUID,
60
+ data: RolePermissionsUpdate,
61
+ request: Request,
62
+ service: PermissionService = Depends(get_permission_service),
63
+ ) -> RolePermissionsOut:
64
+ result = await service.set_role_permissions(role_id, data.permissions, assigned_by(request))
65
+ if result is None:
66
+ raise HTTPException(status_code=404, detail="Role not found")
67
+ return result
68
+
69
+
70
+ # ── User-scoped endpoints ──────────────────────────────────────
71
+
72
+
73
+ @router.get(
74
+ "/users/{user_id}",
75
+ response_model=UserPermissionsOut,
76
+ dependencies=[Depends(RequiresPermission(PERM_VIEW))],
77
+ )
78
+ async def get_user_permissions(
79
+ user_id: uuid.UUID,
80
+ service: PermissionService = Depends(get_permission_service),
81
+ ) -> UserPermissionsOut:
82
+ result = await service.get_user_permissions(user_id)
83
+ if result is None:
84
+ raise HTTPException(status_code=404, detail="User not found")
85
+ return result
86
+
87
+
88
+ @router.put(
89
+ "/users/{user_id}",
90
+ response_model=UserPermissionsOut,
91
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
92
+ )
93
+ async def set_user_permissions(
94
+ user_id: uuid.UUID,
95
+ data: UserPermissionsUpdate,
96
+ request: Request,
97
+ service: PermissionService = Depends(get_permission_service),
98
+ ) -> UserPermissionsOut:
99
+ result = await service.set_user_permissions(user_id, data.permissions, assigned_by(request))
100
+ if result is None:
101
+ raise HTTPException(status_code=404, detail="User not found")
102
+ return result
@@ -0,0 +1,123 @@
1
+ """Inertia view endpoints for Permissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, Depends, Request
8
+ from inertia import InertiaResponse
9
+ from pydantic import ValidationError
10
+ from simple_module_hosting.inertia_deps import InertiaDep
11
+ from simple_module_hosting.inertia_utils import (
12
+ redirect_back_with_errors,
13
+ validation_errors_to_dict,
14
+ )
15
+ from starlette.responses import RedirectResponse
16
+
17
+ from permissions.constants import PERM_MANAGE
18
+ from permissions.contracts.schemas import RolePermissionsUpdate, UserPermissionsUpdate
19
+ from permissions.deps import RequiresPermission, assigned_by, get_permission_service
20
+ from permissions.service import PermissionService
21
+
22
+ router = APIRouter()
23
+
24
+ _ADMIN_URL = "/users/admin"
25
+
26
+
27
+ @router.get("/", response_model=None)
28
+ async def browse() -> RedirectResponse:
29
+ return RedirectResponse(_ADMIN_URL, status_code=307)
30
+
31
+
32
+ # ── Role edit ──────────────────────────────────────────────────
33
+
34
+
35
+ @router.get(
36
+ "/roles/{role_id}/edit",
37
+ response_model=None,
38
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
39
+ )
40
+ async def edit_role(
41
+ role_id: uuid.UUID,
42
+ inertia: InertiaDep,
43
+ service: PermissionService = Depends(get_permission_service),
44
+ ) -> InertiaResponse | RedirectResponse:
45
+ assignment = await service.get_role_permissions(role_id)
46
+ if assignment is None:
47
+ return RedirectResponse(_ADMIN_URL, status_code=303)
48
+ groups = service.list_registered_groups()
49
+ return await inertia.render(
50
+ "Permissions/RoleEdit",
51
+ {
52
+ "role": assignment.role.model_dump(mode="json"),
53
+ "assigned": assignment.permissions,
54
+ "groups": [g.model_dump(mode="json") for g in groups],
55
+ },
56
+ )
57
+
58
+
59
+ @router.put(
60
+ "/roles/{role_id}",
61
+ response_model=None,
62
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
63
+ )
64
+ async def update_role(
65
+ role_id: uuid.UUID,
66
+ request: Request,
67
+ service: PermissionService = Depends(get_permission_service),
68
+ ) -> RedirectResponse:
69
+ body = await request.json()
70
+ try:
71
+ data = RolePermissionsUpdate(**body)
72
+ except ValidationError as exc:
73
+ return redirect_back_with_errors(request, validation_errors_to_dict(exc))
74
+ await service.set_role_permissions(role_id, data.permissions, assigned_by(request))
75
+ return RedirectResponse(_ADMIN_URL, status_code=303)
76
+
77
+
78
+ # ── User edit ──────────────────────────────────────────────────
79
+
80
+
81
+ @router.get(
82
+ "/users/{user_id}/edit",
83
+ response_model=None,
84
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
85
+ )
86
+ async def edit_user(
87
+ user_id: uuid.UUID,
88
+ inertia: InertiaDep,
89
+ service: PermissionService = Depends(get_permission_service),
90
+ ) -> InertiaResponse | RedirectResponse:
91
+ assignment = await service.get_user_permissions(user_id)
92
+ if assignment is None:
93
+ return RedirectResponse(_ADMIN_URL, status_code=303)
94
+ groups = service.list_registered_groups()
95
+ return await inertia.render(
96
+ "Permissions/UserEdit",
97
+ {
98
+ "user": assignment.user.model_dump(mode="json"),
99
+ "roles": assignment.roles,
100
+ "direct": assignment.direct,
101
+ "inherited": assignment.inherited,
102
+ "groups": [g.model_dump(mode="json") for g in groups],
103
+ },
104
+ )
105
+
106
+
107
+ @router.put(
108
+ "/users/{user_id}",
109
+ response_model=None,
110
+ dependencies=[Depends(RequiresPermission(PERM_MANAGE))],
111
+ )
112
+ async def update_user(
113
+ user_id: uuid.UUID,
114
+ request: Request,
115
+ service: PermissionService = Depends(get_permission_service),
116
+ ) -> RedirectResponse:
117
+ body = await request.json()
118
+ try:
119
+ data = UserPermissionsUpdate(**body)
120
+ except ValidationError as exc:
121
+ return redirect_back_with_errors(request, validation_errors_to_dict(exc))
122
+ await service.set_user_permissions(user_id, data.permissions, assigned_by(request))
123
+ return RedirectResponse(_ADMIN_URL, status_code=303)