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.title)}

+

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

+
+ +
+

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

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

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

+ ) : ( + + )} +
+ +
+
+

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

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

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

+ ) : ( + + )} +
+ +
+

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

+

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

+ {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/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/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..d22bfc11 --- /dev/null +++ b/modules/permissions/permissions/service.py @@ -0,0 +1,258 @@ +"""PermissionService — reads the in-memory registry and persists role/user 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 sqlalchemy.orm import selectinload + +from permissions.contracts.schemas import ( + PermissionGroupOut, + RoleOut, + RolePermissionsOut, + UserOut, + UserPermissionsOut, +) +from permissions.models import RolePermission, UserPermission + +if TYPE_CHECKING: + from simple_module_core.permissions import PermissionRegistry + 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 + (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 + + # ── Registry read-outs ───────────────────────────────────── + + def list_registered_groups(self) -> list[PermissionGroupOut]: + return [ + PermissionGroupOut(name=g.name, permissions=sorted(g.permissions)) + 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 + + 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_role_keys(role.name) + return RolePermissionsOut(role=role, permissions=sorted(keys)) + + 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()) + + 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 + + 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: + await self.db.execute( + delete(RolePermission).where( + RolePermission.role_name == role.name, + RolePermission.permission_key.in_(to_remove), + ) + ) + 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() + + # `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)) + + 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]: + """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 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()) + if not users: + return [] + + user_ids = [u.id for u in users] + counts_result = await self.db.execute( + 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] + + 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), + ) + ) + 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] + 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. + + 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) + ) + 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..7da74234 --- /dev/null +++ b/modules/permissions/tests/test_permissions_module.py @@ -0,0 +1,279 @@ +"""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.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 +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 PERM_VIEW in reg.all_permissions + assert PERM_MANAGE in reg.all_permissions + + +# ── PermissionService ────────────────────────────────────────── + + +@pytest.fixture +def registry() -> PermissionRegistry: + reg = PermissionRegistry() + reg.add_group("Products", ["products.view", "products.create"]) + reg.add_group(PERMISSION_GROUP, [PERM_VIEW, PERM_MANAGE]) + return reg + + +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=name, description=f"{name} role") + db.add(role) + await db.flush() + return role.id + + +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) + 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) + 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_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_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_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_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_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) + assert await svc.get_user_permissions(uuid.uuid4()) is None + + async def test_set_and_get_user_permissions( + self, db_session: AsyncSession, registry: PermissionRegistry + ): + user_id = await _seed_user(db_session) + svc = PermissionService(db_session, registry) + + updated = await svc.set_user_permissions(user_id, ["products.view"]) + assert updated is not None + assert updated.direct == ["products.view"] + assert updated.inherited == [] + + 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 + ): + 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"] + + 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) ───────────────────────── + + +class TestPermissionsAPI: + async def test_list_registered(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/permissions/") + assert resp.status_code == 200 + 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()}") + 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": [PERM_VIEW]}, + ) + assert resp.status_code == 200 + 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 + assert fetch.json()["permissions"] == [PERM_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_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. + 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 6dbbda8d..0c5ce155 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -116,6 +116,36 @@ 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.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 9283f19f..80681063 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -167,6 +167,46 @@ export const keys = { 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', + }, + 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', + 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', diff --git a/pyproject.toml b/pyproject.toml index ba1dafe4..eaf05093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ extra-paths = [ "modules/dashboard", "modules/products", "modules/users", + "modules/permissions", "modules/background_tasks", "modules/file_storage", "host", @@ -99,7 +100,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", "modules/background_tasks/tests", "modules/file_storage/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", "modules/background_tasks/tests", "modules/file_storage/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`)",