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.
@@ -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)
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()
@@ -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
+ }