feat: implement complete RBAC access control with tests
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:
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user