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/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/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/host/pyproject.toml b/host/pyproject.toml index 67cf9dd8..aedc5748 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "auth", "dashboard", "products", + "permissions", "background-tasks", "file-storage", "python-multipart>=0.0.6", @@ -18,5 +19,6 @@ simple-module-hosting = { workspace = true } auth = { workspace = true } products = { workspace = true } dashboard = { workspace = true } +permissions = { workspace = true } background-tasks = { workspace = true } file-storage = { 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/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/contracts/__init__.py b/modules/permissions/permissions/contracts/__init__.py new file mode 100644 index 00000000..cbdc8ffd --- /dev/null +++ b/modules/permissions/permissions/contracts/__init__.py @@ -0,0 +1,23 @@ +"""Permissions contracts — public interface for other modules.""" + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RoleOut, + RolePermissionsOut, + RolePermissionsUpdate, + UserOut, + UserPermissionsOut, + UserPermissionsUpdate, +) +from permissions.contracts.service import IPermissionService + +__all__ = [ + "IPermissionService", + "PermissionGroupOut", + "RoleOut", + "RolePermissionsOut", + "RolePermissionsUpdate", + "UserOut", + "UserPermissionsOut", + "UserPermissionsUpdate", +] diff --git a/modules/permissions/permissions/contracts/schemas.py b/modules/permissions/permissions/contracts/schemas.py new file mode 100644 index 00000000..29eee230 --- /dev/null +++ b/modules/permissions/permissions/contracts/schemas.py @@ -0,0 +1,65 @@ +"""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) + + +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 new file mode 100644 index 00000000..5ec7ef17 --- /dev/null +++ b/modules/permissions/permissions/contracts/service.py @@ -0,0 +1,33 @@ +"""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, + UserPermissionsOut, +) + + +class IPermissionService(Protocol): + """Interface for permission-assignment operations.""" + + 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, + role_id: uuid.UUID, + 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 new file mode 100644 index 00000000..627e04e7 --- /dev/null +++ b/modules/permissions/permissions/deps.py @@ -0,0 +1,72 @@ +"""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 + +import uuid + +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 + + +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) + + +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: + """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 + a direct grant on a single user takes effect without inventing a role. + """ + + 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 service.get_user_direct_keys(uuid.UUID(str(user.id))) + if self.permission in direct: + return + + raise HTTPException( + status_code=403, + detail=f"Permission required: {self.permission}", + ) 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..81049e30 --- /dev/null +++ b/modules/permissions/permissions/endpoints/api.py @@ -0,0 +1,102 @@ +"""REST API endpoints for Permissions administration.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request + +from permissions.constants import PERM_MANAGE, PERM_VIEW +from permissions.contracts.schemas import ( + PermissionGroupOut, + RolePermissionsOut, + RolePermissionsUpdate, + UserPermissionsOut, + UserPermissionsUpdate, +) +from permissions.deps import RequiresPermission, assigned_by, get_permission_service +from permissions.service import PermissionService + +router = APIRouter() + + +@router.get( + "/", + response_model=list[PermissionGroupOut], + dependencies=[Depends(RequiresPermission(PERM_VIEW))], +) +async def list_registered( + service: PermissionService = Depends(get_permission_service), +) -> list[PermissionGroupOut]: + """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, + dependencies=[Depends(RequiresPermission(PERM_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(PERM_MANAGE))], +) +async def set_role_permissions( + role_id: uuid.UUID, + data: RolePermissionsUpdate, + request: Request, + service: PermissionService = Depends(get_permission_service), +) -> RolePermissionsOut: + 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 new file mode 100644 index 00000000..adcd277d --- /dev/null +++ b/modules/permissions/permissions/endpoints/views.py @@ -0,0 +1,144 @@ +"""Inertia view endpoints for Permissions.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query, 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 starlette.responses import RedirectResponse + +from permissions.constants import PERM_MANAGE, PERM_VIEW +from permissions.contracts.schemas import RolePermissionsUpdate, UserPermissionsUpdate +from permissions.deps import RequiresPermission, assigned_by, get_permission_service +from permissions.service import PermissionService + +router = APIRouter() + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_VIEW))], +) +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", + { + "groups": [g.model_dump(mode="json") for g in groups], + "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, + dependencies=[Depends(RequiresPermission(PERM_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(PERM_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)) + 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 new file mode 100644 index 00000000..ebb54c6e --- /dev/null +++ b/modules/permissions/permissions/locales/en.json @@ -0,0 +1,40 @@ +{ + "browse": { + "title": "Permissions", + "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": { + "title": "Edit permissions for {{role}}", + "empty": "No permissions have been registered by any installed module.", + "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.", + "user_not_found": "User not found." + } +} diff --git a/modules/permissions/permissions/models.py b/modules/permissions/permissions/models.py new file mode 100644 index 00000000..864ebe01 --- /dev/null +++ b/modules/permissions/permissions/models.py @@ -0,0 +1,70 @@ +"""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`, 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 GUID, now_utc +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import DateTime, Index +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) + + # 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.""" + + __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/module.py b/modules/permissions/permissions/module.py new file mode 100644 index 00000000..294e8d39 --- /dev/null +++ b/modules/permissions/permissions/module.py @@ -0,0 +1,64 @@ +"""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: + from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP + + registry.add_group( + PERMISSION_GROUP, + [PERM_VIEW, PERM_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..f104e40c --- /dev/null +++ b/modules/permissions/permissions/pages/Browse.tsx @@ -0,0 +1,129 @@ +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 User = { + id: string; + email: string; + full_name: string | null; + permission_count: number; +}; + +type Props = { groups: Group[]; roles: Role[]; users: User[]; search: string }; + +export default function Browse({ groups, roles, users, search }: Props) { + const { t } = useT(); + return ( +
+ {t(keys.permissions.browse.description)} +
+{t(keys.permissions.browse.no_roles)}
+ ) : ( +{t(keys.permissions.browse.no_users)}
+ ) : ( ++ {t(keys.permissions.browse.registry_hint)} +
+ {groups.length === 0 ? ( ++ {t(keys.permissions.browse.no_permissions)} +
+ ) : ( +{role.description}
} + + +{user.full_name}
} + + {roles.length > 0 && ( ++ {t(keys.permissions.user_edit.roles_label)} + {roles.join(', ')} +
+ )} + + +