Skip to content

permissions ​

The permissions module decouples role-based and per-user permission grants from the framework's in-memory PermissionRegistry. It owns:

  • Two assignment tables (permissions_role_permission, permissions_user_permission).
  • An admin UI to edit them.
  • A grant source that feeds direct user grants into the framework's permission resolution.

There is one RequiresPermission, in simple_module_hosting.permissions. With this module installed it honours direct user grants as well as roles, and so does everything else that reads the resolved set: resolved_permissions_for, the menu filter, and the frontend's auth.permissions. permissions.deps.RequiresPermission is the same class, kept so existing imports still work. Before GH #337 they were separate classes, and a direct grant took effect only on routes that imported the permissions.deps one.

ModuleMeta ​

FieldValue
namePermissions
route_prefix/api/permissions
view_prefix/admin/permissions
depends_on["Auth", "Users"]

Routes ​

API ​

All require authentication. Read endpoints need permissions.view; mutate endpoints need permissions.manage.

Method + pathBody / responsePermission
GET /api/permissions/→ list[PermissionGroupOut]permissions.view
GET /api/permissions/roles/{role_id}→ RolePermissionsOutpermissions.view
PUT /api/permissions/roles/{role_id}RolePermissionsUpdate → RolePermissionsOutpermissions.manage
GET /api/permissions/users/{user_id}→ UserPermissionsOutpermissions.view
PUT /api/permissions/users/{user_id}UserPermissionsUpdate → UserPermissionsOutpermissions.manage

View ​

Method + pathInertia component / behaviourPermission
GET /admin/permissions/redirect to /admin/users/login
GET /admin/permissions/roles/{role_id}/editPermissions/RoleEditpermissions.manage
PUT /admin/permissions/roles/{role_id}form action; redirectspermissions.manage
GET /admin/permissions/users/{user_id}/editPermissions/UserEditpermissions.manage
PUT /admin/permissions/users/{user_id}form action; redirectspermissions.manage

Using RequiresPermission ​

python
from fastapi import APIRouter, Depends
from simple_module_hosting.permissions import RequiresPermission

router = APIRouter()


@router.delete(
    "/orders/{order_id}",
    dependencies=[Depends(RequiresPermission("orders.delete"))],
)
async def delete_order(order_id: int) -> None: ...

RequiresPermission(permission) takes a single permission key and 403s unless the request's user holds it, considering:

  1. The keys assigned to any of the user's roles.
  2. The keys assigned directly to the user (permissions_user_permission), contributed by permissions.grants.direct_grant_source via PermissionRegistry.add_grant_source.
  3. The implicit WILDCARD grant. The admin role is synced to hold every permission key at startup, so admins pass any check.

All three are resolved once per request by InertiaLayoutDataMiddleware and cached on request.state.resolved_permissions. auth.deps.require_permission(*keys) reads the same set, with any-of semantics.

Caching and propagation ​

The grant source runs on every authenticated request, so each process caches a user's direct grants for 30 seconds (permissions.grants.GRANTS_TTL_SECONDS). Saving a user's grants publishes permissions.user_grants on the InvalidationBus once the transaction commits. The worker that made the change sees it on the next request. Other workers see it immediately when background_tasks provides a Redis transport, and otherwise within the TTL.

Public contracts ​

python
from permissions.contracts.schemas import (
    PermissionGroupOut,
    RoleOut,
    RolePermissionsOut,
    RolePermissionsUpdate,
    UserOut,
    UserPermissionsOut,
    UserPermissionsUpdate,
)
ClassPurpose
PermissionGroupOutA named group (e.g. Orders) with the list of permission keys it owns. Built from the framework's PermissionRegistry.
RoleOutid, name, description.
RolePermissionsOutA role plus its assigned permission keys.
RolePermissionsUpdate{ "permissions": ["orders.view", ...] } — replaces the full set.
UserOutid, email, full_name.
UserPermissionsOutA user, the keys granted directly, and the keys inherited from roles.
UserPermissionsUpdate{ "permissions": [...] } — replaces the user's direct grants only.

Models ​

RolePermission (table permissions_role_permission)

ColumnTypeNotes
role_namestrcomposite PK
permission_keystrcomposite PK; indexed for reverse lookups
assigned_atdatetime
assigned_bystr | Noneactor email

UserPermission (table permissions_user_permission)

ColumnTypeNotes
user_idUUIDcomposite PK; references users_user.id
permission_keystrcomposite PK
assigned_atdatetime
assigned_bystr | Noneactor email

The schema deliberately keys by string permission key, not a normalised permissions table. Permission keys are the source of truth (registered at boot from register_permissions); the rows here are just assignments. If a key disappears from the registry, its assignments become inert (no FK to break) and the admin UI flags them as orphans.

Permissions ​

CodeGranted toPurpose
permissions.viewadminread groups, roles, user grants
permissions.manageadminedit role + user grants

(none) — the editor is reachable from the users admin pages (each user/role row links to its permissions edit page).

Inertia pages ​

  • Permissions/RoleEdit.tsx — checkbox grid grouped by PermissionGroup; submits the full set on save.
  • Permissions/UserEdit.tsx — same grid, with badges showing which permissions are inherited from roles vs granted directly.

Locales ​

Top-level keys in permissions/locales/en.json: browse, table, edit (role editor), user_edit (user editor), toasts, errors.

Released under the MIT License.