feat: implement complete RBAC access control with tests
Some checks failed
CI / pytest (push) Failing after 45m29s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 9s

Add 4-layer access control stack (subscription → module → menu → permissions):
- P1: Wire requires_permission into menu sidebar filtering
- P2: Expose window.USER_PERMISSIONS for Alpine.js client-side gating
- P3: Add page-level permission guards on store routes
- P4: Role CRUD API endpoints and role editor UI
- P5: Audit trail for all role/permission changes

Includes unit tests (menu permission filtering, role CRUD service) and
integration tests (role API endpoints). All 404 core+tenancy tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:26:59 +01:00
parent 962862ccc1
commit cb3bc3c118
29 changed files with 1850 additions and 17 deletions

View File

@@ -158,6 +158,7 @@ core_module = ModuleDefinition(
route="/store/{store_code}/dashboard",
order=10,
is_mandatory=True,
requires_permission="dashboard.view",
),
],
),
@@ -181,6 +182,7 @@ core_module = ModuleDefinition(
route="/store/{store_code}/settings",
order=20,
is_mandatory=True,
requires_permission="settings.view",
),
],
),

View File

@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.api.deps import get_current_store_api, get_user_permissions
from app.core.database import get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
@@ -81,6 +81,7 @@ async def get_rendered_store_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_api),
user_perms: list = Depends(get_user_permissions),
):
"""
Get the rendered store menu for the current user.
@@ -89,6 +90,7 @@ async def get_rendered_store_menu(
- Modules enabled on the store's platform
- AdminMenuConfig visibility for the platform
- Store code for URL placeholder replacement
- User permissions (items with requires_permission are hidden if user lacks it)
Used by the store frontend to render the sidebar dynamically.
"""
@@ -100,12 +102,13 @@ async def get_rendered_store_menu(
if platform_id is None:
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
# Get filtered menu with platform visibility and store_code interpolation
# Get filtered menu with platform visibility, store_code, and permission filtering
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.STORE,
platform_id=platform_id,
store_code=store.subdomain,
user_permissions=user_perms,
)
# Resolve language

View File

@@ -212,6 +212,7 @@ class MenuDiscoveryService:
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
user_permissions: list[str] | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get filtered menu structure for frontend rendering.
@@ -220,7 +221,7 @@ class MenuDiscoveryService:
1. Module enablement (disabled modules = hidden items)
2. Visibility configuration (AdminMenuConfig preferences)
3. Super admin status (hides super_admin_only items for non-super-admins)
4. Permission requirements (future: filter by user permissions)
4. Permission requirements (filter by user permissions)
Args:
db: Database session
@@ -231,6 +232,9 @@ class MenuDiscoveryService:
store_code: Store code for route placeholder replacement
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Passed through to get_menu_sections_for_frontend.
user_permissions: List of permission IDs the user has. If provided,
items with requires_permission set will be hidden unless the
permission is in this list. None = no permission filtering.
Returns:
List of DiscoveredMenuSection with filtered and sorted items
@@ -268,6 +272,14 @@ class MenuDiscoveryService:
if item.id not in visible_item_ids:
continue
# Apply permission filtering
if (
user_permissions is not None
and item.requires_permission
and item.requires_permission not in user_permissions
):
continue
# Resolve route placeholders
if store_code and "{store_code}" in item.route:
item.route = item.route.replace("{store_code}", store_code)

View File

@@ -229,6 +229,7 @@ class MenuService:
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
user_permissions: list[str] | None = None,
) -> list:
"""
Get filtered menu structure for frontend rendering.
@@ -239,6 +240,7 @@ class MenuService:
1. Module enablement (items from disabled modules are removed)
2. Visibility configuration
3. Super admin status
4. User permissions (items with requires_permission hidden if user lacks it)
Args:
db: Database session
@@ -250,6 +252,9 @@ class MenuService:
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Used by merchant portal where a merchant
may have subscriptions across multiple platforms.
user_permissions: List of permission IDs the user has. If provided,
items with requires_permission will be hidden unless the user
has the permission. None = no permission filtering.
Returns:
List of DiscoveredMenuSection ready for rendering
@@ -262,6 +267,7 @@ class MenuService:
is_super_admin=is_super_admin,
store_code=store_code,
enabled_module_codes=enabled_module_codes,
user_permissions=user_permissions,
)
# =========================================================================

View File

@@ -179,3 +179,82 @@ class TestMenuDiscoveryServiceEnabledModuleCodes:
assert "billing" in section_ids
assert "loyalty" in section_ids
assert "account" in section_ids
@pytest.mark.unit
@pytest.mark.core
class TestMenuDiscoveryPermissionFiltering:
"""Test user_permissions parameter on get_menu_for_frontend."""
def setup_method(self):
self.service = MenuDiscoveryService()
def test_no_permission_filter_shows_all_items(self, db):
"""When user_permissions is None, no permission filtering occurs."""
enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.STORE, enabled_module_codes=enabled,
user_permissions=None,
)
# Should have items regardless of requires_permission
all_items = [item for s in sections for item in s.items]
assert len(all_items) > 0
def test_permission_filter_hides_unpermitted_items(self, db):
"""Items with requires_permission not in user_permissions are hidden."""
enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"}
# Only allow dashboard.view — should hide products, orders, etc.
sections = self.service.get_menu_for_frontend(
db, FrontendType.STORE, enabled_module_codes=enabled,
user_permissions=["dashboard.view"],
)
all_item_ids = {item.id for s in sections for item in s.items}
# Dashboard should be visible (has requires_permission="dashboard.view")
assert "dashboard" in all_item_ids
# Products requires "products.view" — should be hidden
assert "products" not in all_item_ids
def test_permission_filter_shows_permitted_items(self, db):
"""Items with matching permission are shown."""
enabled = {"core", "catalog", "orders", "tenancy"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.STORE, enabled_module_codes=enabled,
user_permissions=["dashboard.view", "products.view", "orders.view", "team.view"],
)
all_item_ids = {item.id for s in sections for item in s.items}
assert "dashboard" in all_item_ids
assert "products" in all_item_ids
assert "orders" in all_item_ids
def test_empty_permissions_list_hides_permission_required_items(self, db):
"""Empty permissions list hides all items that require a permission."""
enabled = {"core", "catalog"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.STORE, enabled_module_codes=enabled,
user_permissions=[],
)
all_item_ids = {item.id for s in sections for item in s.items}
# Items without requires_permission should still show
# Items with requires_permission should be hidden
assert "products" not in all_item_ids
assert "dashboard" not in all_item_ids
def test_items_without_requires_permission_always_visible(self, db):
"""Items that have no requires_permission are shown regardless of user_permissions."""
enabled = {"core", "catalog", "tenancy"}
# Get all store items to check which have no requires_permission
all_items_raw = self.service.get_all_menu_items(FrontendType.STORE)
items_without_perm = [
item for item in all_items_raw if item.requires_permission is None
]
if items_without_perm:
sections = self.service.get_menu_for_frontend(
db, FrontendType.STORE, enabled_module_codes=enabled,
user_permissions=["some.random.permission"],
)
all_item_ids = {item.id for s in sections for item in s.items}
# Items without requires_permission should still be visible
for item in items_without_perm:
if item.module_code in enabled:
assert item.id in all_item_ids

View File

@@ -290,18 +290,61 @@ def get_store_context(
**extra_context: Additional variables for template
Returns:
Context dict for store pages
Context dict for store pages (includes user_permissions list)
"""
# Resolve user permissions for the current store
user_permissions = _resolve_store_user_permissions(db, current_user)
return get_context_for_frontend(
FrontendType.STORE,
request,
db,
user=current_user,
store_code=store_code,
user_permissions=user_permissions,
**extra_context,
)
def _resolve_store_user_permissions(db: Session, current_user: Any) -> list[str]:
"""
Resolve the permission list for a store user.
Returns all permission IDs for the current user in their current store.
Owners get all permissions. Members get permissions from their role.
"""
from app.modules.tenancy.models import User
store_id = getattr(current_user, "token_store_id", None)
if not store_id:
return []
user_id = getattr(current_user, "user_id", None) or getattr(
current_user, "id", None
)
if not user_id:
return []
user_model = db.query(User).filter(User.id == user_id).first()
if not user_model:
return []
# Owners get all permissions
if user_model.is_owner_of(store_id):
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
return list(permission_discovery_service.get_all_permission_ids())
# Members get permissions from their store role
for membership in user_model.store_memberships:
if membership.store_id == store_id and membership.is_active:
return membership.get_all_permissions()
return []
def get_storefront_context(
request: Request,
db: Session | None = None,