From 305eda3ef2102cfae95e98392e94cb94d16a8a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 08:04:40 +0000 Subject: [PATCH 1/7] feat(permissions): add module for role-to-permission assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `permissions` module persists the mutable `role -> [permission_key]` mapping and bridges it with the in-memory PermissionRegistry. Modules still declare their permission keys via `register_permissions`; admins can now assign those keys to roles at runtime through a UI/API without restarting the app. - `RolePermission` table keyed on role_name (keeps the module independent of users' schema — per-module metadata cannot express cross-module FKs) - Service: list registered groups, get/set role permissions, replace semantics with registry write-through and startup replay from DB - API: GET /api/permissions/, GET/PUT /api/permissions/roles/{role_id} guarded by permissions.view / permissions.manage - Inertia Browse (roles + permission groups) and RoleEdit (checkboxes) pages, admin-only sidebar entry, i18n strings - Alembic migration (branch_labels=("permissions",)) and tests covering service behaviour, API CRUD, and the register_permissions hook --- ...4_add_permissions_role_permission_table.py | 54 +++++ host/pyproject.toml | 2 + modules/permissions/permissions/__init__.py | 1 + .../permissions/contracts/__init__.py | 17 ++ .../permissions/contracts/schemas.py | 38 +++ .../permissions/contracts/service.py | 25 ++ modules/permissions/permissions/deps.py | 21 ++ .../permissions/endpoints/__init__.py | 0 .../permissions/permissions/endpoints/api.py | 64 ++++++ .../permissions/endpoints/views.py | 89 +++++++ .../permissions/permissions/locales/en.json | 23 ++ modules/permissions/permissions/models.py | 44 ++++ modules/permissions/permissions/module.py | 62 +++++ .../permissions/permissions/pages/Browse.tsx | 79 +++++++ .../permissions/pages/RoleEdit.tsx | 56 +++++ modules/permissions/permissions/py.typed | 0 modules/permissions/permissions/service.py | 133 +++++++++++ modules/permissions/pyproject.toml | 25 ++ .../tests/test_permissions_module.py | 186 +++++++++++++++ packages/i18n/src/generated-resources.ts | 16 ++ packages/i18n/src/keys.generated.ts | 217 ++++++++++-------- pyproject.toml | 3 +- 22 files changed, 1057 insertions(+), 98 deletions(-) create mode 100644 host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py create mode 100644 modules/permissions/permissions/__init__.py create mode 100644 modules/permissions/permissions/contracts/__init__.py create mode 100644 modules/permissions/permissions/contracts/schemas.py create mode 100644 modules/permissions/permissions/contracts/service.py create mode 100644 modules/permissions/permissions/deps.py create mode 100644 modules/permissions/permissions/endpoints/__init__.py create mode 100644 modules/permissions/permissions/endpoints/api.py create mode 100644 modules/permissions/permissions/endpoints/views.py create mode 100644 modules/permissions/permissions/locales/en.json create mode 100644 modules/permissions/permissions/models.py create mode 100644 modules/permissions/permissions/module.py create mode 100644 modules/permissions/permissions/pages/Browse.tsx create mode 100644 modules/permissions/permissions/pages/RoleEdit.tsx create mode 100644 modules/permissions/permissions/py.typed create mode 100644 modules/permissions/permissions/service.py create mode 100644 modules/permissions/pyproject.toml create mode 100644 modules/permissions/tests/test_permissions_module.py diff --git a/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py b/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py new file mode 100644 index 00000000..fdf16540 --- /dev/null +++ b/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py @@ -0,0 +1,54 @@ +"""add permissions role permission table + +Revision ID: 5d08d8587674 +Revises: b7e1af4c9d02 +Create Date: 2026-04-19 07:58:55.443793 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5d08d8587674" +down_revision: str | None = "b7e1af4c9d02" +branch_labels: str | Sequence[str] | None = ("permissions",) +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # On PostgreSQL, create the `permissions` schema before creating tables. + if op.get_context().dialect.name == "postgresql": + op.execute("CREATE SCHEMA IF NOT EXISTS permissions") + + op.create_table( + "permissions_role_permission", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("role_name", sa.String(length=64), nullable=False), + sa.Column("permission_key", sa.String(length=128), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint( + "role_name", + "permission_key", + name=op.f("pk_permissions_role_permission"), + ), + ) + + +def downgrade() -> None: + op.drop_table("permissions_role_permission") + + if op.get_context().dialect.name == "postgresql": + op.execute("DROP SCHEMA IF EXISTS permissions") diff --git a/host/pyproject.toml b/host/pyproject.toml index ed15268a..b58529af 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "auth", "dashboard", "products", + "permissions", "python-multipart>=0.0.6", ] @@ -16,3 +17,4 @@ simple-module-hosting = { workspace = true } auth = { workspace = true } products = { workspace = true } dashboard = { workspace = true } +permissions = { workspace = true } diff --git a/modules/permissions/permissions/__init__.py b/modules/permissions/permissions/__init__.py new file mode 100644 index 00000000..6cbe3a10 --- /dev/null +++ b/modules/permissions/permissions/__init__.py @@ -0,0 +1 @@ +"""Permissions module.""" diff --git a/modules/permissions/permissions/contracts/__init__.py b/modules/permissions/permissions/contracts/__init__.py new file mode 100644 index 00000000..876f2791 --- /dev/null +++ b/modules/permissions/permissions/contracts/__init__.py @@ -0,0 +1,17 @@ +"""Permissions contracts — public interface for other modules.""" + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RoleOut, + RolePermissionsOut, + RolePermissionsUpdate, +) +from permissions.contracts.service import IPermissionService + +__all__ = [ + "IPermissionService", + "PermissionGroupOut", + "RoleOut", + "RolePermissionsOut", + "RolePermissionsUpdate", +] diff --git a/modules/permissions/permissions/contracts/schemas.py b/modules/permissions/permissions/contracts/schemas.py new file mode 100644 index 00000000..92ffd151 --- /dev/null +++ b/modules/permissions/permissions/contracts/schemas.py @@ -0,0 +1,38 @@ +"""SQLModel DTOs for the Permissions module.""" + +from __future__ import annotations + +import uuid + +from pydantic import ConfigDict +from sqlmodel import Field, SQLModel + + +class PermissionGroupOut(SQLModel): + """A named group of related permission keys (one per module).""" + + name: str + permissions: list[str] + + +class RoleOut(SQLModel): + """A role as surfaced by the permissions admin UI.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None = None + + +class RolePermissionsOut(SQLModel): + """A role together with its currently assigned permission keys.""" + + role: RoleOut + permissions: list[str] + + +class RolePermissionsUpdate(SQLModel): + """Replace the full set of permission keys assigned to a role.""" + + permissions: list[str] = Field(default_factory=list) diff --git a/modules/permissions/permissions/contracts/service.py b/modules/permissions/permissions/contracts/service.py new file mode 100644 index 00000000..a6e15c71 --- /dev/null +++ b/modules/permissions/permissions/contracts/service.py @@ -0,0 +1,25 @@ +"""Permission service protocol — the public contract other modules depend on.""" + +from __future__ import annotations + +import uuid +from typing import Protocol + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RolePermissionsOut, +) + + +class IPermissionService(Protocol): + """Interface for permission-assignment operations.""" + + async def list_registered_groups(self) -> list[PermissionGroupOut]: ... + async def list_roles_with_counts(self) -> list[tuple[RolePermissionsOut, int]]: ... + async def get_role_permissions(self, role_id: uuid.UUID) -> RolePermissionsOut | None: ... + async def set_role_permissions( + self, + role_id: uuid.UUID, + keys: list[str], + assigned_by: str | None = None, + ) -> RolePermissionsOut | None: ... diff --git a/modules/permissions/permissions/deps.py b/modules/permissions/permissions/deps.py new file mode 100644 index 00000000..47944c12 --- /dev/null +++ b/modules/permissions/permissions/deps.py @@ -0,0 +1,21 @@ +"""FastAPI dependencies for the Permissions module.""" + +from __future__ import annotations + +from fastapi import Depends, Request +from simple_module_core.permissions import PermissionRegistry +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from permissions.service import PermissionService + + +def get_permission_registry(request: Request) -> PermissionRegistry: + return request.app.state.sm.permissions + + +async def get_permission_service( + db: AsyncSession = Depends(get_db), + registry: PermissionRegistry = Depends(get_permission_registry), +) -> PermissionService: + return PermissionService(db, registry) diff --git a/modules/permissions/permissions/endpoints/__init__.py b/modules/permissions/permissions/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/permissions/permissions/endpoints/api.py b/modules/permissions/permissions/endpoints/api.py new file mode 100644 index 00000000..38f97a40 --- /dev/null +++ b/modules/permissions/permissions/endpoints/api.py @@ -0,0 +1,64 @@ +"""REST API endpoints for Permissions administration.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request +from simple_module_hosting.permissions import RequiresPermission + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RolePermissionsOut, + RolePermissionsUpdate, +) +from permissions.deps import get_permission_service +from permissions.service import PermissionService + +router = APIRouter() + + +@router.get( + "/", + response_model=list[PermissionGroupOut], + dependencies=[Depends(RequiresPermission("permissions.view"))], +) +async def list_registered( + service: PermissionService = Depends(get_permission_service), +) -> list[PermissionGroupOut]: + """All permission keys registered by installed modules, grouped.""" + return service.list_registered_groups() + + +@router.get( + "/roles/{role_id}", + response_model=RolePermissionsOut, + dependencies=[Depends(RequiresPermission("permissions.view"))], +) +async def get_role_permissions( + role_id: uuid.UUID, + service: PermissionService = Depends(get_permission_service), +) -> RolePermissionsOut: + result = await service.get_role_permissions(role_id) + if result is None: + raise HTTPException(status_code=404, detail="Role not found") + return result + + +@router.put( + "/roles/{role_id}", + response_model=RolePermissionsOut, + dependencies=[Depends(RequiresPermission("permissions.manage"))], +) +async def set_role_permissions( + role_id: uuid.UUID, + data: RolePermissionsUpdate, + request: Request, + service: PermissionService = Depends(get_permission_service), +) -> RolePermissionsOut: + user = getattr(request.state, "user", None) + assigned_by = str(user.id) if user is not None else None + result = await service.set_role_permissions(role_id, data.permissions, assigned_by) + if result is None: + raise HTTPException(status_code=404, detail="Role not found") + return result diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/endpoints/views.py new file mode 100644 index 00000000..8ce68dfb --- /dev/null +++ b/modules/permissions/permissions/endpoints/views.py @@ -0,0 +1,89 @@ +"""Inertia view endpoints for Permissions.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Request +from inertia import InertiaResponse +from pydantic import ValidationError +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.inertia_utils import ( + redirect_back_with_errors, + validation_errors_to_dict, +) +from simple_module_hosting.permissions import RequiresPermission +from starlette.responses import RedirectResponse + +from permissions.contracts.schemas import RolePermissionsUpdate +from permissions.deps import get_permission_service +from permissions.service import PermissionService + +router = APIRouter() + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission("permissions.view"))], +) +async def browse( + inertia: InertiaDep, + service: PermissionService = Depends(get_permission_service), +) -> InertiaResponse: + groups = service.list_registered_groups() + roles = await service.list_roles_with_counts() + return await inertia.render( + "Permissions/Browse", + { + "groups": [g.model_dump(mode="json") for g in groups], + "roles": [ + {**role.model_dump(mode="json"), "permission_count": count} for role, count in roles + ], + }, + ) + + +@router.get( + "/roles/{role_id}/edit", + response_model=None, + dependencies=[Depends(RequiresPermission("permissions.manage"))], +) +async def edit_role( + role_id: uuid.UUID, + inertia: InertiaDep, + service: PermissionService = Depends(get_permission_service), +) -> InertiaResponse | RedirectResponse: + assignment = await service.get_role_permissions(role_id) + if assignment is None: + return RedirectResponse("/permissions", status_code=303) + groups = service.list_registered_groups() + return await inertia.render( + "Permissions/RoleEdit", + { + "role": assignment.role.model_dump(mode="json"), + "assigned": assignment.permissions, + "groups": [g.model_dump(mode="json") for g in groups], + }, + ) + + +@router.put( + "/roles/{role_id}", + response_model=None, + dependencies=[Depends(RequiresPermission("permissions.manage"))], +) +async def update_role( + role_id: uuid.UUID, + request: Request, + service: PermissionService = Depends(get_permission_service), +) -> RedirectResponse: + body = await request.json() + try: + data = RolePermissionsUpdate(**body) + except ValidationError as exc: + return redirect_back_with_errors(request, validation_errors_to_dict(exc)) + user = getattr(request.state, "user", None) + assigned_by = str(user.id) if user is not None else None + await service.set_role_permissions(role_id, data.permissions, assigned_by) + return RedirectResponse("/permissions", status_code=303) diff --git a/modules/permissions/permissions/locales/en.json b/modules/permissions/permissions/locales/en.json new file mode 100644 index 00000000..91206075 --- /dev/null +++ b/modules/permissions/permissions/locales/en.json @@ -0,0 +1,23 @@ +{ + "browse": { + "title": "Permissions", + "description": "Review the permissions registered by installed modules and assign them to roles.", + "roles_heading": "Roles", + "no_roles": "No roles exist yet.", + "count_label": "{{count}} assigned", + "count_label_one": "{{count}} assigned", + "count_label_other": "{{count}} assigned", + "edit_link": "Edit", + "registry_heading": "Registered permissions", + "no_permissions": "No modules have registered any permissions." + }, + "edit": { + "title": "Edit permissions for {{role}}", + "empty": "No permissions have been registered by any installed module.", + "submit_button": "Save", + "cancel_link": "Cancel" + }, + "errors": { + "role_not_found": "Role not found." + } +} diff --git a/modules/permissions/permissions/models.py b/modules/permissions/permissions/models.py new file mode 100644 index 00000000..ade27b81 --- /dev/null +++ b/modules/permissions/permissions/models.py @@ -0,0 +1,44 @@ +"""SQLModel tables for the Permissions module. + +The *set* of available permission keys lives in the in-memory +:class:`simple_module_core.permissions.PermissionRegistry` — each module +declares them via :meth:`ModuleBase.register_permissions`. What needs +persistence is the mutable role-to-permission mapping an admin edits at +runtime, so this module owns only that junction table. + +The junction keys on :attr:`users.models.Role.name` (which is uniquely +indexed) rather than ``role_id``. That keeps the schema independent of the +``users`` module's tables — per-module :class:`MetaData` cannot express a +cross-module foreign key — and matches how the in-memory +:class:`PermissionRegistry` already identifies roles (by name). +""" + +# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel +# Relationship resolution requires runtime annotations. + +from datetime import datetime + +from fastapi_users_db_sqlalchemy.generics import now_utc +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import DateTime +from sqlmodel import Field + +# Provider is auto-detected from SM_DATABASE_URL (falls back to SQLite). +# On PostgreSQL this gives the module its own `permissions` schema; on SQLite +# all modules share one schema, so __tablename__ is prefixed for isolation. +Base = create_module_base("permissions") + + +class RolePermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """Assignment of a registered permission key to a role.""" + + __tablename__ = "permissions_role_permission" + + role_name: str = Field(max_length=64, primary_key=True) + permission_key: str = Field(max_length=128, primary_key=True) + assigned_at: datetime = Field( + default_factory=now_utc, + sa_type=DateTime(timezone=True), + ) + assigned_by: str | None = Field(default=None, max_length=255) diff --git a/modules/permissions/permissions/module.py b/modules/permissions/permissions/module.py new file mode 100644 index 00000000..64b96933 --- /dev/null +++ b/modules/permissions/permissions/module.py @@ -0,0 +1,62 @@ +"""Permissions module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path +from typing import TYPE_CHECKING + +from fastapi import APIRouter +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +if TYPE_CHECKING: + from fastapi import FastAPI + + +class PermissionsModule(ModuleBase): + meta = ModuleMeta( + name="Permissions", + route_prefix="/api/permissions", + view_prefix="/permissions", + depends_on=["Auth", "Users"], + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from permissions.endpoints.api import router as api + from permissions.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Permissions", + url="/permissions", + icon="shield-check", + order=40, + section=MenuSection.SIDEBAR, + roles=["admin"], + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + "Permissions", + ["permissions.view", "permissions.manage"], + ) + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {"permissions": base} + + async def on_startup(self, app: FastAPI) -> None: + """Replay persisted role→permission rows into the live registry.""" + from permissions.service import PermissionService + + db_state = app.state.sm.db + registry = app.state.sm.permissions + async with db_state.session_factory() as db: + await PermissionService(db, registry).load_all_into_registry() diff --git a/modules/permissions/permissions/pages/Browse.tsx b/modules/permissions/permissions/pages/Browse.tsx new file mode 100644 index 00000000..fb6ff25a --- /dev/null +++ b/modules/permissions/permissions/pages/Browse.tsx @@ -0,0 +1,79 @@ +import { keys, useT } from '@simple-module/i18n'; + +type Group = { name: string; permissions: string[] }; +type Role = { + id: string; + name: string; + description: string | null; + permission_count: number; +}; + +type Props = { groups: Group[]; roles: Role[] }; + +export default function Browse({ groups, roles }: Props) { + const { t } = useT(); + return ( +
+
+

{t(keys.permissions.browse.title)}

+

+ {t(keys.permissions.browse.description)} +

+
+ +
+

{t(keys.permissions.browse.roles_heading)}

+ {roles.length === 0 ? ( +

{t(keys.permissions.browse.no_roles)}

+ ) : ( +
    + {roles.map((role) => ( +
  • +
    +
    {role.name}
    + {role.description && ( +
    {role.description}
    + )} +
    +
    + + {t(keys.permissions.browse.count_label, { + count: role.permission_count, + })} + + + {t(keys.permissions.browse.edit_link)} + +
    +
  • + ))} +
+ )} +
+ +
+

{t(keys.permissions.browse.registry_heading)}

+ {groups.length === 0 ? ( +

+ {t(keys.permissions.browse.no_permissions)} +

+ ) : ( +
+ {groups.map((group) => ( +
+
{group.name}
+
    + {group.permissions.map((key) => ( +
  • + {key} +
  • + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/modules/permissions/permissions/pages/RoleEdit.tsx b/modules/permissions/permissions/pages/RoleEdit.tsx new file mode 100644 index 00000000..c82b01ec --- /dev/null +++ b/modules/permissions/permissions/pages/RoleEdit.tsx @@ -0,0 +1,56 @@ +import { keys, useT } from '@simple-module/i18n'; + +type Group = { name: string; permissions: string[] }; +type Role = { id: string; name: string; description: string | null }; + +type Props = { role: Role; assigned: string[]; groups: Group[] }; + +export default function RoleEdit({ role, assigned, groups }: Props) { + const { t } = useT(); + const assignedSet = new Set(assigned); + + return ( +
+

+ {t(keys.permissions.edit.title, { role: role.name })} +

+ {role.description &&

{role.description}

} + +
+ + + {groups.length === 0 ? ( +

{t(keys.permissions.edit.empty)}

+ ) : ( + groups.map((group) => ( +
+ {group.name} +
+ {group.permissions.map((key) => ( + + ))} +
+
+ )) + )} + +
+ + + {t(keys.permissions.edit.cancel_link)} + +
+
+
+ ); +} diff --git a/modules/permissions/permissions/py.typed b/modules/permissions/permissions/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py new file mode 100644 index 00000000..80118011 --- /dev/null +++ b/modules/permissions/permissions/service.py @@ -0,0 +1,133 @@ +"""PermissionService — reads the in-memory registry and persists role maps.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RoleOut, + RolePermissionsOut, +) +from permissions.models import RolePermission + +if TYPE_CHECKING: + from simple_module_core.permissions import PermissionRegistry + from users.models import Role + + +class PermissionService: + """Bridges the in-memory :class:`PermissionRegistry` and the DB. + + The registry is the source of truth for *which* permission keys exist + (declared at boot via each module's ``register_permissions`` hook). The + DB stores the mutable ``role -> [key, ...]`` mapping that admins edit at + runtime, keyed by role *name* so this module remains independent of + ``users``' schema. + """ + + def __init__(self, db: AsyncSession, registry: PermissionRegistry) -> None: + self.db = db + self.registry = registry + + # ── Read ─────────────────────────────────────────────────── + + def list_registered_groups(self) -> list[PermissionGroupOut]: + return [ + PermissionGroupOut(name=g.name, permissions=sorted(g.permissions)) + for g in self.registry.groups + ] + + async def _load_roles(self) -> list[Role]: + from users.models import Role + + result = await self.db.execute(select(Role).order_by(Role.name)) + return list(result.scalars().all()) + + async def list_roles_with_counts(self) -> list[tuple[RoleOut, int]]: + roles = await self._load_roles() + counts_result = await self.db.execute( + select(RolePermission.role_name, func.count()).group_by(RolePermission.role_name) + ) + counts = dict(counts_result.all()) + return [(RoleOut.model_validate(r), counts.get(r.name, 0)) for r in roles] + + async def get_role(self, role_id: uuid.UUID) -> RoleOut | None: + from users.models import Role + + role = await self.db.get(Role, role_id) + return RoleOut.model_validate(role) if role is not None else None + + async def get_role_permissions(self, role_id: uuid.UUID) -> RolePermissionsOut | None: + role = await self.get_role(role_id) + if role is None: + return None + keys = await self._get_assigned_keys(role.name) + return RolePermissionsOut(role=role, permissions=sorted(keys)) + + async def _get_assigned_keys(self, role_name: str) -> list[str]: + result = await self.db.execute( + select(RolePermission.permission_key).where(RolePermission.role_name == role_name) + ) + return list(result.scalars().all()) + + # ── Write ────────────────────────────────────────────────── + + async def set_role_permissions( + self, + role_id: uuid.UUID, + keys: list[str], + assigned_by: str | None = None, + ) -> RolePermissionsOut | None: + role = await self.get_role(role_id) + if role is None: + return None + + registered = set(self.registry.all_permissions) + wanted = {k for k in keys if k in registered} + + existing = set(await self._get_assigned_keys(role.name)) + to_add = wanted - existing + to_remove = existing - wanted + + if to_remove: + await self.db.execute( + delete(RolePermission).where( + RolePermission.role_name == role.name, + RolePermission.permission_key.in_(to_remove), + ) + ) + for key in to_add: + self.db.add( + RolePermission( + role_name=role.name, + permission_key=key, + assigned_by=assigned_by, + ) + ) + await self.db.flush() + + # Reflect the change in the live registry so authz takes effect + # immediately without requiring a restart. `map_role` is additive by + # design — reset the entry first so a PUT really means "replace". + self.registry._role_map.pop(role.name, None) + self.registry.map_role(role.name, sorted(wanted)) + + return RolePermissionsOut(role=role, permissions=sorted(wanted)) + + # ── Startup sync ─────────────────────────────────────────── + + async def load_all_into_registry(self) -> None: + """Apply every persisted role→permission row onto the registry.""" + result = await self.db.execute( + select(RolePermission.role_name, RolePermission.permission_key) + ) + by_role: dict[str, list[str]] = {} + for name, key in result.all(): + by_role.setdefault(name, []).append(key) + for name, keys in by_role.items(): + self.registry.map_role(name, keys) diff --git a/modules/permissions/pyproject.toml b/modules/permissions/pyproject.toml new file mode 100644 index 00000000..16773004 --- /dev/null +++ b/modules/permissions/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "permissions" +version = "0.1.0" +description = "The Permissions module" +authors = [] +requires-python = ">=3.12" +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + "users", +] + +[project.entry-points.simple_module] +permissions = "permissions.module:PermissionsModule" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } +users = { workspace = true } diff --git a/modules/permissions/tests/test_permissions_module.py b/modules/permissions/tests/test_permissions_module.py new file mode 100644 index 00000000..0b7b9bfd --- /dev/null +++ b/modules/permissions/tests/test_permissions_module.py @@ -0,0 +1,186 @@ +"""Tests for the Permissions module: service, API, module hook.""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from fastapi import FastAPI +from permissions.module import PermissionsModule +from permissions.service import PermissionService +from simple_module_core.permissions import PermissionRegistry +from sqlalchemy.ext.asyncio import AsyncSession + +# ── register_permissions hook ────────────────────────────────── + + +class TestModuleHook: + def test_declares_view_and_manage(self): + reg = PermissionRegistry() + PermissionsModule().register_permissions(reg) + assert "permissions.view" in reg.all_permissions + assert "permissions.manage" in reg.all_permissions + + +# ── PermissionService ────────────────────────────────────────── + + +@pytest.fixture +def registry() -> PermissionRegistry: + reg = PermissionRegistry() + reg.add_group("Products", ["products.view", "products.create"]) + reg.add_group("Permissions", ["permissions.view", "permissions.manage"]) + return reg + + +async def _seed_user_role(db: AsyncSession) -> uuid.UUID: + """Insert a 'user' role and return its id.""" + from users.constants import USER_ROLE_ID + from users.models import Role + + role = Role(id=USER_ROLE_ID, name="user", description="Standard user") + db.add(role) + await db.flush() + return role.id + + +class TestPermissionService: + async def test_list_registered_groups( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + svc = PermissionService(db_session, registry) + groups = svc.list_registered_groups() + names = {g.name for g in groups} + assert {"Products", "Permissions"} <= names + + async def test_get_role_permissions_not_found( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + svc = PermissionService(db_session, registry) + result = await svc.get_role_permissions(uuid.uuid4()) + assert result is None + + async def test_set_and_get_role_permissions( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + svc = PermissionService(db_session, registry) + + updated = await svc.set_role_permissions(role_id, ["products.view", "products.create"]) + assert updated is not None + assert updated.permissions == ["products.create", "products.view"] + + fetched = await svc.get_role_permissions(role_id) + assert fetched is not None + assert set(fetched.permissions) == {"products.view", "products.create"} + + async def test_set_filters_unregistered_keys( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + svc = PermissionService(db_session, registry) + + updated = await svc.set_role_permissions(role_id, ["products.view", "bogus.key"]) + assert updated is not None + assert updated.permissions == ["products.view"] + + async def test_set_is_replace_semantics( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + svc = PermissionService(db_session, registry) + + await svc.set_role_permissions(role_id, ["products.view", "products.create"]) + await svc.set_role_permissions(role_id, ["products.view"]) + + fetched = await svc.get_role_permissions(role_id) + assert fetched is not None + assert fetched.permissions == ["products.view"] + + async def test_set_updates_registry_role_map( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + svc = PermissionService(db_session, registry) + + await svc.set_role_permissions(role_id, ["products.view"]) + assert "products.view" in registry.role_map.get("user", []) + + async def test_set_returns_none_for_missing_role( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + svc = PermissionService(db_session, registry) + result = await svc.set_role_permissions(uuid.uuid4(), ["products.view"]) + assert result is None + + async def test_list_roles_with_counts( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + svc = PermissionService(db_session, registry) + await svc.set_role_permissions(role_id, ["products.view", "products.create"]) + + rows = await svc.list_roles_with_counts() + counts = {row.name: count for row, count in rows} + assert counts.get("user") == 2 + + async def test_load_all_into_registry( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_user_role(db_session) + await PermissionService(db_session, registry).set_role_permissions( + role_id, ["products.view"] + ) + + # Fresh registry — load should re-populate it from DB. + fresh = PermissionRegistry() + fresh.add_group("Products", ["products.view", "products.create"]) + await PermissionService(db_session, fresh).load_all_into_registry() + assert "products.view" in fresh.role_map.get("user", []) + + +# ── API endpoints (admin-authenticated) ───────────────────────── + + +class TestPermissionsAPI: + async def test_list_registered(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/permissions/") + assert resp.status_code == 200 + groups = {g["name"] for g in resp.json()} + assert "Permissions" in groups + + async def test_get_role_permissions_not_found(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get(f"/api/permissions/roles/{uuid.uuid4()}") + assert resp.status_code == 404 + + async def test_put_and_get_role_permissions( + self, authenticated_client: httpx.AsyncClient, app: FastAPI + ): + from users.constants import ADMIN_ROLE_ID + + resp = await authenticated_client.put( + f"/api/permissions/roles/{ADMIN_ROLE_ID}", + json={"permissions": ["permissions.view"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["permissions"] == ["permissions.view"] + assert data["role"]["name"] == "admin" + + fetch = await authenticated_client.get(f"/api/permissions/roles/{ADMIN_ROLE_ID}") + assert fetch.status_code == 200 + assert fetch.json()["permissions"] == ["permissions.view"] + + async def test_put_missing_role_returns_404(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.put( + f"/api/permissions/roles/{uuid.uuid4()}", + json={"permissions": []}, + ) + assert resp.status_code == 404 + + async def test_list_requires_auth(self, client: httpx.AsyncClient): + # Unauthenticated API requests are redirected to the login page + # by the auth middleware before ever reaching RequiresPermission. + resp = await client.get("/api/permissions/") + assert resp.status_code in (302, 401, 403) diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 18d4a801..5906fef2 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,6 +1,7 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. + export default { translation: { 'auth.errors.missing_permission': '', @@ -47,6 +48,21 @@ export default { 'host.landing.hero_subtitle': '', 'host.landing.hero_title_line1': '', 'host.landing.hero_title_line2': '', + 'permissions.browse.count_label': '', + 'permissions.browse.count_label_one': '', + 'permissions.browse.count_label_other': '', + 'permissions.browse.description': '', + 'permissions.browse.edit_link': '', + 'permissions.browse.no_permissions': '', + 'permissions.browse.no_roles': '', + 'permissions.browse.registry_heading': '', + 'permissions.browse.roles_heading': '', + 'permissions.browse.title': '', + 'permissions.edit.cancel_link': '', + 'permissions.edit.empty': '', + 'permissions.edit.submit_button': '', + 'permissions.edit.title': '', + 'permissions.errors.role_not_found': '', 'products.browse.count_one': '', 'products.browse.count_other': '', 'products.browse.create_button': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index d0ee311e..b7b4f1b1 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,145 +4,168 @@ export const keys = { auth: { errors: { - missing_permission: 'auth.errors.missing_permission', - not_authenticated: 'auth.errors.not_authenticated', + missing_permission: "auth.errors.missing_permission", + not_authenticated: "auth.errors.not_authenticated", }, }, dashboard: { home: { - description: 'dashboard.home.description', - description_body: 'dashboard.home.description_body', + description: "dashboard.home.description", + description_body: "dashboard.home.description_body", stats: { - active_users: 'dashboard.home.stats.active_users', - modules: 'dashboard.home.stats.modules', - products: 'dashboard.home.stats.products', - total_users: 'dashboard.home.stats.total_users', + active_users: "dashboard.home.stats.active_users", + modules: "dashboard.home.stats.modules", + products: "dashboard.home.stats.products", + total_users: "dashboard.home.stats.total_users", }, system_info: { - health_checks: 'dashboard.home.system_info.health_checks', - modules: 'dashboard.home.system_info.modules', - python_version: 'dashboard.home.system_info.python_version', + health_checks: "dashboard.home.system_info.health_checks", + modules: "dashboard.home.system_info.modules", + python_version: "dashboard.home.system_info.python_version", }, - system_info_title: 'dashboard.home.system_info_title', - title: 'dashboard.home.title', - welcome_card_title: 'dashboard.home.welcome_card_title', - welcome_message: 'dashboard.home.welcome_message', + system_info_title: "dashboard.home.system_info_title", + title: "dashboard.home.title", + welcome_card_title: "dashboard.home.welcome_card_title", + welcome_message: "dashboard.home.welcome_message", }, }, host: { error: { - forbidden_description: 'host.error.forbidden_description', - forbidden_title: 'host.error.forbidden_title', - generic_description: 'host.error.generic_description', - generic_title: 'host.error.generic_title', - go_back: 'host.error.go_back', - go_home: 'host.error.go_home', - not_found_description: 'host.error.not_found_description', - not_found_title: 'host.error.not_found_title', - server_error_description: 'host.error.server_error_description', - server_error_title: 'host.error.server_error_title', + forbidden_description: "host.error.forbidden_description", + forbidden_title: "host.error.forbidden_title", + generic_description: "host.error.generic_description", + generic_title: "host.error.generic_title", + go_back: "host.error.go_back", + go_home: "host.error.go_home", + not_found_description: "host.error.not_found_description", + not_found_title: "host.error.not_found_title", + server_error_description: "host.error.server_error_description", + server_error_title: "host.error.server_error_title", }, landing: { - badge: 'host.landing.badge', - cta_dashboard: 'host.landing.cta_dashboard', - cta_docs: 'host.landing.cta_docs', - cta_get_started: 'host.landing.cta_get_started', + badge: "host.landing.badge", + cta_dashboard: "host.landing.cta_dashboard", + cta_docs: "host.landing.cta_docs", + cta_get_started: "host.landing.cta_get_started", features: { - auth_description: 'host.landing.features.auth_description', - auth_title: 'host.landing.features.auth_title', - devtools_description: 'host.landing.features.devtools_description', - devtools_title: 'host.landing.features.devtools_title', - diagnostics_description: 'host.landing.features.diagnostics_description', - diagnostics_title: 'host.landing.features.diagnostics_title', - inertia_description: 'host.landing.features.inertia_description', - inertia_title: 'host.landing.features.inertia_title', - module_system_description: 'host.landing.features.module_system_description', - module_system_title: 'host.landing.features.module_system_title', - schema_description: 'host.landing.features.schema_description', - schema_title: 'host.landing.features.schema_title', + auth_description: "host.landing.features.auth_description", + auth_title: "host.landing.features.auth_title", + devtools_description: "host.landing.features.devtools_description", + devtools_title: "host.landing.features.devtools_title", + diagnostics_description: "host.landing.features.diagnostics_description", + diagnostics_title: "host.landing.features.diagnostics_title", + inertia_description: "host.landing.features.inertia_description", + inertia_title: "host.landing.features.inertia_title", + module_system_description: "host.landing.features.module_system_description", + module_system_title: "host.landing.features.module_system_title", + schema_description: "host.landing.features.schema_description", + schema_title: "host.landing.features.schema_title", }, - hero_subtitle: 'host.landing.hero_subtitle', - hero_title_line1: 'host.landing.hero_title_line1', - hero_title_line2: 'host.landing.hero_title_line2', + hero_subtitle: "host.landing.hero_subtitle", + hero_title_line1: "host.landing.hero_title_line1", + hero_title_line2: "host.landing.hero_title_line2", + }, + }, + permissions: { + browse: { + count_label: "permissions.browse.count_label", + count_label_one: "permissions.browse.count_label_one", + count_label_other: "permissions.browse.count_label_other", + description: "permissions.browse.description", + edit_link: "permissions.browse.edit_link", + no_permissions: "permissions.browse.no_permissions", + no_roles: "permissions.browse.no_roles", + registry_heading: "permissions.browse.registry_heading", + roles_heading: "permissions.browse.roles_heading", + title: "permissions.browse.title", + }, + edit: { + cancel_link: "permissions.edit.cancel_link", + empty: "permissions.edit.empty", + submit_button: "permissions.edit.submit_button", + title: "permissions.edit.title", + }, + errors: { + role_not_found: "permissions.errors.role_not_found", }, }, products: { browse: { - count: 'products.browse.count', - count_one: 'products.browse.count_one', - count_other: 'products.browse.count_other', - create_button: 'products.browse.create_button', - description: 'products.browse.description', - empty_description: 'products.browse.empty_description', - empty_title: 'products.browse.empty_title', - new_button: 'products.browse.new_button', - no_match: 'products.browse.no_match', - search_placeholder: 'products.browse.search_placeholder', - title: 'products.browse.title', + count: "products.browse.count", + count_one: "products.browse.count_one", + count_other: "products.browse.count_other", + create_button: "products.browse.create_button", + description: "products.browse.description", + empty_description: "products.browse.empty_description", + empty_title: "products.browse.empty_title", + new_button: "products.browse.new_button", + no_match: "products.browse.no_match", + search_placeholder: "products.browse.search_placeholder", + title: "products.browse.title", }, create: { - description: 'products.create.description', - submit_button: 'products.create.submit_button', - submitting_button: 'products.create.submitting_button', - title: 'products.create.title', + description: "products.create.description", + submit_button: "products.create.submit_button", + submitting_button: "products.create.submitting_button", + title: "products.create.title", }, delete_dialog: { - cancel: 'products.delete_dialog.cancel', - confirm: 'products.delete_dialog.confirm', - description: 'products.delete_dialog.description', - title: 'products.delete_dialog.title', + cancel: "products.delete_dialog.cancel", + confirm: "products.delete_dialog.confirm", + description: "products.delete_dialog.description", + title: "products.delete_dialog.title", }, edit: { - back_button: 'products.edit.back_button', - description: 'products.edit.description', - submit_button: 'products.edit.submit_button', - submitting_button: 'products.edit.submitting_button', - title: 'products.edit.title', + back_button: "products.edit.back_button", + description: "products.edit.description", + submit_button: "products.edit.submit_button", + submitting_button: "products.edit.submitting_button", + title: "products.edit.title", }, errors: { - not_found: 'products.errors.not_found', + not_found: "products.errors.not_found", }, form: { - active_label: 'products.form.active_label', - cancel_button: 'products.form.cancel_button', - description_label: 'products.form.description_label', - description_placeholder: 'products.form.description_placeholder', - name_label: 'products.form.name_label', - name_placeholder: 'products.form.name_placeholder', - price_label: 'products.form.price_label', - price_placeholder: 'products.form.price_placeholder', + active_label: "products.form.active_label", + cancel_button: "products.form.cancel_button", + description_label: "products.form.description_label", + description_placeholder: "products.form.description_placeholder", + name_label: "products.form.name_label", + name_placeholder: "products.form.name_placeholder", + price_label: "products.form.price_label", + price_placeholder: "products.form.price_placeholder", }, table: { - actions: 'products.table.actions', - active: 'products.table.active', - description: 'products.table.description', - inactive: 'products.table.inactive', - name: 'products.table.name', - price: 'products.table.price', - status: 'products.table.status', + actions: "products.table.actions", + active: "products.table.active", + description: "products.table.description", + inactive: "products.table.inactive", + name: "products.table.name", + price: "products.table.price", + status: "products.table.status", }, toasts: { - created: 'products.toasts.created', - delete_failed: 'products.toasts.delete_failed', - deleted: 'products.toasts.deleted', - updated: 'products.toasts.updated', + created: "products.toasts.created", + delete_failed: "products.toasts.delete_failed", + deleted: "products.toasts.deleted", + updated: "products.toasts.updated", }, validation: { - name_required: 'products.validation.name_required', - name_too_long: 'products.validation.name_too_long', - price_positive: 'products.validation.price_positive', - price_required: 'products.validation.price_required', + name_required: "products.validation.name_required", + name_too_long: "products.validation.name_too_long", + price_positive: "products.validation.price_positive", + price_required: "products.validation.price_required", }, }, ui: { errors: { - generic_description: 'ui.errors.generic_description', - generic_title: 'ui.errors.generic_title', - go_home_button: 'ui.errors.go_home_button', - reload_button: 'ui.errors.reload_button', + generic_description: "ui.errors.generic_description", + generic_title: "ui.errors.generic_title", + go_home_button: "ui.errors.go_home_button", + reload_button: "ui.errors.reload_button", }, switcher: { - label: 'ui.switcher.label', + label: "ui.switcher.label", }, }, } as const; diff --git a/pyproject.toml b/pyproject.toml index 478569dd..5ed24711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ extra-paths = [ "modules/dashboard", "modules/products", "modules/users", + "modules/permissions", "host", ] @@ -95,7 +96,7 @@ invalid-argument-type = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/permissions/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", From 1aa28985ca2bf55ea49f6f6315d6ebdf93b2d2f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 08:19:22 +0000 Subject: [PATCH 2/7] refactor(permissions): replace hardcoded keys with constants Centralise the module's permission keys (`permissions.view`, `permissions.manage`) and group name in `permissions/constants.py` so `register_permissions`, endpoint guards, and tests all reference the same literal. Other modules that need to check one of these permissions can now import the constant instead of duplicating the string. --- modules/permissions/permissions/constants.py | 13 + .../permissions/permissions/endpoints/api.py | 7 +- .../permissions/endpoints/views.py | 7 +- modules/permissions/permissions/module.py | 6 +- .../tests/test_permissions_module.py | 15 +- packages/i18n/src/generated-resources.ts | 1 - packages/i18n/src/keys.generated.ts | 224 +++++++++--------- 7 files changed, 145 insertions(+), 128 deletions(-) create mode 100644 modules/permissions/permissions/constants.py diff --git a/modules/permissions/permissions/constants.py b/modules/permissions/permissions/constants.py new file mode 100644 index 00000000..24cbb857 --- /dev/null +++ b/modules/permissions/permissions/constants.py @@ -0,0 +1,13 @@ +"""Permission keys declared by this module. + +Centralised so `register_permissions`, endpoint guards, and tests all +reference the same literal. Other modules that need to check one of these +permissions should import the constant rather than duplicating the string. +""" + +from __future__ import annotations + +PERMISSION_GROUP = "Permissions" + +PERM_VIEW = "permissions.view" +PERM_MANAGE = "permissions.manage" diff --git a/modules/permissions/permissions/endpoints/api.py b/modules/permissions/permissions/endpoints/api.py index 38f97a40..73873f0d 100644 --- a/modules/permissions/permissions/endpoints/api.py +++ b/modules/permissions/permissions/endpoints/api.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from simple_module_hosting.permissions import RequiresPermission +from permissions.constants import PERM_MANAGE, PERM_VIEW from permissions.contracts.schemas import ( PermissionGroupOut, RolePermissionsOut, @@ -21,7 +22,7 @@ @router.get( "/", response_model=list[PermissionGroupOut], - dependencies=[Depends(RequiresPermission("permissions.view"))], + dependencies=[Depends(RequiresPermission(PERM_VIEW))], ) async def list_registered( service: PermissionService = Depends(get_permission_service), @@ -33,7 +34,7 @@ async def list_registered( @router.get( "/roles/{role_id}", response_model=RolePermissionsOut, - dependencies=[Depends(RequiresPermission("permissions.view"))], + dependencies=[Depends(RequiresPermission(PERM_VIEW))], ) async def get_role_permissions( role_id: uuid.UUID, @@ -48,7 +49,7 @@ async def get_role_permissions( @router.put( "/roles/{role_id}", response_model=RolePermissionsOut, - dependencies=[Depends(RequiresPermission("permissions.manage"))], + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], ) async def set_role_permissions( role_id: uuid.UUID, diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/endpoints/views.py index 8ce68dfb..2d5ca94d 100644 --- a/modules/permissions/permissions/endpoints/views.py +++ b/modules/permissions/permissions/endpoints/views.py @@ -15,6 +15,7 @@ from simple_module_hosting.permissions import RequiresPermission from starlette.responses import RedirectResponse +from permissions.constants import PERM_MANAGE, PERM_VIEW from permissions.contracts.schemas import RolePermissionsUpdate from permissions.deps import get_permission_service from permissions.service import PermissionService @@ -25,7 +26,7 @@ @router.get( "/", response_model=None, - dependencies=[Depends(RequiresPermission("permissions.view"))], + dependencies=[Depends(RequiresPermission(PERM_VIEW))], ) async def browse( inertia: InertiaDep, @@ -47,7 +48,7 @@ async def browse( @router.get( "/roles/{role_id}/edit", response_model=None, - dependencies=[Depends(RequiresPermission("permissions.manage"))], + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], ) async def edit_role( role_id: uuid.UUID, @@ -71,7 +72,7 @@ async def edit_role( @router.put( "/roles/{role_id}", response_model=None, - dependencies=[Depends(RequiresPermission("permissions.manage"))], + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], ) async def update_role( role_id: uuid.UUID, diff --git a/modules/permissions/permissions/module.py b/modules/permissions/permissions/module.py index 64b96933..294e8d39 100644 --- a/modules/permissions/permissions/module.py +++ b/modules/permissions/permissions/module.py @@ -43,9 +43,11 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) def register_permissions(self, registry: PermissionRegistry) -> None: + from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP + registry.add_group( - "Permissions", - ["permissions.view", "permissions.manage"], + PERMISSION_GROUP, + [PERM_VIEW, PERM_MANAGE], ) def locale_dirs(self) -> dict[str, Path]: diff --git a/modules/permissions/tests/test_permissions_module.py b/modules/permissions/tests/test_permissions_module.py index 0b7b9bfd..b1017233 100644 --- a/modules/permissions/tests/test_permissions_module.py +++ b/modules/permissions/tests/test_permissions_module.py @@ -7,6 +7,7 @@ import httpx import pytest from fastapi import FastAPI +from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP from permissions.module import PermissionsModule from permissions.service import PermissionService from simple_module_core.permissions import PermissionRegistry @@ -19,8 +20,8 @@ class TestModuleHook: def test_declares_view_and_manage(self): reg = PermissionRegistry() PermissionsModule().register_permissions(reg) - assert "permissions.view" in reg.all_permissions - assert "permissions.manage" in reg.all_permissions + assert PERM_VIEW in reg.all_permissions + assert PERM_MANAGE in reg.all_permissions # ── PermissionService ────────────────────────────────────────── @@ -30,7 +31,7 @@ def test_declares_view_and_manage(self): def registry() -> PermissionRegistry: reg = PermissionRegistry() reg.add_group("Products", ["products.view", "products.create"]) - reg.add_group("Permissions", ["permissions.view", "permissions.manage"]) + reg.add_group(PERMISSION_GROUP, [PERM_VIEW, PERM_MANAGE]) return reg @@ -148,7 +149,7 @@ async def test_list_registered(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get("/api/permissions/") assert resp.status_code == 200 groups = {g["name"] for g in resp.json()} - assert "Permissions" in groups + assert PERMISSION_GROUP in groups async def test_get_role_permissions_not_found(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get(f"/api/permissions/roles/{uuid.uuid4()}") @@ -161,16 +162,16 @@ async def test_put_and_get_role_permissions( resp = await authenticated_client.put( f"/api/permissions/roles/{ADMIN_ROLE_ID}", - json={"permissions": ["permissions.view"]}, + json={"permissions": [PERM_VIEW]}, ) assert resp.status_code == 200 data = resp.json() - assert data["permissions"] == ["permissions.view"] + assert data["permissions"] == [PERM_VIEW] assert data["role"]["name"] == "admin" fetch = await authenticated_client.get(f"/api/permissions/roles/{ADMIN_ROLE_ID}") assert fetch.status_code == 200 - assert fetch.json()["permissions"] == ["permissions.view"] + assert fetch.json()["permissions"] == [PERM_VIEW] async def test_put_missing_role_returns_404(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.put( diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 5906fef2..0927250a 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,7 +1,6 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. - export default { translation: { 'auth.errors.missing_permission': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index b7b4f1b1..94139ed9 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,168 +4,168 @@ export const keys = { auth: { errors: { - missing_permission: "auth.errors.missing_permission", - not_authenticated: "auth.errors.not_authenticated", + missing_permission: 'auth.errors.missing_permission', + not_authenticated: 'auth.errors.not_authenticated', }, }, dashboard: { home: { - description: "dashboard.home.description", - description_body: "dashboard.home.description_body", + description: 'dashboard.home.description', + description_body: 'dashboard.home.description_body', stats: { - active_users: "dashboard.home.stats.active_users", - modules: "dashboard.home.stats.modules", - products: "dashboard.home.stats.products", - total_users: "dashboard.home.stats.total_users", + active_users: 'dashboard.home.stats.active_users', + modules: 'dashboard.home.stats.modules', + products: 'dashboard.home.stats.products', + total_users: 'dashboard.home.stats.total_users', }, system_info: { - health_checks: "dashboard.home.system_info.health_checks", - modules: "dashboard.home.system_info.modules", - python_version: "dashboard.home.system_info.python_version", + health_checks: 'dashboard.home.system_info.health_checks', + modules: 'dashboard.home.system_info.modules', + python_version: 'dashboard.home.system_info.python_version', }, - system_info_title: "dashboard.home.system_info_title", - title: "dashboard.home.title", - welcome_card_title: "dashboard.home.welcome_card_title", - welcome_message: "dashboard.home.welcome_message", + system_info_title: 'dashboard.home.system_info_title', + title: 'dashboard.home.title', + welcome_card_title: 'dashboard.home.welcome_card_title', + welcome_message: 'dashboard.home.welcome_message', }, }, host: { error: { - forbidden_description: "host.error.forbidden_description", - forbidden_title: "host.error.forbidden_title", - generic_description: "host.error.generic_description", - generic_title: "host.error.generic_title", - go_back: "host.error.go_back", - go_home: "host.error.go_home", - not_found_description: "host.error.not_found_description", - not_found_title: "host.error.not_found_title", - server_error_description: "host.error.server_error_description", - server_error_title: "host.error.server_error_title", + forbidden_description: 'host.error.forbidden_description', + forbidden_title: 'host.error.forbidden_title', + generic_description: 'host.error.generic_description', + generic_title: 'host.error.generic_title', + go_back: 'host.error.go_back', + go_home: 'host.error.go_home', + not_found_description: 'host.error.not_found_description', + not_found_title: 'host.error.not_found_title', + server_error_description: 'host.error.server_error_description', + server_error_title: 'host.error.server_error_title', }, landing: { - badge: "host.landing.badge", - cta_dashboard: "host.landing.cta_dashboard", - cta_docs: "host.landing.cta_docs", - cta_get_started: "host.landing.cta_get_started", + badge: 'host.landing.badge', + cta_dashboard: 'host.landing.cta_dashboard', + cta_docs: 'host.landing.cta_docs', + cta_get_started: 'host.landing.cta_get_started', features: { - auth_description: "host.landing.features.auth_description", - auth_title: "host.landing.features.auth_title", - devtools_description: "host.landing.features.devtools_description", - devtools_title: "host.landing.features.devtools_title", - diagnostics_description: "host.landing.features.diagnostics_description", - diagnostics_title: "host.landing.features.diagnostics_title", - inertia_description: "host.landing.features.inertia_description", - inertia_title: "host.landing.features.inertia_title", - module_system_description: "host.landing.features.module_system_description", - module_system_title: "host.landing.features.module_system_title", - schema_description: "host.landing.features.schema_description", - schema_title: "host.landing.features.schema_title", + auth_description: 'host.landing.features.auth_description', + auth_title: 'host.landing.features.auth_title', + devtools_description: 'host.landing.features.devtools_description', + devtools_title: 'host.landing.features.devtools_title', + diagnostics_description: 'host.landing.features.diagnostics_description', + diagnostics_title: 'host.landing.features.diagnostics_title', + inertia_description: 'host.landing.features.inertia_description', + inertia_title: 'host.landing.features.inertia_title', + module_system_description: 'host.landing.features.module_system_description', + module_system_title: 'host.landing.features.module_system_title', + schema_description: 'host.landing.features.schema_description', + schema_title: 'host.landing.features.schema_title', }, - hero_subtitle: "host.landing.hero_subtitle", - hero_title_line1: "host.landing.hero_title_line1", - hero_title_line2: "host.landing.hero_title_line2", + hero_subtitle: 'host.landing.hero_subtitle', + hero_title_line1: 'host.landing.hero_title_line1', + hero_title_line2: 'host.landing.hero_title_line2', }, }, permissions: { browse: { - count_label: "permissions.browse.count_label", - count_label_one: "permissions.browse.count_label_one", - count_label_other: "permissions.browse.count_label_other", - description: "permissions.browse.description", - edit_link: "permissions.browse.edit_link", - no_permissions: "permissions.browse.no_permissions", - no_roles: "permissions.browse.no_roles", - registry_heading: "permissions.browse.registry_heading", - roles_heading: "permissions.browse.roles_heading", - title: "permissions.browse.title", + count_label: 'permissions.browse.count_label', + count_label_one: 'permissions.browse.count_label_one', + count_label_other: 'permissions.browse.count_label_other', + description: 'permissions.browse.description', + edit_link: 'permissions.browse.edit_link', + no_permissions: 'permissions.browse.no_permissions', + no_roles: 'permissions.browse.no_roles', + registry_heading: 'permissions.browse.registry_heading', + roles_heading: 'permissions.browse.roles_heading', + title: 'permissions.browse.title', }, edit: { - cancel_link: "permissions.edit.cancel_link", - empty: "permissions.edit.empty", - submit_button: "permissions.edit.submit_button", - title: "permissions.edit.title", + cancel_link: 'permissions.edit.cancel_link', + empty: 'permissions.edit.empty', + submit_button: 'permissions.edit.submit_button', + title: 'permissions.edit.title', }, errors: { - role_not_found: "permissions.errors.role_not_found", + role_not_found: 'permissions.errors.role_not_found', }, }, products: { browse: { - count: "products.browse.count", - count_one: "products.browse.count_one", - count_other: "products.browse.count_other", - create_button: "products.browse.create_button", - description: "products.browse.description", - empty_description: "products.browse.empty_description", - empty_title: "products.browse.empty_title", - new_button: "products.browse.new_button", - no_match: "products.browse.no_match", - search_placeholder: "products.browse.search_placeholder", - title: "products.browse.title", + count: 'products.browse.count', + count_one: 'products.browse.count_one', + count_other: 'products.browse.count_other', + create_button: 'products.browse.create_button', + description: 'products.browse.description', + empty_description: 'products.browse.empty_description', + empty_title: 'products.browse.empty_title', + new_button: 'products.browse.new_button', + no_match: 'products.browse.no_match', + search_placeholder: 'products.browse.search_placeholder', + title: 'products.browse.title', }, create: { - description: "products.create.description", - submit_button: "products.create.submit_button", - submitting_button: "products.create.submitting_button", - title: "products.create.title", + description: 'products.create.description', + submit_button: 'products.create.submit_button', + submitting_button: 'products.create.submitting_button', + title: 'products.create.title', }, delete_dialog: { - cancel: "products.delete_dialog.cancel", - confirm: "products.delete_dialog.confirm", - description: "products.delete_dialog.description", - title: "products.delete_dialog.title", + cancel: 'products.delete_dialog.cancel', + confirm: 'products.delete_dialog.confirm', + description: 'products.delete_dialog.description', + title: 'products.delete_dialog.title', }, edit: { - back_button: "products.edit.back_button", - description: "products.edit.description", - submit_button: "products.edit.submit_button", - submitting_button: "products.edit.submitting_button", - title: "products.edit.title", + back_button: 'products.edit.back_button', + description: 'products.edit.description', + submit_button: 'products.edit.submit_button', + submitting_button: 'products.edit.submitting_button', + title: 'products.edit.title', }, errors: { - not_found: "products.errors.not_found", + not_found: 'products.errors.not_found', }, form: { - active_label: "products.form.active_label", - cancel_button: "products.form.cancel_button", - description_label: "products.form.description_label", - description_placeholder: "products.form.description_placeholder", - name_label: "products.form.name_label", - name_placeholder: "products.form.name_placeholder", - price_label: "products.form.price_label", - price_placeholder: "products.form.price_placeholder", + active_label: 'products.form.active_label', + cancel_button: 'products.form.cancel_button', + description_label: 'products.form.description_label', + description_placeholder: 'products.form.description_placeholder', + name_label: 'products.form.name_label', + name_placeholder: 'products.form.name_placeholder', + price_label: 'products.form.price_label', + price_placeholder: 'products.form.price_placeholder', }, table: { - actions: "products.table.actions", - active: "products.table.active", - description: "products.table.description", - inactive: "products.table.inactive", - name: "products.table.name", - price: "products.table.price", - status: "products.table.status", + actions: 'products.table.actions', + active: 'products.table.active', + description: 'products.table.description', + inactive: 'products.table.inactive', + name: 'products.table.name', + price: 'products.table.price', + status: 'products.table.status', }, toasts: { - created: "products.toasts.created", - delete_failed: "products.toasts.delete_failed", - deleted: "products.toasts.deleted", - updated: "products.toasts.updated", + created: 'products.toasts.created', + delete_failed: 'products.toasts.delete_failed', + deleted: 'products.toasts.deleted', + updated: 'products.toasts.updated', }, validation: { - name_required: "products.validation.name_required", - name_too_long: "products.validation.name_too_long", - price_positive: "products.validation.price_positive", - price_required: "products.validation.price_required", + name_required: 'products.validation.name_required', + name_too_long: 'products.validation.name_too_long', + price_positive: 'products.validation.price_positive', + price_required: 'products.validation.price_required', }, }, ui: { errors: { - generic_description: "ui.errors.generic_description", - generic_title: "ui.errors.generic_title", - go_home_button: "ui.errors.go_home_button", - reload_button: "ui.errors.reload_button", + generic_description: 'ui.errors.generic_description', + generic_title: 'ui.errors.generic_title', + go_home_button: 'ui.errors.go_home_button', + reload_button: 'ui.errors.reload_button', }, switcher: { - label: "ui.switcher.label", + label: 'ui.switcher.label', }, }, } as const; From 15a4d8f3e1f40641fdfe5b820ef464c432faa582 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 09:47:57 +0000 Subject: [PATCH 3/7] feat(permissions): support direct grants to individual users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permissions were already auto-discovered from every module's `register_permissions` hook and assignable to roles. This adds the second axis — direct per-user grants — so an admin can hand a single user a capability without inventing a role. - `UserPermission` junction table (user_id, permission_key) - Service: get/set user grants, list users with counts, resolve effective permissions as `direct ∪ role-inherited`, dedupe inherited keys the user also holds directly - API: GET/PUT `/api/permissions/users/{user_id}` - Inertia `UserEdit` page grouped by module, marking inherited keys; Browse now lists users alongside roles with a search box - `permissions.deps.RequiresPermission` — drop-in for the framework's dependency that additionally consults per-user grants (cached on `request.state`). Framework's role-only version still works; modules opt in by importing from `permissions.deps` instead. - Alembic migration for the new table + expanded test coverage --- ...6_add_permissions_user_permission_table.py | 58 +++++ .../permissions/contracts/__init__.py | 6 + .../permissions/contracts/schemas.py | 27 ++ .../permissions/contracts/service.py | 10 +- modules/permissions/permissions/deps.py | 78 +++++- .../permissions/permissions/endpoints/api.py | 54 +++- .../permissions/endpoints/views.py | 73 +++++- .../permissions/permissions/locales/en.json | 21 +- modules/permissions/permissions/models.py | 43 +++- .../permissions/permissions/pages/Browse.tsx | 54 +++- .../permissions/pages/UserEdit.tsx | 82 ++++++ modules/permissions/permissions/service.py | 172 +++++++++++-- .../tests/test_permissions_module.py | 174 ++++++++++--- packages/i18n/src/generated-resources.ts | 16 ++ packages/i18n/src/keys.generated.ts | 241 ++++++++++-------- 15 files changed, 904 insertions(+), 205 deletions(-) create mode 100644 host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py create mode 100644 modules/permissions/permissions/pages/UserEdit.tsx diff --git a/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py b/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py new file mode 100644 index 00000000..b9b29111 --- /dev/null +++ b/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py @@ -0,0 +1,58 @@ +"""add permissions user permission table + +Revision ID: 9557a7c2e646 +Revises: 5d08d8587674 +Create Date: 2026-04-19 09:43:57.944179 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from fastapi_users_db_sqlalchemy.generics import GUID + +# revision identifiers, used by Alembic. +revision: str = "9557a7c2e646" +down_revision: str | None = "5d08d8587674" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "permissions_user_permission", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("user_id", GUID(), nullable=False), + sa.Column("permission_key", sa.String(length=128), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint( + "user_id", + "permission_key", + name=op.f("pk_permissions_user_permission"), + ), + ) + op.create_index( + "ix_permissions_user_permission_key", + "permissions_user_permission", + ["permission_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_permissions_user_permission_key", + table_name="permissions_user_permission", + ) + op.drop_table("permissions_user_permission") diff --git a/modules/permissions/permissions/contracts/__init__.py b/modules/permissions/permissions/contracts/__init__.py index 876f2791..cbdc8ffd 100644 --- a/modules/permissions/permissions/contracts/__init__.py +++ b/modules/permissions/permissions/contracts/__init__.py @@ -5,6 +5,9 @@ RoleOut, RolePermissionsOut, RolePermissionsUpdate, + UserOut, + UserPermissionsOut, + UserPermissionsUpdate, ) from permissions.contracts.service import IPermissionService @@ -14,4 +17,7 @@ "RoleOut", "RolePermissionsOut", "RolePermissionsUpdate", + "UserOut", + "UserPermissionsOut", + "UserPermissionsUpdate", ] diff --git a/modules/permissions/permissions/contracts/schemas.py b/modules/permissions/permissions/contracts/schemas.py index 92ffd151..29eee230 100644 --- a/modules/permissions/permissions/contracts/schemas.py +++ b/modules/permissions/permissions/contracts/schemas.py @@ -36,3 +36,30 @@ class RolePermissionsUpdate(SQLModel): """Replace the full set of permission keys assigned to a role.""" permissions: list[str] = Field(default_factory=list) + + +class UserOut(SQLModel): + """A user as surfaced by the permissions admin UI.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + full_name: str | None = None + + +class UserPermissionsOut(SQLModel): + """A user together with their direct and role-inherited permission keys.""" + + user: UserOut + roles: list[str] + direct: list[str] + """Keys granted directly to this user.""" + inherited: list[str] + """Keys the user holds via any of their roles (excluding duplicates of ``direct``).""" + + +class UserPermissionsUpdate(SQLModel): + """Replace the full set of permission keys granted directly to a user.""" + + permissions: list[str] = Field(default_factory=list) diff --git a/modules/permissions/permissions/contracts/service.py b/modules/permissions/permissions/contracts/service.py index a6e15c71..46553c9f 100644 --- a/modules/permissions/permissions/contracts/service.py +++ b/modules/permissions/permissions/contracts/service.py @@ -8,6 +8,7 @@ from permissions.contracts.schemas import ( PermissionGroupOut, RolePermissionsOut, + UserPermissionsOut, ) @@ -15,7 +16,6 @@ class IPermissionService(Protocol): """Interface for permission-assignment operations.""" async def list_registered_groups(self) -> list[PermissionGroupOut]: ... - async def list_roles_with_counts(self) -> list[tuple[RolePermissionsOut, int]]: ... async def get_role_permissions(self, role_id: uuid.UUID) -> RolePermissionsOut | None: ... async def set_role_permissions( self, @@ -23,3 +23,11 @@ async def set_role_permissions( keys: list[str], assigned_by: str | None = None, ) -> RolePermissionsOut | None: ... + async def get_user_permissions(self, user_id: uuid.UUID) -> UserPermissionsOut | None: ... + async def set_user_permissions( + self, + user_id: uuid.UUID, + keys: list[str], + assigned_by: str | None = None, + ) -> UserPermissionsOut | None: ... + async def resolve_effective_permissions(self, user_id: uuid.UUID) -> set[str]: ... diff --git a/modules/permissions/permissions/deps.py b/modules/permissions/permissions/deps.py index 47944c12..e3000c2c 100644 --- a/modules/permissions/permissions/deps.py +++ b/modules/permissions/permissions/deps.py @@ -1,14 +1,26 @@ -"""FastAPI dependencies for the Permissions module.""" +"""FastAPI dependencies for the Permissions module. + +In addition to the standard service wiring this module exports a +:class:`RequiresPermission` dependency that honours *both* role-based +and direct user grants — the framework's own +:class:`simple_module_hosting.permissions.RequiresPermission` checks +only roles, because the framework has no concept of user-direct grants. +Endpoints that want users to be able to hold individual permissions on +top of their roles should depend on this version instead. +""" from __future__ import annotations -from fastapi import Depends, Request -from simple_module_core.permissions import PermissionRegistry +from fastapi import Depends, HTTPException, Request +from simple_module_core.permissions import WILDCARD, PermissionRegistry from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession from permissions.service import PermissionService +# Marker key under which per-request user-direct grants are cached. +_USER_DIRECT_CACHE = "permissions_user_direct" + def get_permission_registry(request: Request) -> PermissionRegistry: return request.app.state.sm.permissions @@ -19,3 +31,63 @@ async def get_permission_service( registry: PermissionRegistry = Depends(get_permission_registry), ) -> PermissionService: return PermissionService(db, registry) + + +async def _get_user_direct_grants( + request: Request, + user_id: str, + service: PermissionService, +) -> set[str]: + """Fetch direct grants for the user, caching on ``request.state``.""" + cache: dict[str, set[str]] | None = getattr(request.state, _USER_DIRECT_CACHE, None) + if cache is None: + cache = {} + setattr(request.state, _USER_DIRECT_CACHE, cache) + if user_id not in cache: + import uuid + + keys = await service.get_user_direct_keys(uuid.UUID(user_id)) + cache[user_id] = set(keys) + return cache[user_id] + + +class RequiresPermission: + """FastAPI dependency enforcing a permission across roles *and* user grants. + + Behaves like the framework's ``simple_module_hosting.RequiresPermission`` + but additionally consults the ``permissions_user_permission`` table, so + an admin who grants ``products.create`` directly to a specific user + takes immediate effect for that user alone. + + Usage:: + + from permissions.deps import RequiresPermission + + @router.post("/", dependencies=[Depends(RequiresPermission("products.create"))]) + async def create_product(...): ... + """ + + def __init__(self, permission: str) -> None: + self.permission = permission + + async def __call__( + self, + request: Request, + service: PermissionService = Depends(get_permission_service), + ) -> None: + user = getattr(request.state, "user", None) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + + role_perms: set[str] = getattr(request.state, "resolved_permissions", set()) or set() + if WILDCARD in role_perms or self.permission in role_perms: + return + + direct = await _get_user_direct_grants(request, str(user.id), service) + if self.permission in direct: + return + + raise HTTPException( + status_code=403, + detail=f"Permission required: {self.permission}", + ) diff --git a/modules/permissions/permissions/endpoints/api.py b/modules/permissions/permissions/endpoints/api.py index 73873f0d..47fe6521 100644 --- a/modules/permissions/permissions/endpoints/api.py +++ b/modules/permissions/permissions/endpoints/api.py @@ -5,20 +5,26 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Request -from simple_module_hosting.permissions import RequiresPermission from permissions.constants import PERM_MANAGE, PERM_VIEW from permissions.contracts.schemas import ( PermissionGroupOut, RolePermissionsOut, RolePermissionsUpdate, + UserPermissionsOut, + UserPermissionsUpdate, ) -from permissions.deps import get_permission_service +from permissions.deps import RequiresPermission, get_permission_service from permissions.service import PermissionService router = APIRouter() +def _assigned_by(request: Request) -> str | None: + user = getattr(request.state, "user", None) + return str(user.id) if user is not None else None + + @router.get( "/", response_model=list[PermissionGroupOut], @@ -27,10 +33,13 @@ async def list_registered( service: PermissionService = Depends(get_permission_service), ) -> list[PermissionGroupOut]: - """All permission keys registered by installed modules, grouped.""" + """All permission keys auto-discovered from installed modules, grouped.""" return service.list_registered_groups() +# ── Role-scoped endpoints ────────────────────────────────────── + + @router.get( "/roles/{role_id}", response_model=RolePermissionsOut, @@ -57,9 +66,42 @@ async def set_role_permissions( request: Request, service: PermissionService = Depends(get_permission_service), ) -> RolePermissionsOut: - user = getattr(request.state, "user", None) - assigned_by = str(user.id) if user is not None else None - result = await service.set_role_permissions(role_id, data.permissions, assigned_by) + result = await service.set_role_permissions(role_id, data.permissions, _assigned_by(request)) if result is None: raise HTTPException(status_code=404, detail="Role not found") return result + + +# ── User-scoped endpoints ────────────────────────────────────── + + +@router.get( + "/users/{user_id}", + response_model=UserPermissionsOut, + dependencies=[Depends(RequiresPermission(PERM_VIEW))], +) +async def get_user_permissions( + user_id: uuid.UUID, + service: PermissionService = Depends(get_permission_service), +) -> UserPermissionsOut: + result = await service.get_user_permissions(user_id) + if result is None: + raise HTTPException(status_code=404, detail="User not found") + return result + + +@router.put( + "/users/{user_id}", + response_model=UserPermissionsOut, + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], +) +async def set_user_permissions( + user_id: uuid.UUID, + data: UserPermissionsUpdate, + request: Request, + service: PermissionService = Depends(get_permission_service), +) -> UserPermissionsOut: + result = await service.set_user_permissions(user_id, data.permissions, _assigned_by(request)) + if result is None: + raise HTTPException(status_code=404, detail="User not found") + return result diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/endpoints/views.py index 2d5ca94d..1d3edde4 100644 --- a/modules/permissions/permissions/endpoints/views.py +++ b/modules/permissions/permissions/endpoints/views.py @@ -4,7 +4,7 @@ import uuid -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from inertia import InertiaResponse from pydantic import ValidationError from simple_module_hosting.inertia_deps import InertiaDep @@ -12,17 +12,21 @@ redirect_back_with_errors, validation_errors_to_dict, ) -from simple_module_hosting.permissions import RequiresPermission from starlette.responses import RedirectResponse from permissions.constants import PERM_MANAGE, PERM_VIEW -from permissions.contracts.schemas import RolePermissionsUpdate -from permissions.deps import get_permission_service +from permissions.contracts.schemas import RolePermissionsUpdate, UserPermissionsUpdate +from permissions.deps import RequiresPermission, get_permission_service from permissions.service import PermissionService router = APIRouter() +def _assigned_by(request: Request) -> str | None: + user = getattr(request.state, "user", None) + return str(user.id) if user is not None else None + + @router.get( "/", response_model=None, @@ -30,10 +34,12 @@ ) async def browse( inertia: InertiaDep, + search: str = Query("", alias="q"), service: PermissionService = Depends(get_permission_service), ) -> InertiaResponse: groups = service.list_registered_groups() roles = await service.list_roles_with_counts() + users = await service.list_users_with_counts(search=search or None) return await inertia.render( "Permissions/Browse", { @@ -41,10 +47,17 @@ async def browse( "roles": [ {**role.model_dump(mode="json"), "permission_count": count} for role, count in roles ], + "users": [ + {**user.model_dump(mode="json"), "permission_count": count} for user, count in users + ], + "search": search, }, ) +# ── Role edit ────────────────────────────────────────────────── + + @router.get( "/roles/{role_id}/edit", response_model=None, @@ -84,7 +97,53 @@ async def update_role( data = RolePermissionsUpdate(**body) except ValidationError as exc: return redirect_back_with_errors(request, validation_errors_to_dict(exc)) - user = getattr(request.state, "user", None) - assigned_by = str(user.id) if user is not None else None - await service.set_role_permissions(role_id, data.permissions, assigned_by) + await service.set_role_permissions(role_id, data.permissions, _assigned_by(request)) + return RedirectResponse("/permissions", status_code=303) + + +# ── User edit ────────────────────────────────────────────────── + + +@router.get( + "/users/{user_id}/edit", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], +) +async def edit_user( + user_id: uuid.UUID, + inertia: InertiaDep, + service: PermissionService = Depends(get_permission_service), +) -> InertiaResponse | RedirectResponse: + assignment = await service.get_user_permissions(user_id) + if assignment is None: + return RedirectResponse("/permissions", status_code=303) + groups = service.list_registered_groups() + return await inertia.render( + "Permissions/UserEdit", + { + "user": assignment.user.model_dump(mode="json"), + "roles": assignment.roles, + "direct": assignment.direct, + "inherited": assignment.inherited, + "groups": [g.model_dump(mode="json") for g in groups], + }, + ) + + +@router.put( + "/users/{user_id}", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_MANAGE))], +) +async def update_user( + user_id: uuid.UUID, + request: Request, + service: PermissionService = Depends(get_permission_service), +) -> RedirectResponse: + body = await request.json() + try: + data = UserPermissionsUpdate(**body) + except ValidationError as exc: + return redirect_back_with_errors(request, validation_errors_to_dict(exc)) + await service.set_user_permissions(user_id, data.permissions, _assigned_by(request)) return RedirectResponse("/permissions", status_code=303) diff --git a/modules/permissions/permissions/locales/en.json b/modules/permissions/permissions/locales/en.json index 91206075..ebb54c6e 100644 --- a/modules/permissions/permissions/locales/en.json +++ b/modules/permissions/permissions/locales/en.json @@ -1,14 +1,21 @@ { "browse": { "title": "Permissions", - "description": "Review the permissions registered by installed modules and assign them to roles.", + "description": "Permissions are auto-discovered from every installed module. Assign them to roles, or grant them directly to individual users.", "roles_heading": "Roles", "no_roles": "No roles exist yet.", + "users_heading": "Users with direct grants", + "no_users": "No users match.", + "search_placeholder": "Search users by email or name", "count_label": "{{count}} assigned", "count_label_one": "{{count}} assigned", "count_label_other": "{{count}} assigned", + "direct_count_label": "{{count}} direct", + "direct_count_label_one": "{{count}} direct", + "direct_count_label_other": "{{count}} direct", "edit_link": "Edit", "registry_heading": "Registered permissions", + "registry_hint": "Modules declare these via ModuleBase.register_permissions — this list is rebuilt from code at every boot.", "no_permissions": "No modules have registered any permissions." }, "edit": { @@ -17,7 +24,17 @@ "submit_button": "Save", "cancel_link": "Cancel" }, + "user_edit": { + "title": "Edit direct permissions for {{email}}", + "roles_label": "Roles:", + "empty": "No permissions have been registered by any installed module.", + "inherited_badge": "inherited", + "inherited_hint": "Already granted via a role the user holds.", + "submit_button": "Save", + "cancel_link": "Cancel" + }, "errors": { - "role_not_found": "Role not found." + "role_not_found": "Role not found.", + "user_not_found": "User not found." } } diff --git a/modules/permissions/permissions/models.py b/modules/permissions/permissions/models.py index ade27b81..ffd6c3fc 100644 --- a/modules/permissions/permissions/models.py +++ b/modules/permissions/permissions/models.py @@ -2,26 +2,30 @@ The *set* of available permission keys lives in the in-memory :class:`simple_module_core.permissions.PermissionRegistry` — each module -declares them via :meth:`ModuleBase.register_permissions`. What needs -persistence is the mutable role-to-permission mapping an admin edits at -runtime, so this module owns only that junction table. - -The junction keys on :attr:`users.models.Role.name` (which is uniquely -indexed) rather than ``role_id``. That keeps the schema independent of the -``users`` module's tables — per-module :class:`MetaData` cannot express a -cross-module foreign key — and matches how the in-memory -:class:`PermissionRegistry` already identifies roles (by name). +declares them via :meth:`ModuleBase.register_permissions`, and they are +auto-discovered at boot. What needs persistence is the mutable mapping +of those keys onto the principals that receive them: + +* :class:`RolePermission` — keys granted to every user in a named role. +* :class:`UserPermission` — keys granted directly to a single user, on + top of (or independent of) any role they happen to hold. + +Both junction rows are keyed on plain strings rather than FKs into the +``users`` schema — per-module :class:`MetaData` cannot express a +cross-module foreign key, and this keeps the permissions module +independent of ``users``' table layout. """ # NOTE: intentionally no ``from __future__ import annotations`` — SQLModel # Relationship resolution requires runtime annotations. +import uuid from datetime import datetime -from fastapi_users_db_sqlalchemy.generics import now_utc +from fastapi_users_db_sqlalchemy.generics import GUID, now_utc from simple_module_db.base import create_module_base from simple_module_db.mixins import AuditMixin -from sqlalchemy import DateTime +from sqlalchemy import DateTime, Index from sqlmodel import Field # Provider is auto-detected from SM_DATABASE_URL (falls back to SQLite). @@ -42,3 +46,20 @@ class RolePermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-ba sa_type=DateTime(timezone=True), ) assigned_by: str | None = Field(default=None, max_length=255) + + +class UserPermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """Direct assignment of a registered permission key to a single user.""" + + __tablename__ = "permissions_user_permission" + + user_id: uuid.UUID = Field(sa_type=GUID, primary_key=True) + permission_key: str = Field(max_length=128, primary_key=True) + assigned_at: datetime = Field( + default_factory=now_utc, + sa_type=DateTime(timezone=True), + ) + assigned_by: str | None = Field(default=None, max_length=255) + + # Reverse lookups ("who has this permission directly?") are rare but cheap. + __table_args__ = (Index("ix_permissions_user_permission_key", "permission_key"),) diff --git a/modules/permissions/permissions/pages/Browse.tsx b/modules/permissions/permissions/pages/Browse.tsx index fb6ff25a..f104e40c 100644 --- a/modules/permissions/permissions/pages/Browse.tsx +++ b/modules/permissions/permissions/pages/Browse.tsx @@ -7,10 +7,16 @@ type Role = { description: string | null; permission_count: number; }; +type User = { + id: string; + email: string; + full_name: string | null; + permission_count: number; +}; -type Props = { groups: Group[]; roles: Role[] }; +type Props = { groups: Group[]; roles: Role[]; users: User[]; search: string }; -export default function Browse({ groups, roles }: Props) { +export default function Browse({ groups, roles, users, search }: Props) { const { t } = useT(); return (
@@ -51,8 +57,52 @@ export default function Browse({ groups, roles }: Props) { )} +
+
+

{t(keys.permissions.browse.users_heading)}

+
+ +
+
+ {users.length === 0 ? ( +

{t(keys.permissions.browse.no_users)}

+ ) : ( +
    + {users.map((user) => ( +
  • +
    +
    {user.email}
    + {user.full_name && ( +
    {user.full_name}
    + )} +
    +
    + + {t(keys.permissions.browse.direct_count_label, { + count: user.permission_count, + })} + + + {t(keys.permissions.browse.edit_link)} + +
    +
  • + ))} +
+ )} +
+

{t(keys.permissions.browse.registry_heading)}

+

+ {t(keys.permissions.browse.registry_hint)} +

{groups.length === 0 ? (

{t(keys.permissions.browse.no_permissions)} diff --git a/modules/permissions/permissions/pages/UserEdit.tsx b/modules/permissions/permissions/pages/UserEdit.tsx new file mode 100644 index 00000000..43607bfa --- /dev/null +++ b/modules/permissions/permissions/pages/UserEdit.tsx @@ -0,0 +1,82 @@ +import { keys, useT } from '@simple-module/i18n'; + +type Group = { name: string; permissions: string[] }; +type User = { id: string; email: string; full_name: string | null }; + +type Props = { + user: User; + roles: string[]; + direct: string[]; + inherited: string[]; + groups: Group[]; +}; + +export default function UserEdit({ user, roles, direct, inherited, groups }: Props) { + const { t } = useT(); + const directSet = new Set(direct); + const inheritedSet = new Set(inherited); + + return ( +

+

+ {t(keys.permissions.user_edit.title, { email: user.email })} +

+ {user.full_name &&

{user.full_name}

} + + {roles.length > 0 && ( +

+ {t(keys.permissions.user_edit.roles_label)} + {roles.join(', ')} +

+ )} + +
+ + + {groups.length === 0 ? ( +

{t(keys.permissions.user_edit.empty)}

+ ) : ( + groups.map((group) => ( +
+ {group.name} +
+ {group.permissions.map((key) => { + const fromRole = inheritedSet.has(key); + return ( + + ); + })} +
+
+ )) + )} + +
+ + + {t(keys.permissions.user_edit.cancel_link)} + +
+
+
+ ); +} diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 80118011..b9dfe314 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -1,4 +1,4 @@ -"""PermissionService — reads the in-memory registry and persists role maps.""" +"""PermissionService — reads the in-memory registry and persists role/user maps.""" from __future__ import annotations @@ -7,34 +7,38 @@ from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from permissions.contracts.schemas import ( PermissionGroupOut, RoleOut, RolePermissionsOut, + UserOut, + UserPermissionsOut, ) -from permissions.models import RolePermission +from permissions.models import RolePermission, UserPermission if TYPE_CHECKING: from simple_module_core.permissions import PermissionRegistry - from users.models import Role + from users.models import Role, User class PermissionService: """Bridges the in-memory :class:`PermissionRegistry` and the DB. The registry is the source of truth for *which* permission keys exist - (declared at boot via each module's ``register_permissions`` hook). The - DB stores the mutable ``role -> [key, ...]`` mapping that admins edit at - runtime, keyed by role *name* so this module remains independent of - ``users``' schema. + (auto-discovered at boot from each module's ``register_permissions`` + hook). The DB stores the mutable mapping of those keys onto: + + * roles — applied to every user holding that role + * users — granted directly on top of (or independent of) roles """ def __init__(self, db: AsyncSession, registry: PermissionRegistry) -> None: self.db = db self.registry = registry - # ── Read ─────────────────────────────────────────────────── + # ── Registry read-outs ───────────────────────────────────── def list_registered_groups(self) -> list[PermissionGroupOut]: return [ @@ -42,6 +46,11 @@ def list_registered_groups(self) -> list[PermissionGroupOut]: for g in self.registry.groups ] + def _registered_keys(self) -> set[str]: + return set(self.registry.all_permissions) + + # ── Role helpers ─────────────────────────────────────────── + async def _load_roles(self) -> list[Role]: from users.models import Role @@ -66,17 +75,15 @@ async def get_role_permissions(self, role_id: uuid.UUID) -> RolePermissionsOut | role = await self.get_role(role_id) if role is None: return None - keys = await self._get_assigned_keys(role.name) + keys = await self._get_role_keys(role.name) return RolePermissionsOut(role=role, permissions=sorted(keys)) - async def _get_assigned_keys(self, role_name: str) -> list[str]: + async def _get_role_keys(self, role_name: str) -> list[str]: result = await self.db.execute( select(RolePermission.permission_key).where(RolePermission.role_name == role_name) ) return list(result.scalars().all()) - # ── Write ────────────────────────────────────────────────── - async def set_role_permissions( self, role_id: uuid.UUID, @@ -87,11 +94,8 @@ async def set_role_permissions( if role is None: return None - registered = set(self.registry.all_permissions) - wanted = {k for k in keys if k in registered} - - existing = set(await self._get_assigned_keys(role.name)) - to_add = wanted - existing + wanted = {k for k in keys if k in self._registered_keys()} + existing = set(await self._get_role_keys(role.name)) to_remove = existing - wanted if to_remove: @@ -101,7 +105,7 @@ async def set_role_permissions( RolePermission.permission_key.in_(to_remove), ) ) - for key in to_add: + for key in wanted - existing: self.db.add( RolePermission( role_name=role.name, @@ -112,17 +116,145 @@ async def set_role_permissions( await self.db.flush() # Reflect the change in the live registry so authz takes effect - # immediately without requiring a restart. `map_role` is additive by + # immediately without requiring a restart. ``map_role`` is additive by # design — reset the entry first so a PUT really means "replace". self.registry._role_map.pop(role.name, None) self.registry.map_role(role.name, sorted(wanted)) return RolePermissionsOut(role=role, permissions=sorted(wanted)) + # ── User helpers ─────────────────────────────────────────── + + async def _get_user(self, user_id: uuid.UUID) -> User | None: + from users.models import User + + result = await self.db.execute( + select(User).where(User.id == user_id).options(selectinload(User.roles)) + ) + return result.scalar_one_or_none() + + async def _get_user_direct_keys(self, user_id: uuid.UUID) -> list[str]: + result = await self.db.execute( + select(UserPermission.permission_key).where(UserPermission.user_id == user_id) + ) + return list(result.scalars().all()) + + async def get_user_direct_keys(self, user_id: uuid.UUID) -> list[str]: + """Public wrapper — keys granted directly to *user_id* (no roles).""" + return await self._get_user_direct_keys(user_id) + + async def list_users_with_counts( + self, + *, + limit: int = 50, + search: str | None = None, + ) -> list[tuple[UserOut, int]]: + """Users who have at least one direct grant, plus an optional search.""" + from users.models import User + + stmt = select(User).limit(limit) + if search: + pattern = f"%{search}%" + stmt = stmt.where(User.email.ilike(pattern) | User.full_name.ilike(pattern)) + stmt = stmt.order_by(User.email) + + users_result = await self.db.execute(stmt) + users = list(users_result.scalars().all()) + + counts_result = await self.db.execute( + select(UserPermission.user_id, func.count()).group_by(UserPermission.user_id) + ) + counts = dict(counts_result.all()) + return [(UserOut.model_validate(u), counts.get(u.id, 0)) for u in users] + + async def get_user_permissions(self, user_id: uuid.UUID) -> UserPermissionsOut | None: + user = await self._get_user(user_id) + if user is None: + return None + direct = set(await self._get_user_direct_keys(user.id)) + role_names = [r.name for r in user.roles] + inherited = self._resolve_role_permissions(role_names) - direct + return UserPermissionsOut( + user=UserOut.model_validate(user), + roles=sorted(role_names), + direct=sorted(direct), + inherited=sorted(inherited), + ) + + async def set_user_permissions( + self, + user_id: uuid.UUID, + keys: list[str], + assigned_by: str | None = None, + ) -> UserPermissionsOut | None: + user = await self._get_user(user_id) + if user is None: + return None + + wanted = {k for k in keys if k in self._registered_keys()} + existing = set(await self._get_user_direct_keys(user.id)) + to_remove = existing - wanted + + if to_remove: + await self.db.execute( + delete(UserPermission).where( + UserPermission.user_id == user.id, + UserPermission.permission_key.in_(to_remove), + ) + ) + for key in wanted - existing: + self.db.add( + UserPermission( + user_id=user.id, + permission_key=key, + assigned_by=assigned_by, + ) + ) + await self.db.flush() + + role_names = [r.name for r in user.roles] + inherited = self._resolve_role_permissions(role_names) - wanted + return UserPermissionsOut( + user=UserOut.model_validate(user), + roles=sorted(role_names), + direct=sorted(wanted), + inherited=sorted(inherited), + ) + + # ── Effective-permissions resolution ─────────────────────── + + def _resolve_role_permissions(self, role_names: list[str]) -> set[str]: + """Resolve role names to their permission keys via the registry.""" + from simple_module_core.permissions import WILDCARD + + role_map = self.registry.role_map + resolved: set[str] = set() + for name in role_names: + perms = role_map.get(name, []) + if WILDCARD in perms: + return set(self.registry.all_permissions) + resolved.update(perms) + return resolved + + async def resolve_effective_permissions(self, user_id: uuid.UUID) -> set[str]: + """Return every permission key the user holds (role-inherited + direct).""" + user = await self._get_user(user_id) + if user is None: + return set() + direct = set(await self._get_user_direct_keys(user.id)) + inherited = self._resolve_role_permissions([r.name for r in user.roles]) + return direct | inherited + # ── Startup sync ─────────────────────────────────────────── async def load_all_into_registry(self) -> None: - """Apply every persisted role→permission row onto the registry.""" + """Apply every persisted role→permission row onto the registry. + + Runs on app startup so admin-assigned role permissions survive a + restart. Direct user grants are *not* replayed here because the + registry is role-scoped; user grants are resolved on every + request (see :meth:`resolve_effective_permissions`). + """ result = await self.db.execute( select(RolePermission.role_name, RolePermission.permission_key) ) diff --git a/modules/permissions/tests/test_permissions_module.py b/modules/permissions/tests/test_permissions_module.py index b1017233..7da74234 100644 --- a/modules/permissions/tests/test_permissions_module.py +++ b/modules/permissions/tests/test_permissions_module.py @@ -35,37 +35,60 @@ def registry() -> PermissionRegistry: return reg -async def _seed_user_role(db: AsyncSession) -> uuid.UUID: - """Insert a 'user' role and return its id.""" +async def _seed_role(db: AsyncSession, *, name: str = "user") -> uuid.UUID: from users.constants import USER_ROLE_ID from users.models import Role - role = Role(id=USER_ROLE_ID, name="user", description="Standard user") + role = Role(id=USER_ROLE_ID, name=name, description=f"{name} role") db.add(role) await db.flush() return role.id -class TestPermissionService: +async def _seed_user( + db: AsyncSession, + *, + email: str = "alice@test", + roles: list[uuid.UUID] | None = None, +) -> uuid.UUID: + from users.models import User, UserRole + + user_id = uuid.uuid4() + db.add( + User( + id=user_id, + email=email, + hashed_password="x", + is_active=True, + is_superuser=False, + is_verified=True, + ) + ) + await db.flush() + for rid in roles or []: + db.add(UserRole(user_id=user_id, role_id=rid)) + await db.flush() + return user_id + + +class TestRoleService: async def test_list_registered_groups( self, db_session: AsyncSession, registry: PermissionRegistry ): svc = PermissionService(db_session, registry) - groups = svc.list_registered_groups() - names = {g.name for g in groups} - assert {"Products", "Permissions"} <= names + names = {g.name for g in svc.list_registered_groups()} + assert {"Products", PERMISSION_GROUP} <= names async def test_get_role_permissions_not_found( self, db_session: AsyncSession, registry: PermissionRegistry ): svc = PermissionService(db_session, registry) - result = await svc.get_role_permissions(uuid.uuid4()) - assert result is None + assert await svc.get_role_permissions(uuid.uuid4()) is None async def test_set_and_get_role_permissions( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) + role_id = await _seed_role(db_session) svc = PermissionService(db_session, registry) updated = await svc.set_role_permissions(role_id, ["products.view", "products.create"]) @@ -79,9 +102,8 @@ async def test_set_and_get_role_permissions( async def test_set_filters_unregistered_keys( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) + role_id = await _seed_role(db_session) svc = PermissionService(db_session, registry) - updated = await svc.set_role_permissions(role_id, ["products.view", "bogus.key"]) assert updated is not None assert updated.permissions == ["products.view"] @@ -89,12 +111,10 @@ async def test_set_filters_unregistered_keys( async def test_set_is_replace_semantics( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) + role_id = await _seed_role(db_session) svc = PermissionService(db_session, registry) - await svc.set_role_permissions(role_id, ["products.view", "products.create"]) await svc.set_role_permissions(role_id, ["products.view"]) - fetched = await svc.get_role_permissions(role_id) assert fetched is not None assert fetched.permissions == ["products.view"] @@ -102,43 +122,91 @@ async def test_set_is_replace_semantics( async def test_set_updates_registry_role_map( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) + role_id = await _seed_role(db_session) svc = PermissionService(db_session, registry) - await svc.set_role_permissions(role_id, ["products.view"]) assert "products.view" in registry.role_map.get("user", []) - async def test_set_returns_none_for_missing_role( + async def test_load_all_into_registry( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_role(db_session) + await PermissionService(db_session, registry).set_role_permissions( + role_id, ["products.view"] + ) + fresh = PermissionRegistry() + fresh.add_group("Products", ["products.view", "products.create"]) + await PermissionService(db_session, fresh).load_all_into_registry() + assert "products.view" in fresh.role_map.get("user", []) + + +class TestUserService: + async def test_get_user_permissions_not_found( self, db_session: AsyncSession, registry: PermissionRegistry ): svc = PermissionService(db_session, registry) - result = await svc.set_role_permissions(uuid.uuid4(), ["products.view"]) - assert result is None + assert await svc.get_user_permissions(uuid.uuid4()) is None - async def test_list_roles_with_counts( + async def test_set_and_get_user_permissions( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) + user_id = await _seed_user(db_session) svc = PermissionService(db_session, registry) - await svc.set_role_permissions(role_id, ["products.view", "products.create"]) - rows = await svc.list_roles_with_counts() - counts = {row.name: count for row, count in rows} - assert counts.get("user") == 2 + updated = await svc.set_user_permissions(user_id, ["products.view"]) + assert updated is not None + assert updated.direct == ["products.view"] + assert updated.inherited == [] - async def test_load_all_into_registry( + fetched = await svc.get_user_permissions(user_id) + assert fetched is not None + assert fetched.direct == ["products.view"] + + async def test_set_filters_unregistered_keys_for_user( self, db_session: AsyncSession, registry: PermissionRegistry ): - role_id = await _seed_user_role(db_session) - await PermissionService(db_session, registry).set_role_permissions( - role_id, ["products.view"] - ) + user_id = await _seed_user(db_session) + svc = PermissionService(db_session, registry) + updated = await svc.set_user_permissions(user_id, ["products.view", "bogus"]) + assert updated is not None + assert updated.direct == ["products.view"] - # Fresh registry — load should re-populate it from DB. - fresh = PermissionRegistry() - fresh.add_group("Products", ["products.view", "products.create"]) - await PermissionService(db_session, fresh).load_all_into_registry() - assert "products.view" in fresh.role_map.get("user", []) + async def test_inherited_deduplicated_from_direct( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_role(db_session) + user_id = await _seed_user(db_session, roles=[role_id]) + svc = PermissionService(db_session, registry) + + await svc.set_role_permissions(role_id, ["products.view"]) + await svc.set_user_permissions(user_id, ["products.view", "products.create"]) + + fetched = await svc.get_user_permissions(user_id) + assert fetched is not None + assert fetched.direct == ["products.create", "products.view"] + # products.view is held both directly and via the role → should NOT + # also appear in `inherited` (that surface should only list keys the + # user gets *only* from their roles). + assert fetched.inherited == [] + + async def test_resolve_effective_permissions_union( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + role_id = await _seed_role(db_session) + user_id = await _seed_user(db_session, roles=[role_id]) + svc = PermissionService(db_session, registry) + + await svc.set_role_permissions(role_id, ["products.view"]) + await svc.set_user_permissions(user_id, ["products.create"]) + + effective = await svc.resolve_effective_permissions(user_id) + assert effective == {"products.view", "products.create"} + + async def test_resolve_effective_permissions_missing_user( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + svc = PermissionService(db_session, registry) + assert await svc.resolve_effective_permissions(uuid.uuid4()) == set() # ── API endpoints (admin-authenticated) ───────────────────────── @@ -148,8 +216,7 @@ class TestPermissionsAPI: async def test_list_registered(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get("/api/permissions/") assert resp.status_code == 200 - groups = {g["name"] for g in resp.json()} - assert PERMISSION_GROUP in groups + assert PERMISSION_GROUP in {g["name"] for g in resp.json()} async def test_get_role_permissions_not_found(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get(f"/api/permissions/roles/{uuid.uuid4()}") @@ -165,9 +232,8 @@ async def test_put_and_get_role_permissions( json={"permissions": [PERM_VIEW]}, ) assert resp.status_code == 200 - data = resp.json() - assert data["permissions"] == [PERM_VIEW] - assert data["role"]["name"] == "admin" + assert resp.json()["permissions"] == [PERM_VIEW] + assert resp.json()["role"]["name"] == "admin" fetch = await authenticated_client.get(f"/api/permissions/roles/{ADMIN_ROLE_ID}") assert fetch.status_code == 200 @@ -180,6 +246,32 @@ async def test_put_missing_role_returns_404(self, authenticated_client: httpx.As ) assert resp.status_code == 404 + async def test_get_user_permissions_not_found(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get(f"/api/permissions/users/{uuid.uuid4()}") + assert resp.status_code == 404 + + async def test_put_and_get_user_permissions( + self, authenticated_client: httpx.AsyncClient, app: FastAPI + ): + # The authenticated_client fixture seeded admin@test — grant a direct + # permission to that user and round-trip it through the API. + async with app.state.sm.db.session_factory() as db: + from sqlalchemy import select + from users.models import User + + user_id = (await db.execute(select(User.id))).scalar_one() + + resp = await authenticated_client.put( + f"/api/permissions/users/{user_id}", + json={"permissions": [PERM_VIEW]}, + ) + assert resp.status_code == 200 + assert resp.json()["direct"] == [PERM_VIEW] + + fetch = await authenticated_client.get(f"/api/permissions/users/{user_id}") + assert fetch.status_code == 200 + assert fetch.json()["direct"] == [PERM_VIEW] + async def test_list_requires_auth(self, client: httpx.AsyncClient): # Unauthenticated API requests are redirected to the login page # by the auth middleware before ever reaching RequiresPermission. diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 0927250a..2a64c0cb 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,6 +1,7 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. + export default { translation: { 'auth.errors.missing_permission': '', @@ -51,17 +52,32 @@ export default { 'permissions.browse.count_label_one': '', 'permissions.browse.count_label_other': '', 'permissions.browse.description': '', + 'permissions.browse.direct_count_label': '', + 'permissions.browse.direct_count_label_one': '', + 'permissions.browse.direct_count_label_other': '', 'permissions.browse.edit_link': '', 'permissions.browse.no_permissions': '', 'permissions.browse.no_roles': '', + 'permissions.browse.no_users': '', 'permissions.browse.registry_heading': '', + 'permissions.browse.registry_hint': '', 'permissions.browse.roles_heading': '', + 'permissions.browse.search_placeholder': '', 'permissions.browse.title': '', + 'permissions.browse.users_heading': '', 'permissions.edit.cancel_link': '', 'permissions.edit.empty': '', 'permissions.edit.submit_button': '', 'permissions.edit.title': '', 'permissions.errors.role_not_found': '', + 'permissions.errors.user_not_found': '', + 'permissions.user_edit.cancel_link': '', + 'permissions.user_edit.empty': '', + 'permissions.user_edit.inherited_badge': '', + 'permissions.user_edit.inherited_hint': '', + 'permissions.user_edit.roles_label': '', + 'permissions.user_edit.submit_button': '', + 'permissions.user_edit.title': '', 'products.browse.count_one': '', 'products.browse.count_other': '', 'products.browse.create_button': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 94139ed9..28319b80 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,168 +4,185 @@ export const keys = { auth: { errors: { - missing_permission: 'auth.errors.missing_permission', - not_authenticated: 'auth.errors.not_authenticated', + missing_permission: "auth.errors.missing_permission", + not_authenticated: "auth.errors.not_authenticated", }, }, dashboard: { home: { - description: 'dashboard.home.description', - description_body: 'dashboard.home.description_body', + description: "dashboard.home.description", + description_body: "dashboard.home.description_body", stats: { - active_users: 'dashboard.home.stats.active_users', - modules: 'dashboard.home.stats.modules', - products: 'dashboard.home.stats.products', - total_users: 'dashboard.home.stats.total_users', + active_users: "dashboard.home.stats.active_users", + modules: "dashboard.home.stats.modules", + products: "dashboard.home.stats.products", + total_users: "dashboard.home.stats.total_users", }, system_info: { - health_checks: 'dashboard.home.system_info.health_checks', - modules: 'dashboard.home.system_info.modules', - python_version: 'dashboard.home.system_info.python_version', + health_checks: "dashboard.home.system_info.health_checks", + modules: "dashboard.home.system_info.modules", + python_version: "dashboard.home.system_info.python_version", }, - system_info_title: 'dashboard.home.system_info_title', - title: 'dashboard.home.title', - welcome_card_title: 'dashboard.home.welcome_card_title', - welcome_message: 'dashboard.home.welcome_message', + system_info_title: "dashboard.home.system_info_title", + title: "dashboard.home.title", + welcome_card_title: "dashboard.home.welcome_card_title", + welcome_message: "dashboard.home.welcome_message", }, }, host: { error: { - forbidden_description: 'host.error.forbidden_description', - forbidden_title: 'host.error.forbidden_title', - generic_description: 'host.error.generic_description', - generic_title: 'host.error.generic_title', - go_back: 'host.error.go_back', - go_home: 'host.error.go_home', - not_found_description: 'host.error.not_found_description', - not_found_title: 'host.error.not_found_title', - server_error_description: 'host.error.server_error_description', - server_error_title: 'host.error.server_error_title', + forbidden_description: "host.error.forbidden_description", + forbidden_title: "host.error.forbidden_title", + generic_description: "host.error.generic_description", + generic_title: "host.error.generic_title", + go_back: "host.error.go_back", + go_home: "host.error.go_home", + not_found_description: "host.error.not_found_description", + not_found_title: "host.error.not_found_title", + server_error_description: "host.error.server_error_description", + server_error_title: "host.error.server_error_title", }, landing: { - badge: 'host.landing.badge', - cta_dashboard: 'host.landing.cta_dashboard', - cta_docs: 'host.landing.cta_docs', - cta_get_started: 'host.landing.cta_get_started', + badge: "host.landing.badge", + cta_dashboard: "host.landing.cta_dashboard", + cta_docs: "host.landing.cta_docs", + cta_get_started: "host.landing.cta_get_started", features: { - auth_description: 'host.landing.features.auth_description', - auth_title: 'host.landing.features.auth_title', - devtools_description: 'host.landing.features.devtools_description', - devtools_title: 'host.landing.features.devtools_title', - diagnostics_description: 'host.landing.features.diagnostics_description', - diagnostics_title: 'host.landing.features.diagnostics_title', - inertia_description: 'host.landing.features.inertia_description', - inertia_title: 'host.landing.features.inertia_title', - module_system_description: 'host.landing.features.module_system_description', - module_system_title: 'host.landing.features.module_system_title', - schema_description: 'host.landing.features.schema_description', - schema_title: 'host.landing.features.schema_title', + auth_description: "host.landing.features.auth_description", + auth_title: "host.landing.features.auth_title", + devtools_description: "host.landing.features.devtools_description", + devtools_title: "host.landing.features.devtools_title", + diagnostics_description: "host.landing.features.diagnostics_description", + diagnostics_title: "host.landing.features.diagnostics_title", + inertia_description: "host.landing.features.inertia_description", + inertia_title: "host.landing.features.inertia_title", + module_system_description: "host.landing.features.module_system_description", + module_system_title: "host.landing.features.module_system_title", + schema_description: "host.landing.features.schema_description", + schema_title: "host.landing.features.schema_title", }, - hero_subtitle: 'host.landing.hero_subtitle', - hero_title_line1: 'host.landing.hero_title_line1', - hero_title_line2: 'host.landing.hero_title_line2', + hero_subtitle: "host.landing.hero_subtitle", + hero_title_line1: "host.landing.hero_title_line1", + hero_title_line2: "host.landing.hero_title_line2", }, }, permissions: { browse: { - count_label: 'permissions.browse.count_label', - count_label_one: 'permissions.browse.count_label_one', - count_label_other: 'permissions.browse.count_label_other', - description: 'permissions.browse.description', - edit_link: 'permissions.browse.edit_link', - no_permissions: 'permissions.browse.no_permissions', - no_roles: 'permissions.browse.no_roles', - registry_heading: 'permissions.browse.registry_heading', - roles_heading: 'permissions.browse.roles_heading', - title: 'permissions.browse.title', + count_label: "permissions.browse.count_label", + count_label_one: "permissions.browse.count_label_one", + count_label_other: "permissions.browse.count_label_other", + description: "permissions.browse.description", + direct_count_label: "permissions.browse.direct_count_label", + direct_count_label_one: "permissions.browse.direct_count_label_one", + direct_count_label_other: "permissions.browse.direct_count_label_other", + edit_link: "permissions.browse.edit_link", + no_permissions: "permissions.browse.no_permissions", + no_roles: "permissions.browse.no_roles", + no_users: "permissions.browse.no_users", + registry_heading: "permissions.browse.registry_heading", + registry_hint: "permissions.browse.registry_hint", + roles_heading: "permissions.browse.roles_heading", + search_placeholder: "permissions.browse.search_placeholder", + title: "permissions.browse.title", + users_heading: "permissions.browse.users_heading", }, edit: { - cancel_link: 'permissions.edit.cancel_link', - empty: 'permissions.edit.empty', - submit_button: 'permissions.edit.submit_button', - title: 'permissions.edit.title', + cancel_link: "permissions.edit.cancel_link", + empty: "permissions.edit.empty", + submit_button: "permissions.edit.submit_button", + title: "permissions.edit.title", }, errors: { - role_not_found: 'permissions.errors.role_not_found', + role_not_found: "permissions.errors.role_not_found", + user_not_found: "permissions.errors.user_not_found", + }, + user_edit: { + cancel_link: "permissions.user_edit.cancel_link", + empty: "permissions.user_edit.empty", + inherited_badge: "permissions.user_edit.inherited_badge", + inherited_hint: "permissions.user_edit.inherited_hint", + roles_label: "permissions.user_edit.roles_label", + submit_button: "permissions.user_edit.submit_button", + title: "permissions.user_edit.title", }, }, products: { browse: { - count: 'products.browse.count', - count_one: 'products.browse.count_one', - count_other: 'products.browse.count_other', - create_button: 'products.browse.create_button', - description: 'products.browse.description', - empty_description: 'products.browse.empty_description', - empty_title: 'products.browse.empty_title', - new_button: 'products.browse.new_button', - no_match: 'products.browse.no_match', - search_placeholder: 'products.browse.search_placeholder', - title: 'products.browse.title', + count: "products.browse.count", + count_one: "products.browse.count_one", + count_other: "products.browse.count_other", + create_button: "products.browse.create_button", + description: "products.browse.description", + empty_description: "products.browse.empty_description", + empty_title: "products.browse.empty_title", + new_button: "products.browse.new_button", + no_match: "products.browse.no_match", + search_placeholder: "products.browse.search_placeholder", + title: "products.browse.title", }, create: { - description: 'products.create.description', - submit_button: 'products.create.submit_button', - submitting_button: 'products.create.submitting_button', - title: 'products.create.title', + description: "products.create.description", + submit_button: "products.create.submit_button", + submitting_button: "products.create.submitting_button", + title: "products.create.title", }, delete_dialog: { - cancel: 'products.delete_dialog.cancel', - confirm: 'products.delete_dialog.confirm', - description: 'products.delete_dialog.description', - title: 'products.delete_dialog.title', + cancel: "products.delete_dialog.cancel", + confirm: "products.delete_dialog.confirm", + description: "products.delete_dialog.description", + title: "products.delete_dialog.title", }, edit: { - back_button: 'products.edit.back_button', - description: 'products.edit.description', - submit_button: 'products.edit.submit_button', - submitting_button: 'products.edit.submitting_button', - title: 'products.edit.title', + back_button: "products.edit.back_button", + description: "products.edit.description", + submit_button: "products.edit.submit_button", + submitting_button: "products.edit.submitting_button", + title: "products.edit.title", }, errors: { - not_found: 'products.errors.not_found', + not_found: "products.errors.not_found", }, form: { - active_label: 'products.form.active_label', - cancel_button: 'products.form.cancel_button', - description_label: 'products.form.description_label', - description_placeholder: 'products.form.description_placeholder', - name_label: 'products.form.name_label', - name_placeholder: 'products.form.name_placeholder', - price_label: 'products.form.price_label', - price_placeholder: 'products.form.price_placeholder', + active_label: "products.form.active_label", + cancel_button: "products.form.cancel_button", + description_label: "products.form.description_label", + description_placeholder: "products.form.description_placeholder", + name_label: "products.form.name_label", + name_placeholder: "products.form.name_placeholder", + price_label: "products.form.price_label", + price_placeholder: "products.form.price_placeholder", }, table: { - actions: 'products.table.actions', - active: 'products.table.active', - description: 'products.table.description', - inactive: 'products.table.inactive', - name: 'products.table.name', - price: 'products.table.price', - status: 'products.table.status', + actions: "products.table.actions", + active: "products.table.active", + description: "products.table.description", + inactive: "products.table.inactive", + name: "products.table.name", + price: "products.table.price", + status: "products.table.status", }, toasts: { - created: 'products.toasts.created', - delete_failed: 'products.toasts.delete_failed', - deleted: 'products.toasts.deleted', - updated: 'products.toasts.updated', + created: "products.toasts.created", + delete_failed: "products.toasts.delete_failed", + deleted: "products.toasts.deleted", + updated: "products.toasts.updated", }, validation: { - name_required: 'products.validation.name_required', - name_too_long: 'products.validation.name_too_long', - price_positive: 'products.validation.price_positive', - price_required: 'products.validation.price_required', + name_required: "products.validation.name_required", + name_too_long: "products.validation.name_too_long", + price_positive: "products.validation.price_positive", + price_required: "products.validation.price_required", }, }, ui: { errors: { - generic_description: 'ui.errors.generic_description', - generic_title: 'ui.errors.generic_title', - go_home_button: 'ui.errors.go_home_button', - reload_button: 'ui.errors.reload_button', + generic_description: "ui.errors.generic_description", + generic_title: "ui.errors.generic_title", + go_home_button: "ui.errors.go_home_button", + reload_button: "ui.errors.reload_button", }, switcher: { - label: 'ui.switcher.label', + label: "ui.switcher.label", }, }, } as const; From 5165ba07bed172e4549f08a89356e9c55feb13ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:46:57 +0000 Subject: [PATCH 4/7] refactor(permissions): simplify service and deps after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scope list_users_with_counts' count query to the fetched IDs instead of scanning the full UserPermission table - Use add_all for bulk RolePermission/UserPermission inserts so flush emits a single round-trip - Drop the redundant _get_user_direct_keys / get_user_direct_keys wrapper pair; keep just the public method - Delete the per-request user-grant cache in deps — RequiresPermission runs at most once per request, so caching was dead weight - Extract _assigned_by once into deps.assigned_by; the two endpoint files now share it - Correct IPermissionService protocol: list_registered_groups is sync --- .../permissions/contracts/service.py | 2 +- modules/permissions/permissions/deps.py | 37 +++----------- .../permissions/permissions/endpoints/api.py | 11 ++--- .../permissions/endpoints/views.py | 11 ++--- modules/permissions/permissions/service.py | 49 ++++++++----------- 5 files changed, 36 insertions(+), 74 deletions(-) diff --git a/modules/permissions/permissions/contracts/service.py b/modules/permissions/permissions/contracts/service.py index 46553c9f..5ec7ef17 100644 --- a/modules/permissions/permissions/contracts/service.py +++ b/modules/permissions/permissions/contracts/service.py @@ -15,7 +15,7 @@ class IPermissionService(Protocol): """Interface for permission-assignment operations.""" - async def list_registered_groups(self) -> list[PermissionGroupOut]: ... + def list_registered_groups(self) -> list[PermissionGroupOut]: ... async def get_role_permissions(self, role_id: uuid.UUID) -> RolePermissionsOut | None: ... async def set_role_permissions( self, diff --git a/modules/permissions/permissions/deps.py b/modules/permissions/permissions/deps.py index e3000c2c..627e04e7 100644 --- a/modules/permissions/permissions/deps.py +++ b/modules/permissions/permissions/deps.py @@ -11,6 +11,8 @@ from __future__ import annotations +import uuid + from fastapi import Depends, HTTPException, Request from simple_module_core.permissions import WILDCARD, PermissionRegistry from simple_module_db.deps import get_db @@ -18,9 +20,6 @@ from permissions.service import PermissionService -# Marker key under which per-request user-direct grants are cached. -_USER_DIRECT_CACHE = "permissions_user_direct" - def get_permission_registry(request: Request) -> PermissionRegistry: return request.app.state.sm.permissions @@ -33,22 +32,10 @@ async def get_permission_service( return PermissionService(db, registry) -async def _get_user_direct_grants( - request: Request, - user_id: str, - service: PermissionService, -) -> set[str]: - """Fetch direct grants for the user, caching on ``request.state``.""" - cache: dict[str, set[str]] | None = getattr(request.state, _USER_DIRECT_CACHE, None) - if cache is None: - cache = {} - setattr(request.state, _USER_DIRECT_CACHE, cache) - if user_id not in cache: - import uuid - - keys = await service.get_user_direct_keys(uuid.UUID(user_id)) - cache[user_id] = set(keys) - return cache[user_id] +def assigned_by(request: Request) -> str | None: + """Authenticated user id string, for audit columns.""" + user = getattr(request.state, "user", None) + return str(user.id) if user is not None else None class RequiresPermission: @@ -56,15 +43,7 @@ class RequiresPermission: Behaves like the framework's ``simple_module_hosting.RequiresPermission`` but additionally consults the ``permissions_user_permission`` table, so - an admin who grants ``products.create`` directly to a specific user - takes immediate effect for that user alone. - - Usage:: - - from permissions.deps import RequiresPermission - - @router.post("/", dependencies=[Depends(RequiresPermission("products.create"))]) - async def create_product(...): ... + a direct grant on a single user takes effect without inventing a role. """ def __init__(self, permission: str) -> None: @@ -83,7 +62,7 @@ async def __call__( if WILDCARD in role_perms or self.permission in role_perms: return - direct = await _get_user_direct_grants(request, str(user.id), service) + direct = await service.get_user_direct_keys(uuid.UUID(str(user.id))) if self.permission in direct: return diff --git a/modules/permissions/permissions/endpoints/api.py b/modules/permissions/permissions/endpoints/api.py index 47fe6521..81049e30 100644 --- a/modules/permissions/permissions/endpoints/api.py +++ b/modules/permissions/permissions/endpoints/api.py @@ -14,17 +14,12 @@ UserPermissionsOut, UserPermissionsUpdate, ) -from permissions.deps import RequiresPermission, get_permission_service +from permissions.deps import RequiresPermission, assigned_by, get_permission_service from permissions.service import PermissionService router = APIRouter() -def _assigned_by(request: Request) -> str | None: - user = getattr(request.state, "user", None) - return str(user.id) if user is not None else None - - @router.get( "/", response_model=list[PermissionGroupOut], @@ -66,7 +61,7 @@ async def set_role_permissions( request: Request, service: PermissionService = Depends(get_permission_service), ) -> RolePermissionsOut: - result = await service.set_role_permissions(role_id, data.permissions, _assigned_by(request)) + result = await service.set_role_permissions(role_id, data.permissions, assigned_by(request)) if result is None: raise HTTPException(status_code=404, detail="Role not found") return result @@ -101,7 +96,7 @@ async def set_user_permissions( request: Request, service: PermissionService = Depends(get_permission_service), ) -> UserPermissionsOut: - result = await service.set_user_permissions(user_id, data.permissions, _assigned_by(request)) + result = await service.set_user_permissions(user_id, data.permissions, assigned_by(request)) if result is None: raise HTTPException(status_code=404, detail="User not found") return result diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/endpoints/views.py index 1d3edde4..adcd277d 100644 --- a/modules/permissions/permissions/endpoints/views.py +++ b/modules/permissions/permissions/endpoints/views.py @@ -16,17 +16,12 @@ from permissions.constants import PERM_MANAGE, PERM_VIEW from permissions.contracts.schemas import RolePermissionsUpdate, UserPermissionsUpdate -from permissions.deps import RequiresPermission, get_permission_service +from permissions.deps import RequiresPermission, assigned_by, get_permission_service from permissions.service import PermissionService router = APIRouter() -def _assigned_by(request: Request) -> str | None: - user = getattr(request.state, "user", None) - return str(user.id) if user is not None else None - - @router.get( "/", response_model=None, @@ -97,7 +92,7 @@ async def update_role( data = RolePermissionsUpdate(**body) except ValidationError as exc: return redirect_back_with_errors(request, validation_errors_to_dict(exc)) - await service.set_role_permissions(role_id, data.permissions, _assigned_by(request)) + await service.set_role_permissions(role_id, data.permissions, assigned_by(request)) return RedirectResponse("/permissions", status_code=303) @@ -145,5 +140,5 @@ async def update_user( data = UserPermissionsUpdate(**body) except ValidationError as exc: return redirect_back_with_errors(request, validation_errors_to_dict(exc)) - await service.set_user_permissions(user_id, data.permissions, _assigned_by(request)) + await service.set_user_permissions(user_id, data.permissions, assigned_by(request)) return RedirectResponse("/permissions", status_code=303) diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index b9dfe314..d22bfc11 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -105,19 +105,14 @@ async def set_role_permissions( RolePermission.permission_key.in_(to_remove), ) ) - for key in wanted - existing: - self.db.add( - RolePermission( - role_name=role.name, - permission_key=key, - assigned_by=assigned_by, - ) - ) + self.db.add_all( + RolePermission(role_name=role.name, permission_key=key, assigned_by=assigned_by) + for key in wanted - existing + ) await self.db.flush() - # Reflect the change in the live registry so authz takes effect - # immediately without requiring a restart. ``map_role`` is additive by - # design — reset the entry first so a PUT really means "replace". + # `map_role` is additive — reset the role entry so removals take effect + # without a restart. No public replace API on PermissionRegistry yet. self.registry._role_map.pop(role.name, None) self.registry.map_role(role.name, sorted(wanted)) @@ -133,16 +128,13 @@ async def _get_user(self, user_id: uuid.UUID) -> User | None: ) return result.scalar_one_or_none() - async def _get_user_direct_keys(self, user_id: uuid.UUID) -> list[str]: + async def get_user_direct_keys(self, user_id: uuid.UUID) -> list[str]: + """Keys granted directly to *user_id* (roles excluded).""" result = await self.db.execute( select(UserPermission.permission_key).where(UserPermission.user_id == user_id) ) return list(result.scalars().all()) - async def get_user_direct_keys(self, user_id: uuid.UUID) -> list[str]: - """Public wrapper — keys granted directly to *user_id* (no roles).""" - return await self._get_user_direct_keys(user_id) - async def list_users_with_counts( self, *, @@ -160,9 +152,14 @@ async def list_users_with_counts( users_result = await self.db.execute(stmt) users = list(users_result.scalars().all()) + if not users: + return [] + user_ids = [u.id for u in users] counts_result = await self.db.execute( - select(UserPermission.user_id, func.count()).group_by(UserPermission.user_id) + select(UserPermission.user_id, func.count()) + .where(UserPermission.user_id.in_(user_ids)) + .group_by(UserPermission.user_id) ) counts = dict(counts_result.all()) return [(UserOut.model_validate(u), counts.get(u.id, 0)) for u in users] @@ -171,7 +168,7 @@ async def get_user_permissions(self, user_id: uuid.UUID) -> UserPermissionsOut | user = await self._get_user(user_id) if user is None: return None - direct = set(await self._get_user_direct_keys(user.id)) + direct = set(await self.get_user_direct_keys(user.id)) role_names = [r.name for r in user.roles] inherited = self._resolve_role_permissions(role_names) - direct return UserPermissionsOut( @@ -192,7 +189,7 @@ async def set_user_permissions( return None wanted = {k for k in keys if k in self._registered_keys()} - existing = set(await self._get_user_direct_keys(user.id)) + existing = set(await self.get_user_direct_keys(user.id)) to_remove = existing - wanted if to_remove: @@ -202,14 +199,10 @@ async def set_user_permissions( UserPermission.permission_key.in_(to_remove), ) ) - for key in wanted - existing: - self.db.add( - UserPermission( - user_id=user.id, - permission_key=key, - assigned_by=assigned_by, - ) - ) + self.db.add_all( + UserPermission(user_id=user.id, permission_key=key, assigned_by=assigned_by) + for key in wanted - existing + ) await self.db.flush() role_names = [r.name for r in user.roles] @@ -241,7 +234,7 @@ async def resolve_effective_permissions(self, user_id: uuid.UUID) -> set[str]: user = await self._get_user(user_id) if user is None: return set() - direct = set(await self._get_user_direct_keys(user.id)) + direct = set(await self.get_user_direct_keys(user.id)) inherited = self._resolve_role_permissions([r.name for r in user.roles]) return direct | inherited From aa2ea172ad542c0d50eb68f94f25587f26a2dbc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:54:51 +0000 Subject: [PATCH 5/7] perf(permissions): index role_permission.permission_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ix_permissions_user_permission_key for the other junction table. The composite PK already covers role-first lookups; this supports reverse lookups ("which roles hold permission X?") without a table scan — the same reason users_user_role has a standalone role_id index alongside its composite PK. --- ...8_index_permissions_role_permission_key.py | 33 +++ modules/permissions/permissions/models.py | 5 + packages/i18n/src/generated-resources.ts | 1 - packages/i18n/src/keys.generated.ts | 254 +++++++++--------- 4 files changed, 165 insertions(+), 128 deletions(-) create mode 100644 host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py diff --git a/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py b/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py new file mode 100644 index 00000000..ac162f5f --- /dev/null +++ b/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py @@ -0,0 +1,33 @@ +"""index permissions role permission key + +Revision ID: a35930f574d8 +Revises: 9557a7c2e646 +Create Date: 2026-04-19 11:52:35.013862 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "a35930f574d8" +down_revision: str | None = "9557a7c2e646" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_index( + "ix_permissions_role_permission_key", + "permissions_role_permission", + ["permission_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_permissions_role_permission_key", + table_name="permissions_role_permission", + ) diff --git a/modules/permissions/permissions/models.py b/modules/permissions/permissions/models.py index ffd6c3fc..864ebe01 100644 --- a/modules/permissions/permissions/models.py +++ b/modules/permissions/permissions/models.py @@ -47,6 +47,11 @@ class RolePermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-ba ) assigned_by: str | None = Field(default=None, max_length=255) + # The composite PK covers role-first lookups; this index supports + # reverse lookups ("which roles hold permission X?") — PostgreSQL + # does not auto-index non-leading PK columns. + __table_args__ = (Index("ix_permissions_role_permission_key", "permission_key"),) + class UserPermission(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] """Direct assignment of a registered permission key to a single user.""" diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 2a64c0cb..a9f423ef 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,7 +1,6 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. - export default { translation: { 'auth.errors.missing_permission': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 28319b80..5ce1b4ea 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,185 +4,185 @@ export const keys = { auth: { errors: { - missing_permission: "auth.errors.missing_permission", - not_authenticated: "auth.errors.not_authenticated", + missing_permission: 'auth.errors.missing_permission', + not_authenticated: 'auth.errors.not_authenticated', }, }, dashboard: { home: { - description: "dashboard.home.description", - description_body: "dashboard.home.description_body", + description: 'dashboard.home.description', + description_body: 'dashboard.home.description_body', stats: { - active_users: "dashboard.home.stats.active_users", - modules: "dashboard.home.stats.modules", - products: "dashboard.home.stats.products", - total_users: "dashboard.home.stats.total_users", + active_users: 'dashboard.home.stats.active_users', + modules: 'dashboard.home.stats.modules', + products: 'dashboard.home.stats.products', + total_users: 'dashboard.home.stats.total_users', }, system_info: { - health_checks: "dashboard.home.system_info.health_checks", - modules: "dashboard.home.system_info.modules", - python_version: "dashboard.home.system_info.python_version", + health_checks: 'dashboard.home.system_info.health_checks', + modules: 'dashboard.home.system_info.modules', + python_version: 'dashboard.home.system_info.python_version', }, - system_info_title: "dashboard.home.system_info_title", - title: "dashboard.home.title", - welcome_card_title: "dashboard.home.welcome_card_title", - welcome_message: "dashboard.home.welcome_message", + system_info_title: 'dashboard.home.system_info_title', + title: 'dashboard.home.title', + welcome_card_title: 'dashboard.home.welcome_card_title', + welcome_message: 'dashboard.home.welcome_message', }, }, host: { error: { - forbidden_description: "host.error.forbidden_description", - forbidden_title: "host.error.forbidden_title", - generic_description: "host.error.generic_description", - generic_title: "host.error.generic_title", - go_back: "host.error.go_back", - go_home: "host.error.go_home", - not_found_description: "host.error.not_found_description", - not_found_title: "host.error.not_found_title", - server_error_description: "host.error.server_error_description", - server_error_title: "host.error.server_error_title", + forbidden_description: 'host.error.forbidden_description', + forbidden_title: 'host.error.forbidden_title', + generic_description: 'host.error.generic_description', + generic_title: 'host.error.generic_title', + go_back: 'host.error.go_back', + go_home: 'host.error.go_home', + not_found_description: 'host.error.not_found_description', + not_found_title: 'host.error.not_found_title', + server_error_description: 'host.error.server_error_description', + server_error_title: 'host.error.server_error_title', }, landing: { - badge: "host.landing.badge", - cta_dashboard: "host.landing.cta_dashboard", - cta_docs: "host.landing.cta_docs", - cta_get_started: "host.landing.cta_get_started", + badge: 'host.landing.badge', + cta_dashboard: 'host.landing.cta_dashboard', + cta_docs: 'host.landing.cta_docs', + cta_get_started: 'host.landing.cta_get_started', features: { - auth_description: "host.landing.features.auth_description", - auth_title: "host.landing.features.auth_title", - devtools_description: "host.landing.features.devtools_description", - devtools_title: "host.landing.features.devtools_title", - diagnostics_description: "host.landing.features.diagnostics_description", - diagnostics_title: "host.landing.features.diagnostics_title", - inertia_description: "host.landing.features.inertia_description", - inertia_title: "host.landing.features.inertia_title", - module_system_description: "host.landing.features.module_system_description", - module_system_title: "host.landing.features.module_system_title", - schema_description: "host.landing.features.schema_description", - schema_title: "host.landing.features.schema_title", + auth_description: 'host.landing.features.auth_description', + auth_title: 'host.landing.features.auth_title', + devtools_description: 'host.landing.features.devtools_description', + devtools_title: 'host.landing.features.devtools_title', + diagnostics_description: 'host.landing.features.diagnostics_description', + diagnostics_title: 'host.landing.features.diagnostics_title', + inertia_description: 'host.landing.features.inertia_description', + inertia_title: 'host.landing.features.inertia_title', + module_system_description: 'host.landing.features.module_system_description', + module_system_title: 'host.landing.features.module_system_title', + schema_description: 'host.landing.features.schema_description', + schema_title: 'host.landing.features.schema_title', }, - hero_subtitle: "host.landing.hero_subtitle", - hero_title_line1: "host.landing.hero_title_line1", - hero_title_line2: "host.landing.hero_title_line2", + hero_subtitle: 'host.landing.hero_subtitle', + hero_title_line1: 'host.landing.hero_title_line1', + hero_title_line2: 'host.landing.hero_title_line2', }, }, permissions: { browse: { - count_label: "permissions.browse.count_label", - count_label_one: "permissions.browse.count_label_one", - count_label_other: "permissions.browse.count_label_other", - description: "permissions.browse.description", - direct_count_label: "permissions.browse.direct_count_label", - direct_count_label_one: "permissions.browse.direct_count_label_one", - direct_count_label_other: "permissions.browse.direct_count_label_other", - edit_link: "permissions.browse.edit_link", - no_permissions: "permissions.browse.no_permissions", - no_roles: "permissions.browse.no_roles", - no_users: "permissions.browse.no_users", - registry_heading: "permissions.browse.registry_heading", - registry_hint: "permissions.browse.registry_hint", - roles_heading: "permissions.browse.roles_heading", - search_placeholder: "permissions.browse.search_placeholder", - title: "permissions.browse.title", - users_heading: "permissions.browse.users_heading", + count_label: 'permissions.browse.count_label', + count_label_one: 'permissions.browse.count_label_one', + count_label_other: 'permissions.browse.count_label_other', + description: 'permissions.browse.description', + direct_count_label: 'permissions.browse.direct_count_label', + direct_count_label_one: 'permissions.browse.direct_count_label_one', + direct_count_label_other: 'permissions.browse.direct_count_label_other', + edit_link: 'permissions.browse.edit_link', + no_permissions: 'permissions.browse.no_permissions', + no_roles: 'permissions.browse.no_roles', + no_users: 'permissions.browse.no_users', + registry_heading: 'permissions.browse.registry_heading', + registry_hint: 'permissions.browse.registry_hint', + roles_heading: 'permissions.browse.roles_heading', + search_placeholder: 'permissions.browse.search_placeholder', + title: 'permissions.browse.title', + users_heading: 'permissions.browse.users_heading', }, edit: { - cancel_link: "permissions.edit.cancel_link", - empty: "permissions.edit.empty", - submit_button: "permissions.edit.submit_button", - title: "permissions.edit.title", + cancel_link: 'permissions.edit.cancel_link', + empty: 'permissions.edit.empty', + submit_button: 'permissions.edit.submit_button', + title: 'permissions.edit.title', }, errors: { - role_not_found: "permissions.errors.role_not_found", - user_not_found: "permissions.errors.user_not_found", + role_not_found: 'permissions.errors.role_not_found', + user_not_found: 'permissions.errors.user_not_found', }, user_edit: { - cancel_link: "permissions.user_edit.cancel_link", - empty: "permissions.user_edit.empty", - inherited_badge: "permissions.user_edit.inherited_badge", - inherited_hint: "permissions.user_edit.inherited_hint", - roles_label: "permissions.user_edit.roles_label", - submit_button: "permissions.user_edit.submit_button", - title: "permissions.user_edit.title", + cancel_link: 'permissions.user_edit.cancel_link', + empty: 'permissions.user_edit.empty', + inherited_badge: 'permissions.user_edit.inherited_badge', + inherited_hint: 'permissions.user_edit.inherited_hint', + roles_label: 'permissions.user_edit.roles_label', + submit_button: 'permissions.user_edit.submit_button', + title: 'permissions.user_edit.title', }, }, products: { browse: { - count: "products.browse.count", - count_one: "products.browse.count_one", - count_other: "products.browse.count_other", - create_button: "products.browse.create_button", - description: "products.browse.description", - empty_description: "products.browse.empty_description", - empty_title: "products.browse.empty_title", - new_button: "products.browse.new_button", - no_match: "products.browse.no_match", - search_placeholder: "products.browse.search_placeholder", - title: "products.browse.title", + count: 'products.browse.count', + count_one: 'products.browse.count_one', + count_other: 'products.browse.count_other', + create_button: 'products.browse.create_button', + description: 'products.browse.description', + empty_description: 'products.browse.empty_description', + empty_title: 'products.browse.empty_title', + new_button: 'products.browse.new_button', + no_match: 'products.browse.no_match', + search_placeholder: 'products.browse.search_placeholder', + title: 'products.browse.title', }, create: { - description: "products.create.description", - submit_button: "products.create.submit_button", - submitting_button: "products.create.submitting_button", - title: "products.create.title", + description: 'products.create.description', + submit_button: 'products.create.submit_button', + submitting_button: 'products.create.submitting_button', + title: 'products.create.title', }, delete_dialog: { - cancel: "products.delete_dialog.cancel", - confirm: "products.delete_dialog.confirm", - description: "products.delete_dialog.description", - title: "products.delete_dialog.title", + cancel: 'products.delete_dialog.cancel', + confirm: 'products.delete_dialog.confirm', + description: 'products.delete_dialog.description', + title: 'products.delete_dialog.title', }, edit: { - back_button: "products.edit.back_button", - description: "products.edit.description", - submit_button: "products.edit.submit_button", - submitting_button: "products.edit.submitting_button", - title: "products.edit.title", + back_button: 'products.edit.back_button', + description: 'products.edit.description', + submit_button: 'products.edit.submit_button', + submitting_button: 'products.edit.submitting_button', + title: 'products.edit.title', }, errors: { - not_found: "products.errors.not_found", + not_found: 'products.errors.not_found', }, form: { - active_label: "products.form.active_label", - cancel_button: "products.form.cancel_button", - description_label: "products.form.description_label", - description_placeholder: "products.form.description_placeholder", - name_label: "products.form.name_label", - name_placeholder: "products.form.name_placeholder", - price_label: "products.form.price_label", - price_placeholder: "products.form.price_placeholder", + active_label: 'products.form.active_label', + cancel_button: 'products.form.cancel_button', + description_label: 'products.form.description_label', + description_placeholder: 'products.form.description_placeholder', + name_label: 'products.form.name_label', + name_placeholder: 'products.form.name_placeholder', + price_label: 'products.form.price_label', + price_placeholder: 'products.form.price_placeholder', }, table: { - actions: "products.table.actions", - active: "products.table.active", - description: "products.table.description", - inactive: "products.table.inactive", - name: "products.table.name", - price: "products.table.price", - status: "products.table.status", + actions: 'products.table.actions', + active: 'products.table.active', + description: 'products.table.description', + inactive: 'products.table.inactive', + name: 'products.table.name', + price: 'products.table.price', + status: 'products.table.status', }, toasts: { - created: "products.toasts.created", - delete_failed: "products.toasts.delete_failed", - deleted: "products.toasts.deleted", - updated: "products.toasts.updated", + created: 'products.toasts.created', + delete_failed: 'products.toasts.delete_failed', + deleted: 'products.toasts.deleted', + updated: 'products.toasts.updated', }, validation: { - name_required: "products.validation.name_required", - name_too_long: "products.validation.name_too_long", - price_positive: "products.validation.price_positive", - price_required: "products.validation.price_required", + name_required: 'products.validation.name_required', + name_too_long: 'products.validation.name_too_long', + price_positive: 'products.validation.price_positive', + price_required: 'products.validation.price_required', }, }, ui: { errors: { - generic_description: "ui.errors.generic_description", - generic_title: "ui.errors.generic_title", - go_home_button: "ui.errors.go_home_button", - reload_button: "ui.errors.reload_button", + generic_description: 'ui.errors.generic_description', + generic_title: 'ui.errors.generic_title', + go_home_button: 'ui.errors.go_home_button', + reload_button: 'ui.errors.reload_button', }, switcher: { - label: "ui.switcher.label", + label: 'ui.switcher.label', }, }, } as const; From 4f174bb0be678dfec6c05da8acd9f2f25b5fc2f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 12:12:05 +0000 Subject: [PATCH 6/7] chore(i18n): regenerate i18n manifests --- packages/i18n/src/generated-resources.ts | 1 + packages/i18n/src/keys.generated.ts | 254 +++++++++++------------ 2 files changed, 128 insertions(+), 127 deletions(-) diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index a9f423ef..2a64c0cb 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,6 +1,7 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. + export default { translation: { 'auth.errors.missing_permission': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 5ce1b4ea..28319b80 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,185 +4,185 @@ export const keys = { auth: { errors: { - missing_permission: 'auth.errors.missing_permission', - not_authenticated: 'auth.errors.not_authenticated', + missing_permission: "auth.errors.missing_permission", + not_authenticated: "auth.errors.not_authenticated", }, }, dashboard: { home: { - description: 'dashboard.home.description', - description_body: 'dashboard.home.description_body', + description: "dashboard.home.description", + description_body: "dashboard.home.description_body", stats: { - active_users: 'dashboard.home.stats.active_users', - modules: 'dashboard.home.stats.modules', - products: 'dashboard.home.stats.products', - total_users: 'dashboard.home.stats.total_users', + active_users: "dashboard.home.stats.active_users", + modules: "dashboard.home.stats.modules", + products: "dashboard.home.stats.products", + total_users: "dashboard.home.stats.total_users", }, system_info: { - health_checks: 'dashboard.home.system_info.health_checks', - modules: 'dashboard.home.system_info.modules', - python_version: 'dashboard.home.system_info.python_version', + health_checks: "dashboard.home.system_info.health_checks", + modules: "dashboard.home.system_info.modules", + python_version: "dashboard.home.system_info.python_version", }, - system_info_title: 'dashboard.home.system_info_title', - title: 'dashboard.home.title', - welcome_card_title: 'dashboard.home.welcome_card_title', - welcome_message: 'dashboard.home.welcome_message', + system_info_title: "dashboard.home.system_info_title", + title: "dashboard.home.title", + welcome_card_title: "dashboard.home.welcome_card_title", + welcome_message: "dashboard.home.welcome_message", }, }, host: { error: { - forbidden_description: 'host.error.forbidden_description', - forbidden_title: 'host.error.forbidden_title', - generic_description: 'host.error.generic_description', - generic_title: 'host.error.generic_title', - go_back: 'host.error.go_back', - go_home: 'host.error.go_home', - not_found_description: 'host.error.not_found_description', - not_found_title: 'host.error.not_found_title', - server_error_description: 'host.error.server_error_description', - server_error_title: 'host.error.server_error_title', + forbidden_description: "host.error.forbidden_description", + forbidden_title: "host.error.forbidden_title", + generic_description: "host.error.generic_description", + generic_title: "host.error.generic_title", + go_back: "host.error.go_back", + go_home: "host.error.go_home", + not_found_description: "host.error.not_found_description", + not_found_title: "host.error.not_found_title", + server_error_description: "host.error.server_error_description", + server_error_title: "host.error.server_error_title", }, landing: { - badge: 'host.landing.badge', - cta_dashboard: 'host.landing.cta_dashboard', - cta_docs: 'host.landing.cta_docs', - cta_get_started: 'host.landing.cta_get_started', + badge: "host.landing.badge", + cta_dashboard: "host.landing.cta_dashboard", + cta_docs: "host.landing.cta_docs", + cta_get_started: "host.landing.cta_get_started", features: { - auth_description: 'host.landing.features.auth_description', - auth_title: 'host.landing.features.auth_title', - devtools_description: 'host.landing.features.devtools_description', - devtools_title: 'host.landing.features.devtools_title', - diagnostics_description: 'host.landing.features.diagnostics_description', - diagnostics_title: 'host.landing.features.diagnostics_title', - inertia_description: 'host.landing.features.inertia_description', - inertia_title: 'host.landing.features.inertia_title', - module_system_description: 'host.landing.features.module_system_description', - module_system_title: 'host.landing.features.module_system_title', - schema_description: 'host.landing.features.schema_description', - schema_title: 'host.landing.features.schema_title', + auth_description: "host.landing.features.auth_description", + auth_title: "host.landing.features.auth_title", + devtools_description: "host.landing.features.devtools_description", + devtools_title: "host.landing.features.devtools_title", + diagnostics_description: "host.landing.features.diagnostics_description", + diagnostics_title: "host.landing.features.diagnostics_title", + inertia_description: "host.landing.features.inertia_description", + inertia_title: "host.landing.features.inertia_title", + module_system_description: "host.landing.features.module_system_description", + module_system_title: "host.landing.features.module_system_title", + schema_description: "host.landing.features.schema_description", + schema_title: "host.landing.features.schema_title", }, - hero_subtitle: 'host.landing.hero_subtitle', - hero_title_line1: 'host.landing.hero_title_line1', - hero_title_line2: 'host.landing.hero_title_line2', + hero_subtitle: "host.landing.hero_subtitle", + hero_title_line1: "host.landing.hero_title_line1", + hero_title_line2: "host.landing.hero_title_line2", }, }, permissions: { browse: { - count_label: 'permissions.browse.count_label', - count_label_one: 'permissions.browse.count_label_one', - count_label_other: 'permissions.browse.count_label_other', - description: 'permissions.browse.description', - direct_count_label: 'permissions.browse.direct_count_label', - direct_count_label_one: 'permissions.browse.direct_count_label_one', - direct_count_label_other: 'permissions.browse.direct_count_label_other', - edit_link: 'permissions.browse.edit_link', - no_permissions: 'permissions.browse.no_permissions', - no_roles: 'permissions.browse.no_roles', - no_users: 'permissions.browse.no_users', - registry_heading: 'permissions.browse.registry_heading', - registry_hint: 'permissions.browse.registry_hint', - roles_heading: 'permissions.browse.roles_heading', - search_placeholder: 'permissions.browse.search_placeholder', - title: 'permissions.browse.title', - users_heading: 'permissions.browse.users_heading', + count_label: "permissions.browse.count_label", + count_label_one: "permissions.browse.count_label_one", + count_label_other: "permissions.browse.count_label_other", + description: "permissions.browse.description", + direct_count_label: "permissions.browse.direct_count_label", + direct_count_label_one: "permissions.browse.direct_count_label_one", + direct_count_label_other: "permissions.browse.direct_count_label_other", + edit_link: "permissions.browse.edit_link", + no_permissions: "permissions.browse.no_permissions", + no_roles: "permissions.browse.no_roles", + no_users: "permissions.browse.no_users", + registry_heading: "permissions.browse.registry_heading", + registry_hint: "permissions.browse.registry_hint", + roles_heading: "permissions.browse.roles_heading", + search_placeholder: "permissions.browse.search_placeholder", + title: "permissions.browse.title", + users_heading: "permissions.browse.users_heading", }, edit: { - cancel_link: 'permissions.edit.cancel_link', - empty: 'permissions.edit.empty', - submit_button: 'permissions.edit.submit_button', - title: 'permissions.edit.title', + cancel_link: "permissions.edit.cancel_link", + empty: "permissions.edit.empty", + submit_button: "permissions.edit.submit_button", + title: "permissions.edit.title", }, errors: { - role_not_found: 'permissions.errors.role_not_found', - user_not_found: 'permissions.errors.user_not_found', + role_not_found: "permissions.errors.role_not_found", + user_not_found: "permissions.errors.user_not_found", }, user_edit: { - cancel_link: 'permissions.user_edit.cancel_link', - empty: 'permissions.user_edit.empty', - inherited_badge: 'permissions.user_edit.inherited_badge', - inherited_hint: 'permissions.user_edit.inherited_hint', - roles_label: 'permissions.user_edit.roles_label', - submit_button: 'permissions.user_edit.submit_button', - title: 'permissions.user_edit.title', + cancel_link: "permissions.user_edit.cancel_link", + empty: "permissions.user_edit.empty", + inherited_badge: "permissions.user_edit.inherited_badge", + inherited_hint: "permissions.user_edit.inherited_hint", + roles_label: "permissions.user_edit.roles_label", + submit_button: "permissions.user_edit.submit_button", + title: "permissions.user_edit.title", }, }, products: { browse: { - count: 'products.browse.count', - count_one: 'products.browse.count_one', - count_other: 'products.browse.count_other', - create_button: 'products.browse.create_button', - description: 'products.browse.description', - empty_description: 'products.browse.empty_description', - empty_title: 'products.browse.empty_title', - new_button: 'products.browse.new_button', - no_match: 'products.browse.no_match', - search_placeholder: 'products.browse.search_placeholder', - title: 'products.browse.title', + count: "products.browse.count", + count_one: "products.browse.count_one", + count_other: "products.browse.count_other", + create_button: "products.browse.create_button", + description: "products.browse.description", + empty_description: "products.browse.empty_description", + empty_title: "products.browse.empty_title", + new_button: "products.browse.new_button", + no_match: "products.browse.no_match", + search_placeholder: "products.browse.search_placeholder", + title: "products.browse.title", }, create: { - description: 'products.create.description', - submit_button: 'products.create.submit_button', - submitting_button: 'products.create.submitting_button', - title: 'products.create.title', + description: "products.create.description", + submit_button: "products.create.submit_button", + submitting_button: "products.create.submitting_button", + title: "products.create.title", }, delete_dialog: { - cancel: 'products.delete_dialog.cancel', - confirm: 'products.delete_dialog.confirm', - description: 'products.delete_dialog.description', - title: 'products.delete_dialog.title', + cancel: "products.delete_dialog.cancel", + confirm: "products.delete_dialog.confirm", + description: "products.delete_dialog.description", + title: "products.delete_dialog.title", }, edit: { - back_button: 'products.edit.back_button', - description: 'products.edit.description', - submit_button: 'products.edit.submit_button', - submitting_button: 'products.edit.submitting_button', - title: 'products.edit.title', + back_button: "products.edit.back_button", + description: "products.edit.description", + submit_button: "products.edit.submit_button", + submitting_button: "products.edit.submitting_button", + title: "products.edit.title", }, errors: { - not_found: 'products.errors.not_found', + not_found: "products.errors.not_found", }, form: { - active_label: 'products.form.active_label', - cancel_button: 'products.form.cancel_button', - description_label: 'products.form.description_label', - description_placeholder: 'products.form.description_placeholder', - name_label: 'products.form.name_label', - name_placeholder: 'products.form.name_placeholder', - price_label: 'products.form.price_label', - price_placeholder: 'products.form.price_placeholder', + active_label: "products.form.active_label", + cancel_button: "products.form.cancel_button", + description_label: "products.form.description_label", + description_placeholder: "products.form.description_placeholder", + name_label: "products.form.name_label", + name_placeholder: "products.form.name_placeholder", + price_label: "products.form.price_label", + price_placeholder: "products.form.price_placeholder", }, table: { - actions: 'products.table.actions', - active: 'products.table.active', - description: 'products.table.description', - inactive: 'products.table.inactive', - name: 'products.table.name', - price: 'products.table.price', - status: 'products.table.status', + actions: "products.table.actions", + active: "products.table.active", + description: "products.table.description", + inactive: "products.table.inactive", + name: "products.table.name", + price: "products.table.price", + status: "products.table.status", }, toasts: { - created: 'products.toasts.created', - delete_failed: 'products.toasts.delete_failed', - deleted: 'products.toasts.deleted', - updated: 'products.toasts.updated', + created: "products.toasts.created", + delete_failed: "products.toasts.delete_failed", + deleted: "products.toasts.deleted", + updated: "products.toasts.updated", }, validation: { - name_required: 'products.validation.name_required', - name_too_long: 'products.validation.name_too_long', - price_positive: 'products.validation.price_positive', - price_required: 'products.validation.price_required', + name_required: "products.validation.name_required", + name_too_long: "products.validation.name_too_long", + price_positive: "products.validation.price_positive", + price_required: "products.validation.price_required", }, }, ui: { errors: { - generic_description: 'ui.errors.generic_description', - generic_title: 'ui.errors.generic_title', - go_home_button: 'ui.errors.go_home_button', - reload_button: 'ui.errors.reload_button', + generic_description: "ui.errors.generic_description", + generic_title: "ui.errors.generic_title", + go_home_button: "ui.errors.go_home_button", + reload_button: "ui.errors.reload_button", }, switcher: { - label: 'ui.switcher.label', + label: "ui.switcher.label", }, }, } as const; From 1e4d1c9512cb7f6476612778093a725ea24bdb3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 12:57:42 +0000 Subject: [PATCH 7/7] chore(i18n): regenerate i18n manifests after merge --- packages/i18n/src/generated-resources.ts | 1 - packages/i18n/src/keys.generated.ts | 254 +++++++++++------------ 2 files changed, 127 insertions(+), 128 deletions(-) diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index db1f2e46..0c5ce155 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,7 +1,6 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. - export default { translation: { 'auth.errors.missing_permission': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 28ef1670..80681063 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,8 +4,8 @@ export const keys = { auth: { errors: { - missing_permission: "auth.errors.missing_permission", - not_authenticated: "auth.errors.not_authenticated", + missing_permission: 'auth.errors.missing_permission', + not_authenticated: 'auth.errors.not_authenticated', }, }, background_tasks: { @@ -68,23 +68,23 @@ export const keys = { }, dashboard: { home: { - description: "dashboard.home.description", - description_body: "dashboard.home.description_body", + description: 'dashboard.home.description', + description_body: 'dashboard.home.description_body', stats: { - active_users: "dashboard.home.stats.active_users", - modules: "dashboard.home.stats.modules", - products: "dashboard.home.stats.products", - total_users: "dashboard.home.stats.total_users", + active_users: 'dashboard.home.stats.active_users', + modules: 'dashboard.home.stats.modules', + products: 'dashboard.home.stats.products', + total_users: 'dashboard.home.stats.total_users', }, system_info: { - health_checks: "dashboard.home.system_info.health_checks", - modules: "dashboard.home.system_info.modules", - python_version: "dashboard.home.system_info.python_version", + health_checks: 'dashboard.home.system_info.health_checks', + modules: 'dashboard.home.system_info.modules', + python_version: 'dashboard.home.system_info.python_version', }, - system_info_title: "dashboard.home.system_info_title", - title: "dashboard.home.title", - welcome_card_title: "dashboard.home.welcome_card_title", - welcome_message: "dashboard.home.welcome_message", + system_info_title: 'dashboard.home.system_info_title', + title: 'dashboard.home.title', + welcome_card_title: 'dashboard.home.welcome_card_title', + welcome_message: 'dashboard.home.welcome_message', }, }, file_storage: { @@ -132,158 +132,158 @@ export const keys = { }, host: { error: { - forbidden_description: "host.error.forbidden_description", - forbidden_title: "host.error.forbidden_title", - generic_description: "host.error.generic_description", - generic_title: "host.error.generic_title", - go_back: "host.error.go_back", - go_home: "host.error.go_home", - not_found_description: "host.error.not_found_description", - not_found_title: "host.error.not_found_title", - server_error_description: "host.error.server_error_description", - server_error_title: "host.error.server_error_title", + forbidden_description: 'host.error.forbidden_description', + forbidden_title: 'host.error.forbidden_title', + generic_description: 'host.error.generic_description', + generic_title: 'host.error.generic_title', + go_back: 'host.error.go_back', + go_home: 'host.error.go_home', + not_found_description: 'host.error.not_found_description', + not_found_title: 'host.error.not_found_title', + server_error_description: 'host.error.server_error_description', + server_error_title: 'host.error.server_error_title', }, landing: { - badge: "host.landing.badge", - cta_dashboard: "host.landing.cta_dashboard", - cta_docs: "host.landing.cta_docs", - cta_get_started: "host.landing.cta_get_started", + badge: 'host.landing.badge', + cta_dashboard: 'host.landing.cta_dashboard', + cta_docs: 'host.landing.cta_docs', + cta_get_started: 'host.landing.cta_get_started', features: { - auth_description: "host.landing.features.auth_description", - auth_title: "host.landing.features.auth_title", - devtools_description: "host.landing.features.devtools_description", - devtools_title: "host.landing.features.devtools_title", - diagnostics_description: "host.landing.features.diagnostics_description", - diagnostics_title: "host.landing.features.diagnostics_title", - inertia_description: "host.landing.features.inertia_description", - inertia_title: "host.landing.features.inertia_title", - module_system_description: "host.landing.features.module_system_description", - module_system_title: "host.landing.features.module_system_title", - schema_description: "host.landing.features.schema_description", - schema_title: "host.landing.features.schema_title", + auth_description: 'host.landing.features.auth_description', + auth_title: 'host.landing.features.auth_title', + devtools_description: 'host.landing.features.devtools_description', + devtools_title: 'host.landing.features.devtools_title', + diagnostics_description: 'host.landing.features.diagnostics_description', + diagnostics_title: 'host.landing.features.diagnostics_title', + inertia_description: 'host.landing.features.inertia_description', + inertia_title: 'host.landing.features.inertia_title', + module_system_description: 'host.landing.features.module_system_description', + module_system_title: 'host.landing.features.module_system_title', + schema_description: 'host.landing.features.schema_description', + schema_title: 'host.landing.features.schema_title', }, - hero_subtitle: "host.landing.hero_subtitle", - hero_title_line1: "host.landing.hero_title_line1", - hero_title_line2: "host.landing.hero_title_line2", + hero_subtitle: 'host.landing.hero_subtitle', + hero_title_line1: 'host.landing.hero_title_line1', + hero_title_line2: 'host.landing.hero_title_line2', }, }, permissions: { browse: { - count_label: "permissions.browse.count_label", - count_label_one: "permissions.browse.count_label_one", - count_label_other: "permissions.browse.count_label_other", - description: "permissions.browse.description", - direct_count_label: "permissions.browse.direct_count_label", - direct_count_label_one: "permissions.browse.direct_count_label_one", - direct_count_label_other: "permissions.browse.direct_count_label_other", - edit_link: "permissions.browse.edit_link", - no_permissions: "permissions.browse.no_permissions", - no_roles: "permissions.browse.no_roles", - no_users: "permissions.browse.no_users", - registry_heading: "permissions.browse.registry_heading", - registry_hint: "permissions.browse.registry_hint", - roles_heading: "permissions.browse.roles_heading", - search_placeholder: "permissions.browse.search_placeholder", - title: "permissions.browse.title", - users_heading: "permissions.browse.users_heading", + count_label: 'permissions.browse.count_label', + count_label_one: 'permissions.browse.count_label_one', + count_label_other: 'permissions.browse.count_label_other', + description: 'permissions.browse.description', + direct_count_label: 'permissions.browse.direct_count_label', + direct_count_label_one: 'permissions.browse.direct_count_label_one', + direct_count_label_other: 'permissions.browse.direct_count_label_other', + edit_link: 'permissions.browse.edit_link', + no_permissions: 'permissions.browse.no_permissions', + no_roles: 'permissions.browse.no_roles', + no_users: 'permissions.browse.no_users', + registry_heading: 'permissions.browse.registry_heading', + registry_hint: 'permissions.browse.registry_hint', + roles_heading: 'permissions.browse.roles_heading', + search_placeholder: 'permissions.browse.search_placeholder', + title: 'permissions.browse.title', + users_heading: 'permissions.browse.users_heading', }, edit: { - cancel_link: "permissions.edit.cancel_link", - empty: "permissions.edit.empty", - submit_button: "permissions.edit.submit_button", - title: "permissions.edit.title", + cancel_link: 'permissions.edit.cancel_link', + empty: 'permissions.edit.empty', + submit_button: 'permissions.edit.submit_button', + title: 'permissions.edit.title', }, errors: { - role_not_found: "permissions.errors.role_not_found", - user_not_found: "permissions.errors.user_not_found", + role_not_found: 'permissions.errors.role_not_found', + user_not_found: 'permissions.errors.user_not_found', }, user_edit: { - cancel_link: "permissions.user_edit.cancel_link", - empty: "permissions.user_edit.empty", - inherited_badge: "permissions.user_edit.inherited_badge", - inherited_hint: "permissions.user_edit.inherited_hint", - roles_label: "permissions.user_edit.roles_label", - submit_button: "permissions.user_edit.submit_button", - title: "permissions.user_edit.title", + cancel_link: 'permissions.user_edit.cancel_link', + empty: 'permissions.user_edit.empty', + inherited_badge: 'permissions.user_edit.inherited_badge', + inherited_hint: 'permissions.user_edit.inherited_hint', + roles_label: 'permissions.user_edit.roles_label', + submit_button: 'permissions.user_edit.submit_button', + title: 'permissions.user_edit.title', }, }, products: { browse: { - count: "products.browse.count", - count_one: "products.browse.count_one", - count_other: "products.browse.count_other", - create_button: "products.browse.create_button", - description: "products.browse.description", - empty_description: "products.browse.empty_description", - empty_title: "products.browse.empty_title", - new_button: "products.browse.new_button", - no_match: "products.browse.no_match", - search_placeholder: "products.browse.search_placeholder", - title: "products.browse.title", + count: 'products.browse.count', + count_one: 'products.browse.count_one', + count_other: 'products.browse.count_other', + create_button: 'products.browse.create_button', + description: 'products.browse.description', + empty_description: 'products.browse.empty_description', + empty_title: 'products.browse.empty_title', + new_button: 'products.browse.new_button', + no_match: 'products.browse.no_match', + search_placeholder: 'products.browse.search_placeholder', + title: 'products.browse.title', }, create: { - description: "products.create.description", - submit_button: "products.create.submit_button", - submitting_button: "products.create.submitting_button", - title: "products.create.title", + description: 'products.create.description', + submit_button: 'products.create.submit_button', + submitting_button: 'products.create.submitting_button', + title: 'products.create.title', }, delete_dialog: { - cancel: "products.delete_dialog.cancel", - confirm: "products.delete_dialog.confirm", - description: "products.delete_dialog.description", - title: "products.delete_dialog.title", + cancel: 'products.delete_dialog.cancel', + confirm: 'products.delete_dialog.confirm', + description: 'products.delete_dialog.description', + title: 'products.delete_dialog.title', }, edit: { - back_button: "products.edit.back_button", - description: "products.edit.description", - submit_button: "products.edit.submit_button", - submitting_button: "products.edit.submitting_button", - title: "products.edit.title", + back_button: 'products.edit.back_button', + description: 'products.edit.description', + submit_button: 'products.edit.submit_button', + submitting_button: 'products.edit.submitting_button', + title: 'products.edit.title', }, errors: { - not_found: "products.errors.not_found", + not_found: 'products.errors.not_found', }, form: { - active_label: "products.form.active_label", - cancel_button: "products.form.cancel_button", - description_label: "products.form.description_label", - description_placeholder: "products.form.description_placeholder", - name_label: "products.form.name_label", - name_placeholder: "products.form.name_placeholder", - price_label: "products.form.price_label", - price_placeholder: "products.form.price_placeholder", + active_label: 'products.form.active_label', + cancel_button: 'products.form.cancel_button', + description_label: 'products.form.description_label', + description_placeholder: 'products.form.description_placeholder', + name_label: 'products.form.name_label', + name_placeholder: 'products.form.name_placeholder', + price_label: 'products.form.price_label', + price_placeholder: 'products.form.price_placeholder', }, table: { - actions: "products.table.actions", - active: "products.table.active", - description: "products.table.description", - inactive: "products.table.inactive", - name: "products.table.name", - price: "products.table.price", - status: "products.table.status", + actions: 'products.table.actions', + active: 'products.table.active', + description: 'products.table.description', + inactive: 'products.table.inactive', + name: 'products.table.name', + price: 'products.table.price', + status: 'products.table.status', }, toasts: { - created: "products.toasts.created", - delete_failed: "products.toasts.delete_failed", - deleted: "products.toasts.deleted", - updated: "products.toasts.updated", + created: 'products.toasts.created', + delete_failed: 'products.toasts.delete_failed', + deleted: 'products.toasts.deleted', + updated: 'products.toasts.updated', }, validation: { - name_required: "products.validation.name_required", - name_too_long: "products.validation.name_too_long", - price_positive: "products.validation.price_positive", - price_required: "products.validation.price_required", + name_required: 'products.validation.name_required', + name_too_long: 'products.validation.name_too_long', + price_positive: 'products.validation.price_positive', + price_required: 'products.validation.price_required', }, }, ui: { errors: { - generic_description: "ui.errors.generic_description", - generic_title: "ui.errors.generic_title", - go_home_button: "ui.errors.go_home_button", - reload_button: "ui.errors.reload_button", + generic_description: 'ui.errors.generic_description', + generic_title: 'ui.errors.generic_title', + go_home_button: 'ui.errors.go_home_button', + reload_button: 'ui.errors.reload_button', }, switcher: { - label: "ui.switcher.label", + label: 'ui.switcher.label', }, }, } as const;