simple-module-permissions 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- permissions/__init__.py +1 -0
- permissions/constants.py +13 -0
- permissions/contracts/__init__.py +21 -0
- permissions/contracts/schemas.py +65 -0
- permissions/deps.py +72 -0
- permissions/endpoints/__init__.py +0 -0
- permissions/endpoints/api.py +102 -0
- permissions/endpoints/views.py +123 -0
- permissions/locales/en.json +64 -0
- permissions/models.py +70 -0
- permissions/module.py +54 -0
- permissions/package.json +16 -0
- permissions/pages/RoleEdit.tsx +167 -0
- permissions/pages/UserEdit.tsx +187 -0
- permissions/py.typed +0 -0
- permissions/service.py +280 -0
- simple_module_permissions-0.0.1.dist-info/METADATA +83 -0
- simple_module_permissions-0.0.1.dist-info/RECORD +21 -0
- simple_module_permissions-0.0.1.dist-info/WHEEL +4 -0
- simple_module_permissions-0.0.1.dist-info/entry_points.txt +2 -0
- simple_module_permissions-0.0.1.dist-info/licenses/LICENSE +21 -0
permissions/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Permissions module."""
|
permissions/constants.py
ADDED
|
@@ -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)
|
permissions/deps.py
ADDED
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"browse": {
|
|
3
|
+
"title": "Permissions",
|
|
4
|
+
"description": "Permissions are auto-discovered from every installed module. Assign them to roles, or grant them directly to individual users.",
|
|
5
|
+
"roles_heading": "Roles",
|
|
6
|
+
"no_roles": "No roles exist yet.",
|
|
7
|
+
"users_heading": "Users",
|
|
8
|
+
"no_users": "No users match.",
|
|
9
|
+
"search_placeholder": "Search users by email or name",
|
|
10
|
+
"count_label": "{count} assigned",
|
|
11
|
+
"count_label_one": "{count} assigned",
|
|
12
|
+
"count_label_other": "{count} assigned",
|
|
13
|
+
"direct_count_label": "{count} direct",
|
|
14
|
+
"direct_count_label_one": "{count} direct",
|
|
15
|
+
"direct_count_label_other": "{count} direct",
|
|
16
|
+
"edit_link": "Edit",
|
|
17
|
+
"registry_heading": "Registry",
|
|
18
|
+
"registry_hint": "Modules declare these via ModuleBase.register_permissions — this list is rebuilt from code at every boot.",
|
|
19
|
+
"group_description": "Registered keys",
|
|
20
|
+
"no_permissions": "No modules have registered any permissions."
|
|
21
|
+
},
|
|
22
|
+
"table": {
|
|
23
|
+
"role": "Role",
|
|
24
|
+
"user": "User",
|
|
25
|
+
"name": "Name",
|
|
26
|
+
"description": "Description",
|
|
27
|
+
"assigned": "Assigned",
|
|
28
|
+
"direct": "Direct",
|
|
29
|
+
"actions": "Actions"
|
|
30
|
+
},
|
|
31
|
+
"toasts": {
|
|
32
|
+
"saved": "Permissions saved",
|
|
33
|
+
"save_failed": "Could not save permissions"
|
|
34
|
+
},
|
|
35
|
+
"edit": {
|
|
36
|
+
"title": "Edit permissions for {role}",
|
|
37
|
+
"description": "Toggle the permissions granted to every user holding this role.",
|
|
38
|
+
"empty": "No permissions have been registered by any installed module.",
|
|
39
|
+
"submit_button": "Save changes",
|
|
40
|
+
"reset_button": "Discard",
|
|
41
|
+
"cancel_link": "Back",
|
|
42
|
+
"selected_summary": "Selected",
|
|
43
|
+
"select_all_group": "Select all",
|
|
44
|
+
"clear_group": "Clear"
|
|
45
|
+
},
|
|
46
|
+
"user_edit": {
|
|
47
|
+
"title": "Permissions for {email}",
|
|
48
|
+
"description": "Grant permissions directly to this user. Inherited permissions come from the roles they hold.",
|
|
49
|
+
"roles_label": "Roles",
|
|
50
|
+
"no_roles": "No roles assigned",
|
|
51
|
+
"empty": "No permissions have been registered by any installed module.",
|
|
52
|
+
"inherited_badge": "inherited",
|
|
53
|
+
"inherited_hint": "Already granted via a role the user holds.",
|
|
54
|
+
"submit_button": "Save changes",
|
|
55
|
+
"reset_button": "Discard",
|
|
56
|
+
"cancel_link": "Back",
|
|
57
|
+
"direct_summary": "Direct",
|
|
58
|
+
"effective_summary": "Effective"
|
|
59
|
+
},
|
|
60
|
+
"errors": {
|
|
61
|
+
"role_not_found": "Role not found.",
|
|
62
|
+
"user_not_found": "User not found."
|
|
63
|
+
}
|
|
64
|
+
}
|
permissions/models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""SQLModel tables for the Permissions module.
|
|
2
|
+
|
|
3
|
+
The *set* of available permission keys lives in the in-memory
|
|
4
|
+
:class:`simple_module_core.permissions.PermissionRegistry` — each module
|
|
5
|
+
declares them via :meth:`ModuleBase.register_permissions`, and they are
|
|
6
|
+
auto-discovered at boot. What needs persistence is the mutable mapping
|
|
7
|
+
of those keys onto the principals that receive them:
|
|
8
|
+
|
|
9
|
+
* :class:`RolePermission` — keys granted to every user in a named role.
|
|
10
|
+
* :class:`UserPermission` — keys granted directly to a single user, on
|
|
11
|
+
top of (or independent of) any role they happen to hold.
|
|
12
|
+
|
|
13
|
+
Both junction rows are keyed on plain strings rather than FKs into the
|
|
14
|
+
``users`` schema — per-module :class:`MetaData` cannot express a
|
|
15
|
+
cross-module foreign key, and this keeps the permissions module
|
|
16
|
+
independent of ``users``' table layout.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
|
|
20
|
+
# Relationship resolution requires runtime annotations.
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
from fastapi_users_db_sqlalchemy.generics import GUID, now_utc
|
|
26
|
+
from simple_module_db.base import create_module_base
|
|
27
|
+
from simple_module_db.mixins import AuditMixin
|
|
28
|
+
from sqlalchemy import DateTime, Index
|
|
29
|
+
from sqlmodel import Field
|
|
30
|
+
|
|
31
|
+
# Provider is auto-detected from SM_DATABASE_URL (falls back to SQLite).
|
|
32
|
+
# On PostgreSQL this gives the module its own `permissions` schema; on SQLite
|
|
33
|
+
# all modules share one schema, so __tablename__ is prefixed for isolation.
|
|
34
|
+
Base = create_module_base("permissions")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RolePermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
38
|
+
"""Assignment of a registered permission key to a role."""
|
|
39
|
+
|
|
40
|
+
__tablename__ = "permissions_role_permission"
|
|
41
|
+
|
|
42
|
+
role_name: str = Field(max_length=64, primary_key=True)
|
|
43
|
+
permission_key: str = Field(max_length=128, primary_key=True)
|
|
44
|
+
assigned_at: datetime = Field(
|
|
45
|
+
default_factory=now_utc,
|
|
46
|
+
sa_type=DateTime(timezone=True),
|
|
47
|
+
)
|
|
48
|
+
assigned_by: str | None = Field(default=None, max_length=255)
|
|
49
|
+
|
|
50
|
+
# The composite PK covers role-first lookups; this index supports
|
|
51
|
+
# reverse lookups ("which roles hold permission X?") — PostgreSQL
|
|
52
|
+
# does not auto-index non-leading PK columns.
|
|
53
|
+
__table_args__ = (Index("ix_permissions_role_permission_key", "permission_key"),)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UserPermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
57
|
+
"""Direct assignment of a registered permission key to a single user."""
|
|
58
|
+
|
|
59
|
+
__tablename__ = "permissions_user_permission"
|
|
60
|
+
|
|
61
|
+
user_id: uuid.UUID = Field(sa_type=GUID, primary_key=True)
|
|
62
|
+
permission_key: str = Field(max_length=128, primary_key=True)
|
|
63
|
+
assigned_at: datetime = Field(
|
|
64
|
+
default_factory=now_utc,
|
|
65
|
+
sa_type=DateTime(timezone=True),
|
|
66
|
+
)
|
|
67
|
+
assigned_by: str | None = Field(default=None, max_length=255)
|
|
68
|
+
|
|
69
|
+
# Reverse lookups ("who has this permission directly?") are rare but cheap.
|
|
70
|
+
__table_args__ = (Index("ix_permissions_user_permission_key", "permission_key"),)
|
permissions/module.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Permissions module definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PermissionsModule(ModuleBase):
|
|
18
|
+
meta = ModuleMeta(
|
|
19
|
+
name="Permissions",
|
|
20
|
+
route_prefix="/api/permissions",
|
|
21
|
+
view_prefix="/permissions",
|
|
22
|
+
depends_on=["Auth", "Users"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
26
|
+
from permissions.endpoints.api import router as api
|
|
27
|
+
from permissions.endpoints.views import router as views
|
|
28
|
+
|
|
29
|
+
api_router.include_router(api)
|
|
30
|
+
view_router.include_router(views)
|
|
31
|
+
|
|
32
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
33
|
+
from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP
|
|
34
|
+
|
|
35
|
+
registry.add_group(
|
|
36
|
+
PERMISSION_GROUP,
|
|
37
|
+
[PERM_VIEW, PERM_MANAGE],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
41
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
42
|
+
return {"permissions": base}
|
|
43
|
+
|
|
44
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
45
|
+
"""Replay persisted role→permission rows into the live registry."""
|
|
46
|
+
from permissions.service import PermissionService
|
|
47
|
+
|
|
48
|
+
db_state = app.state.sm.db
|
|
49
|
+
registry = app.state.sm.permissions
|
|
50
|
+
async with db_state.session_factory() as db:
|
|
51
|
+
service = PermissionService(db, registry)
|
|
52
|
+
await service.load_all_into_registry()
|
|
53
|
+
await service.sync_admin_all_permissions()
|
|
54
|
+
await db.commit()
|
permissions/package.json
ADDED
|
@@ -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
|
+
}
|