Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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",
)
2 changes: 2 additions & 0 deletions host/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"auth",
"dashboard",
"products",
"permissions",
"background-tasks",
"file-storage",
"python-multipart>=0.0.6",
Expand All @@ -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 }
1 change: 1 addition & 0 deletions modules/permissions/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Permissions module."""
13 changes: 13 additions & 0 deletions modules/permissions/permissions/constants.py
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions modules/permissions/permissions/contracts/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
65 changes: 65 additions & 0 deletions modules/permissions/permissions/contracts/schemas.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions modules/permissions/permissions/contracts/service.py
Original file line number Diff line number Diff line change
@@ -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]: ...
72 changes: 72 additions & 0 deletions modules/permissions/permissions/deps.py
Original file line number Diff line number Diff line change
@@ -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}",
)
Empty file.
Loading
Loading