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

@@ -1557,6 +1557,55 @@ def get_user_permissions(
return [] return []
# ============================================================================
# PAGE-LEVEL PERMISSION GUARDS (For Store Page Routes)
# ============================================================================
def require_store_page_permission(permission: str):
"""
Dependency factory to require a specific store permission for page routes.
Same as require_store_permission but raises InsufficientStorePermissionsException
which the exception handler intercepts for HTML requests (redirecting to login).
Usage:
@router.get("/products", response_class=HTMLResponse)
def store_products_page(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(require_store_page_permission("products.view")),
db: Session = Depends(get_db),
):
...
"""
def permission_checker(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_from_cookie_or_header),
) -> UserContext:
if not current_user.token_store_id:
raise InvalidTokenException(
"Token missing store information. Please login again."
)
store_id = current_user.token_store_id
store = store_service.get_store_by_id(db, store_id)
request.state.store = store
user_model = _get_user_model(current_user, db)
if not user_model.has_store_permission(store.id, permission):
raise InsufficientStorePermissionsException(
required_permission=permission,
store_code=store.store_code,
)
return current_user
return permission_checker
# ============================================================================ # ============================================================================
# OPTIONAL AUTHENTICATION (For Login Page Redirects) # OPTIONAL AUTHENTICATION (For Login Page Redirects)
# ============================================================================ # ============================================================================

View File

@@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.services.platform_settings_service import ( from app.modules.core.services.platform_settings_service import (
platform_settings_service, # MOD-004 - shared platform service platform_settings_service, # MOD-004 - shared platform service
@@ -82,7 +82,7 @@ def get_store_context(
async def store_analytics_page( async def store_analytics_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("analytics.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """

View File

@@ -241,6 +241,7 @@ billing_module = ModuleDefinition(
icon="currency-euro", icon="currency-euro",
route="/store/{store_code}/invoices", route="/store/{store_code}/invoices",
order=30, order=30,
requires_permission="billing.view_invoices",
), ),
], ],
), ),
@@ -256,6 +257,7 @@ billing_module = ModuleDefinition(
icon="credit-card", icon="credit-card",
route="/store/{store_code}/billing", route="/store/{store_code}/billing",
order=30, order=30,
requires_permission="billing.view_subscriptions",
), ),
], ],
), ),

View File

@@ -121,6 +121,7 @@ catalog_module = ModuleDefinition(
route="/store/{store_code}/products", route="/store/{store_code}/products",
order=10, order=10,
is_mandatory=True, is_mandatory=True,
requires_permission="products.view",
), ),
], ],
), ),

View File

@@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.utils.page_context import get_store_context from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -34,7 +34,7 @@ router = APIRouter()
async def store_products_page( async def store_products_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("products.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -55,7 +55,7 @@ async def store_products_page(
async def store_product_create_page( async def store_product_create_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("products.create")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """

View File

@@ -240,6 +240,7 @@ cms_module = ModuleDefinition(
icon="document-text", icon="document-text",
route="/store/{store_code}/content-pages", route="/store/{store_code}/content-pages",
order=10, order=10,
requires_permission="cms.view_pages",
), ),
MenuItemDefinition( MenuItemDefinition(
id="media", id="media",
@@ -247,6 +248,7 @@ cms_module = ModuleDefinition(
icon="photograph", icon="photograph",
route="/store/{store_code}/media", route="/store/{store_code}/media",
order=20, order=20,
requires_permission="cms.view_media",
), ),
], ],
), ),

View File

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

View File

@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session 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.core.database import get_db
from app.modules.core.services.menu_service import menu_service from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
@@ -81,6 +81,7 @@ async def get_rendered_store_menu(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_api), current_user: UserContext = Depends(get_current_store_api),
user_perms: list = Depends(get_user_permissions),
): ):
""" """
Get the rendered store menu for the current user. 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 - Modules enabled on the store's platform
- AdminMenuConfig visibility for the platform - AdminMenuConfig visibility for the platform
- Store code for URL placeholder replacement - 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. 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: if platform_id is None:
platform_id = menu_service.get_store_primary_platform_id(db, store.id) 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( menu = menu_service.get_menu_for_rendering(
db=db, db=db,
frontend_type=FrontendType.STORE, frontend_type=FrontendType.STORE,
platform_id=platform_id, platform_id=platform_id,
store_code=store.subdomain, store_code=store.subdomain,
user_permissions=user_perms,
) )
# Resolve language # Resolve language

View File

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

View File

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

View File

@@ -179,3 +179,82 @@ class TestMenuDiscoveryServiceEnabledModuleCodes:
assert "billing" in section_ids assert "billing" in section_ids
assert "loyalty" in section_ids assert "loyalty" in section_ids
assert "account" 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 **extra_context: Additional variables for template
Returns: 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( return get_context_for_frontend(
FrontendType.STORE, FrontendType.STORE,
request, request,
db, db,
user=current_user, user=current_user,
store_code=store_code, store_code=store_code,
user_permissions=user_permissions,
**extra_context, **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( def get_storefront_context(
request: Request, request: Request,
db: Session | None = None, db: Session | None = None,

View File

@@ -128,6 +128,7 @@ customers_module = ModuleDefinition(
icon="user-group", icon="user-group",
route="/store/{store_code}/customers", route="/store/{store_code}/customers",
order=10, order=10,
requires_permission="customers.view",
), ),
], ],
), ),

View File

@@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.utils.page_context import get_store_context from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -33,7 +33,7 @@ router = APIRouter()
async def store_customers_page( async def store_customers_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("customers.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """

View File

@@ -136,6 +136,7 @@ inventory_module = ModuleDefinition(
icon="clipboard-list", icon="clipboard-list",
route="/store/{store_code}/inventory", route="/store/{store_code}/inventory",
order=20, order=20,
requires_permission="stock.view",
), ),
], ],
), ),

View File

@@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.utils.page_context import get_store_context from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -33,7 +33,7 @@ router = APIRouter()
async def store_inventory_page( async def store_inventory_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("stock.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """

View File

@@ -168,6 +168,7 @@ loyalty_module = ModuleDefinition(
icon="gift", icon="gift",
route="/store/{store_code}/loyalty/terminal", route="/store/{store_code}/loyalty/terminal",
order=10, order=10,
requires_permission="loyalty.view_programs",
), ),
MenuItemDefinition( MenuItemDefinition(
id="cards", id="cards",
@@ -175,6 +176,7 @@ loyalty_module = ModuleDefinition(
icon="identification", icon="identification",
route="/store/{store_code}/loyalty/cards", route="/store/{store_code}/loyalty/cards",
order=20, order=20,
requires_permission="loyalty.view_programs",
), ),
MenuItemDefinition( MenuItemDefinition(
id="stats", id="stats",
@@ -182,6 +184,7 @@ loyalty_module = ModuleDefinition(
icon="chart-bar", icon="chart-bar",
route="/store/{store_code}/loyalty/stats", route="/store/{store_code}/loyalty/stats",
order=30, order=30,
requires_permission="loyalty.view_programs",
), ),
], ],
), ),

View File

@@ -147,6 +147,7 @@ messaging_module = ModuleDefinition(
icon="chat-bubble-left-right", icon="chat-bubble-left-right",
route="/store/{store_code}/messages", route="/store/{store_code}/messages",
order=20, order=20,
requires_permission="messaging.view_messages",
), ),
MenuItemDefinition( MenuItemDefinition(
id="notifications", id="notifications",
@@ -154,6 +155,7 @@ messaging_module = ModuleDefinition(
icon="bell", icon="bell",
route="/store/{store_code}/notifications", route="/store/{store_code}/notifications",
order=30, order=30,
requires_permission="messaging.view_messages",
), ),
], ],
), ),
@@ -169,6 +171,7 @@ messaging_module = ModuleDefinition(
icon="mail", icon="mail",
route="/store/{store_code}/email-templates", route="/store/{store_code}/email-templates",
order=40, order=40,
requires_permission="messaging.manage_templates",
), ),
], ],
), ),

View File

@@ -133,6 +133,7 @@ orders_module = ModuleDefinition(
route="/store/{store_code}/orders", route="/store/{store_code}/orders",
order=10, order=10,
is_mandatory=True, is_mandatory=True,
requires_permission="orders.view",
), ),
], ],
), ),

View File

@@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.utils.page_context import get_store_context from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -34,7 +34,7 @@ router = APIRouter()
async def store_orders_page( async def store_orders_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("orders.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -56,7 +56,7 @@ async def store_order_detail_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
order_id: int = Path(..., description="Order ID"), order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("orders.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """

View File

@@ -28,7 +28,10 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept, InvitationAccept,
InvitationAcceptResponse, InvitationAcceptResponse,
InvitationResponse, InvitationResponse,
RoleCreate,
RoleListResponse, RoleListResponse,
RoleResponse,
RoleUpdate,
TeamMemberInvite, TeamMemberInvite,
TeamMemberListResponse, TeamMemberListResponse,
TeamMemberResponse, TeamMemberResponse,
@@ -392,6 +395,86 @@ def list_roles(
return RoleListResponse(roles=roles, total=len(roles)) return RoleListResponse(roles=roles, total=len(roles))
@store_team_router.post("/roles", response_model=RoleResponse, status_code=201)
def create_role(
request: Request,
role_data: RoleCreate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Create a custom role for the store.
**Required:** Store owner only.
Preset role names (manager, staff, support, viewer, marketing) cannot be used.
"""
store = request.state.store
role = store_team_service.create_custom_role(
db=db,
store_id=store.id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_user.id,
)
db.commit()
return role
@store_team_router.put("/roles/{role_id}", response_model=RoleResponse)
def update_role(
request: Request,
role_id: int,
role_data: RoleUpdate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Update a role's name and/or permissions.
**Required:** Store owner only.
"""
store = request.state.store
role = store_team_service.update_role(
db=db,
store_id=store.id,
role_id=role_id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_user.id,
)
db.commit()
return role
@store_team_router.delete("/roles/{role_id}", status_code=204)
def delete_role(
request: Request,
role_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Delete a custom role.
**Required:** Store owner only.
Preset roles cannot be deleted.
Roles with assigned team members cannot be deleted.
"""
store = request.state.store
store_team_service.delete_role(
db=db,
store_id=store.id,
role_id=role_id,
actor_user_id=current_user.id,
)
db.commit()
# ============================================================================ # ============================================================================
# Permission Routes # Permission Routes
# ============================================================================ # ============================================================================

View File

@@ -19,6 +19,7 @@ from app.api.deps import (
get_current_store_optional, get_current_store_optional,
get_db, get_db,
get_resolved_store_code, get_resolved_store_code,
require_store_page_permission,
) )
from app.modules.core.utils.page_context import get_store_context from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -90,7 +91,7 @@ async def store_login_page(
async def store_team_page( async def store_team_page(
request: Request, request: Request,
store_code: str = Depends(get_resolved_store_code), store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header), current_user: User = Depends(require_store_page_permission("team.view")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -103,6 +104,26 @@ async def store_team_page(
) )
@router.get(
"/team/roles", response_class=HTMLResponse, include_in_schema=False
)
async def store_roles_page(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(require_store_page_permission("team.view")),
db: Session = Depends(get_db),
):
"""
Render role editor page.
Store owners can create, edit, and delete custom roles
with a permission matrix UI.
"""
return templates.TemplateResponse(
"tenancy/store/roles.html",
get_store_context(request, db, current_user, store_code),
)
@router.get( @router.get(
"/profile", response_class=HTMLResponse, include_in_schema=False "/profile", response_class=HTMLResponse, include_in_schema=False
) )

View File

@@ -26,9 +26,11 @@ def get_preset_permissions(preset_name: str) -> set[str]:
"""Get permissions for a preset role.""" """Get permissions for a preset role."""
return permission_discovery_service.get_preset_permissions(preset_name) return permission_discovery_service.get_preset_permissions(preset_name)
from app.modules.billing.exceptions import TierLimitExceededException from app.modules.billing.exceptions import TierLimitExceededException
from app.modules.core.services.audit_aggregator import audit_aggregator
from app.modules.tenancy.exceptions import ( from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException, CannotRemoveOwnerException,
InvalidInvitationTokenException, InvalidInvitationTokenException,
InvalidRoleException,
TeamInvitationAlreadyAcceptedException, TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException, TeamMemberAlreadyExistsException,
UserNotFoundException, UserNotFoundException,
@@ -174,6 +176,20 @@ class StoreTeamService:
# TODO: Send invitation email # TODO: Send invitation email
# self._send_invitation_email(email, store, invitation_token) # self._send_invitation_email(email, store, invitation_token)
audit_aggregator.log(
db=db,
admin_user_id=inviter.id,
action="member.invite",
target_type="store_user",
target_id=str(store_user.id),
details={
"email": email,
"role": role_name,
"store_id": store.id,
"store_code": store.store_code,
},
)
return { return {
"invitation_token": invitation_token, "invitation_token": invitation_token,
"email": email, "email": email,
@@ -274,6 +290,7 @@ class StoreTeamService:
db: Session, db: Session,
store: Store, store: Store,
user_id: int, user_id: int,
actor_user_id: int | None = None,
) -> bool: ) -> bool:
""" """
Remove a team member from a store. Remove a team member from a store.
@@ -309,6 +326,21 @@ class StoreTeamService:
store_user.is_active = False store_user.is_active = False
logger.info(f"Removed user {user_id} from store {store.store_code}") logger.info(f"Removed user {user_id} from store {store.store_code}")
if actor_user_id is not None:
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="member.remove",
target_type="store_user",
target_id=str(store_user.id),
details={
"user_id": user_id,
"store_id": store.id,
"store_code": store.store_code,
},
)
return True return True
except (UserNotFoundException, CannotRemoveOwnerException): except (UserNotFoundException, CannotRemoveOwnerException):
@@ -324,6 +356,7 @@ class StoreTeamService:
user_id: int, user_id: int,
new_role_name: str, new_role_name: str,
custom_permissions: list[str] | None = None, custom_permissions: list[str] | None = None,
actor_user_id: int | None = None,
) -> StoreUser: ) -> StoreUser:
""" """
Update a team member's role. Update a team member's role.
@@ -363,14 +396,31 @@ class StoreTeamService:
custom_permissions=custom_permissions, custom_permissions=custom_permissions,
) )
old_role_name = store_user.role.name if store_user.role else "none"
store_user.role_id = new_role.id store_user.role_id = new_role.id
db.flush() db.flush()
db.refresh(store_user)
logger.info( logger.info(
f"Updated role for user {user_id} in store {store.store_code} " f"Updated role for user {user_id} in store {store.store_code} "
f"to {new_role_name}" f"to {new_role_name}"
) )
if actor_user_id is not None:
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="member.role_change",
target_type="store_user",
target_id=str(store_user.id),
details={
"user_id": user_id,
"store_id": store.id,
"old_role": old_role_name,
"new_role": new_role_name,
},
)
return store_user return store_user
except (UserNotFoundException, CannotRemoveOwnerException): except (UserNotFoundException, CannotRemoveOwnerException):
@@ -470,6 +520,210 @@ class StoreTeamService:
for role in roles for role in roles
] ]
# ========================================================================
# Role CRUD
# ========================================================================
PRESET_ROLE_NAMES = {"manager", "staff", "support", "viewer", "marketing"}
def create_custom_role(
self,
db: Session,
store_id: int,
name: str,
permissions: list[str],
actor_user_id: int | None = None,
) -> Role:
"""
Create a custom role for a store.
Args:
db: Database session
store_id: Store ID
name: Role name
permissions: List of permission IDs
actor_user_id: ID of user performing the action (for audit)
Returns:
Created Role object
Raises:
InvalidRoleException: If role name conflicts with a preset
"""
if name.lower() in self.PRESET_ROLE_NAMES:
raise InvalidRoleException(f"Cannot create role with preset name: {name}")
# Check for duplicate name
existing = (
db.query(Role)
.filter(Role.store_id == store_id, Role.name == name)
.first()
)
if existing:
raise InvalidRoleException(f"Role '{name}' already exists for this store")
# Validate permissions exist
valid_ids = permission_discovery_service.get_all_permission_ids()
invalid = set(permissions) - valid_ids
if invalid:
raise InvalidRoleException(f"Invalid permission IDs: {', '.join(sorted(invalid))}")
role = Role(
store_id=store_id,
name=name,
permissions=permissions,
)
db.add(role)
db.flush()
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.create",
target_type="role",
target_id=str(role.id),
details={"name": name, "permissions_count": len(permissions), "store_id": store_id},
)
return role
def update_role(
self,
db: Session,
store_id: int,
role_id: int,
name: str | None = None,
permissions: list[str] | None = None,
actor_user_id: int | None = None,
) -> Role:
"""
Update a role's name and/or permissions.
Args:
db: Database session
store_id: Store ID (for ownership check)
role_id: Role ID to update
name: New role name (optional)
permissions: New permission list (optional)
Returns:
Updated Role object
Raises:
InvalidRoleException: If role not found or name conflicts
"""
role = (
db.query(Role)
.filter(Role.id == role_id, Role.store_id == store_id)
.first()
)
if not role:
raise InvalidRoleException(f"Role {role_id} not found for this store")
if name is not None:
if name.lower() in self.PRESET_ROLE_NAMES and role.name.lower() != name.lower():
raise InvalidRoleException(f"Cannot rename to preset name: {name}")
# Check duplicate
duplicate = (
db.query(Role)
.filter(
Role.store_id == store_id,
Role.name == name,
Role.id != role_id,
)
.first()
)
if duplicate:
raise InvalidRoleException(f"Role '{name}' already exists for this store")
role.name = name
if permissions is not None:
valid_ids = permission_discovery_service.get_all_permission_ids()
invalid = set(permissions) - valid_ids
if invalid:
raise InvalidRoleException(
f"Invalid permission IDs: {', '.join(sorted(invalid))}"
)
old_permissions = role.permissions or []
role.permissions = permissions
db.flush()
details = {"role_name": role.name, "store_id": store_id}
if name is not None:
details["new_name"] = name
if permissions is not None:
added = set(permissions) - set(old_permissions)
removed = set(old_permissions) - set(permissions)
if added:
details["permissions_added"] = sorted(added)
if removed:
details["permissions_removed"] = sorted(removed)
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.update",
target_type="role",
target_id=str(role.id),
details=details,
)
return role
def delete_role(
self,
db: Session,
store_id: int,
role_id: int,
actor_user_id: int | None = None,
) -> None:
"""
Delete a custom role. Preset roles cannot be deleted.
Args:
db: Database session
store_id: Store ID (for ownership check)
role_id: Role ID to delete
Raises:
InvalidRoleException: If role not found or is a preset role
"""
role = (
db.query(Role)
.filter(Role.id == role_id, Role.store_id == store_id)
.first()
)
if not role:
raise InvalidRoleException(f"Role {role_id} not found for this store")
if role.name.lower() in self.PRESET_ROLE_NAMES:
raise InvalidRoleException(f"Cannot delete preset role: {role.name}")
# Check if any team members use this role
members_with_role = (
db.query(StoreUser)
.filter(StoreUser.store_id == store_id, StoreUser.role_id == role_id)
.count()
)
if members_with_role > 0:
raise InvalidRoleException(
f"Cannot delete role: {members_with_role} team member(s) still assigned"
)
role_name = role.name
db.delete(role)
db.flush()
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.delete",
target_type="role",
target_id=str(role_id),
details={"role_name": role_name, "store_id": store_id},
)
# Private helper methods # Private helper methods
def _generate_invitation_token(self) -> str: def _generate_invitation_token(self) -> str:

View File

@@ -0,0 +1,338 @@
{# app/templates/store/roles.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Role Management{% endblock %}
{% block alpine_data %}storeRoles(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Role Management', subtitle='Create and manage custom roles with granular permissions') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loading', onclick='loadRoles()', variant='secondary') }}
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Role
</button>
</div>
{% endcall %}
{{ loading_state('Loading roles...') }}
{{ error_state('Error loading roles') }}
<!-- Roles List -->
<div x-show="!loading && !error" class="space-y-6">
<template x-for="role in roles" :key="role.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="role.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="(role.permissions || []).length"></span> permissions
<template x-if="isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full dark:bg-blue-900 dark:text-blue-200">Preset</span>
</template>
<template x-if="!isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full dark:bg-green-900 dark:text-green-200">Custom</span>
</template>
</p>
</div>
<div class="flex items-center gap-2">
<button
@click="openEditModal(role)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/20 dark:hover:bg-purple-900/40"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit
</button>
<button
x-show="!isPresetRole(role.name)"
@click="confirmDelete(role)"
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
</div>
</div>
<!-- Permission tags -->
<div class="flex flex-wrap gap-1.5">
<template x-for="perm in (role.permissions || [])" :key="perm">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300" x-text="perm"></span>
</template>
<template x-if="!role.permissions || role.permissions.length === 0">
<span class="text-sm text-gray-400 dark:text-gray-500">No permissions assigned</span>
</template>
</div>
</div>
</template>
<template x-if="roles.length === 0 && !loading">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield', 'w-12 h-12 mx-auto mb-4 opacity-50')"></span>
<p>No roles found. Create a custom role to get started.</p>
</div>
</template>
</div>
<!-- Create/Edit Role Modal -->
{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %}
<div class="space-y-4">
<!-- Role Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role Name</label>
<input
type="text"
x-model="roleForm.name"
placeholder="e.g. Content Editor"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<!-- Permission Matrix -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="(perms, category) in permissionsByCategory" :key="category">
<div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category"></span>
<button
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="isCategoryFullySelected(category) ? 'Deselect All' : 'Select All'"
></button>
</div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in perms" :key="perm.id">
<label class="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
:value="perm.id"
:checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="perm.id"></span>
</label>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button
@click="showRoleModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>Cancel</button>
<button
@click="saveRole()"
:disabled="saving || !roleForm.name.trim()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="saving" class="inline-block animate-spin mr-1">&#8635;</span>
<span x-text="editingRole ? 'Update Role' : 'Create Role'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script>
function storeRoles() {
return {
roles: [],
loading: true,
error: false,
saving: false,
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionsByCategory: {},
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
await this.loadPermissions();
await this.loadRoles();
},
async loadPermissions() {
try {
const resp = await fetch(`/api/v1/store/team/me/permissions`, {
headers: { 'Authorization': `Bearer ${this.getToken()}` }
});
// We need a permissions-by-category endpoint; for now use a simple list
// Group known permissions by category prefix
const allPerms = window.USER_PERMISSIONS || [];
this.permissionsByCategory = this.groupPermissions(allPerms);
} catch (e) {
console.warn('Could not load permission categories:', e);
}
},
groupPermissions(permIds) {
// Known permission categories from the codebase
const knownPerms = [
'dashboard.view',
'settings.view', 'settings.edit', 'settings.theme', 'settings.domains',
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.import', 'products.export',
'orders.view', 'orders.edit', 'orders.cancel', 'orders.refund',
'customers.view', 'customers.edit', 'customers.delete', 'customers.export',
'stock.view', 'stock.edit', 'stock.transfer',
'team.view', 'team.invite', 'team.edit', 'team.remove',
'analytics.view', 'analytics.export',
'messaging.view_messages', 'messaging.send_messages', 'messaging.manage_templates',
'billing.view_tiers', 'billing.manage_tiers', 'billing.view_subscriptions', 'billing.manage_subscriptions', 'billing.view_invoices',
'cms.view_pages', 'cms.manage_pages', 'cms.view_media', 'cms.manage_media', 'cms.manage_themes',
'loyalty.view_programs', 'loyalty.manage_programs', 'loyalty.view_rewards', 'loyalty.manage_rewards',
'cart.view', 'cart.manage',
];
const groups = {};
for (const perm of knownPerms) {
const cat = perm.split('.')[0];
if (!groups[cat]) groups[cat] = [];
groups[cat].push({ id: perm });
}
return groups;
},
async loadRoles() {
this.loading = true;
this.error = false;
try {
const resp = await fetch(`/api/v1/store/team/roles`, {
headers: { 'Authorization': `Bearer ${this.getToken()}` }
});
if (!resp.ok) throw new Error('Failed to load roles');
const data = await resp.json();
this.roles = data.roles || [];
} catch (e) {
this.error = true;
console.error('Error loading roles:', e);
} finally {
this.loading = false;
}
},
isPresetRole(name) {
return this.presetRoles.includes(name.toLowerCase());
},
openCreateModal() {
this.editingRole = null;
this.roleForm = { name: '', permissions: [] };
this.showRoleModal = true;
},
openEditModal(role) {
this.editingRole = role;
this.roleForm = {
name: role.name,
permissions: [...(role.permissions || [])],
};
this.showRoleModal = true;
},
togglePermission(permId) {
const idx = this.roleForm.permissions.indexOf(permId);
if (idx >= 0) {
this.roleForm.permissions.splice(idx, 1);
} else {
this.roleForm.permissions.push(permId);
}
},
toggleCategory(category) {
const perms = this.permissionsByCategory[category] || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
this.roleForm.permissions = this.roleForm.permissions.filter(id => !permIds.includes(id));
} else {
for (const id of permIds) {
if (!this.roleForm.permissions.includes(id)) {
this.roleForm.permissions.push(id);
}
}
}
},
isCategoryFullySelected(category) {
const perms = this.permissionsByCategory[category] || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},
async saveRole() {
this.saving = true;
try {
const url = this.editingRole
? `/api/v1/store/team/roles/${this.editingRole.id}`
: '/api/v1/store/team/roles';
const method = this.editingRole ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${this.getToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(this.roleForm),
});
if (!resp.ok) {
const err = await resp.json();
alert(err.detail || 'Failed to save role');
return;
}
this.showRoleModal = false;
await this.loadRoles();
} catch (e) {
console.error('Error saving role:', e);
alert('Failed to save role');
} finally {
this.saving = false;
}
},
async confirmDelete(role) {
if (!confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
try {
const resp = await fetch(`/api/v1/store/team/roles/${role.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${this.getToken()}` },
});
if (!resp.ok) {
const err = await resp.json();
alert(err.detail || 'Failed to delete role');
return;
}
await this.loadRoles();
} catch (e) {
console.error('Error deleting role:', e);
alert('Failed to delete role');
}
},
getToken() {
return document.cookie.split(';')
.map(c => c.trim())
.find(c => c.startsWith('store_token='))
?.split('=')[1] || '';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,355 @@
# app/modules/tenancy/tests/integration/test_store_team_roles_api.py
"""
Integration tests for store team role CRUD API endpoints.
Tests the role management endpoints at:
/api/v1/store/team/roles
Authentication: Overrides get_current_store_from_cookie_or_header to return
a UserContext with the correct token_store_id. The test user is the merchant
owner, so all permission checks pass (owner bypass).
"""
import uuid
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/store/team"
@pytest.fixture
def role_owner(db):
"""Create a store owner user for role tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"roleowner_{uid}@test.com",
username=f"roleowner_{uid}",
hashed_password=auth.hash_password("rolepass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def role_merchant(db, role_owner):
"""Create a merchant owned by role_owner."""
merchant = Merchant(
name="Role Test Merchant",
owner_user_id=role_owner.id,
contact_email=role_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def role_store(db, role_merchant):
"""Create a store for role tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=role_merchant.id,
store_code=f"ROLETEST_{uid.upper()}",
subdomain=f"roletest{uid}",
name=f"Role Test Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def role_store_user(db, role_store, role_owner):
"""Create a StoreUser association for the owner."""
store_user = StoreUser(
store_id=role_store.id,
user_id=role_owner.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
@pytest.fixture
def role_auth(role_owner, role_store, role_store_user):
"""Override auth dependency to simulate authenticated store owner.
Overrides get_current_store_from_cookie_or_header so that both
require_store_owner and require_store_permission(...) inner functions
receive the correct UserContext. The owner bypass ensures all
permission checks pass.
"""
user_context = UserContext(
id=role_owner.id,
email=role_owner.email,
username=role_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=role_store.id,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
@pytest.fixture
def existing_custom_role(db, role_store):
"""Create an existing custom role for update/delete tests."""
role = Role(
store_id=role_store.id,
name="test_custom_role",
permissions=["products.view", "orders.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
# ============================================================================
# GET /team/roles
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestListRoles:
"""Tests for GET /api/v1/store/team/roles."""
def test_list_roles_creates_defaults(self, client, role_auth, role_store):
"""GET /roles returns default preset roles when none exist."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert data["total"] >= 5
role_names = {r["name"] for r in data["roles"]}
assert "manager" in role_names
assert "staff" in role_names
assert "support" in role_names
assert "viewer" in role_names
assert "marketing" in role_names
def test_list_roles_includes_custom(self, client, role_auth, existing_custom_role):
"""GET /roles includes custom roles alongside presets."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
data = response.json()
role_names = {r["name"] for r in data["roles"]}
assert "test_custom_role" in role_names
def test_list_roles_response_shape(self, client, role_auth, existing_custom_role):
"""Each role in the response has expected fields."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
role = response.json()["roles"][0]
assert "id" in role
assert "name" in role
assert "permissions" in role
assert isinstance(role["permissions"], list)
# ============================================================================
# POST /team/roles
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestCreateRole:
"""Tests for POST /api/v1/store/team/roles."""
def test_create_custom_role_success(self, client, role_auth, role_store):
"""POST /roles creates a new custom role."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "api_test_role",
"permissions": ["products.view", "orders.view"],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "api_test_role"
assert "products.view" in data["permissions"]
assert "orders.view" in data["permissions"]
def test_create_role_preset_name_rejected(self, client, role_auth):
"""POST /roles rejects preset role names."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "manager",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
def test_create_role_duplicate_name_rejected(
self, client, role_auth, existing_custom_role
):
"""POST /roles rejects duplicate role names."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "test_custom_role",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
assert "already exists" in response.json()["message"].lower()
def test_create_role_invalid_permissions(self, client, role_auth):
"""POST /roles rejects invalid permission IDs."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "bad_perms_role",
"permissions": ["totally.fake.permission"],
},
)
assert response.status_code == 422
assert "invalid" in response.json()["message"].lower()
# ============================================================================
# PUT /team/roles/{role_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestUpdateRole:
"""Tests for PUT /api/v1/store/team/roles/{role_id}."""
def test_update_role_name(self, client, role_auth, existing_custom_role):
"""PUT /roles/{id} updates the role name."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={
"name": "renamed_role",
"permissions": existing_custom_role.permissions,
},
)
assert response.status_code == 200
assert response.json()["name"] == "renamed_role"
def test_update_role_permissions(self, client, role_auth, existing_custom_role):
"""PUT /roles/{id} updates permissions."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={
"name": existing_custom_role.name,
"permissions": ["products.view", "products.edit", "orders.view", "orders.edit"],
},
)
assert response.status_code == 200
data = response.json()
assert "products.edit" in data["permissions"]
assert "orders.edit" in data["permissions"]
def test_update_nonexistent_role(self, client, role_auth):
"""PUT /roles/{id} returns 400 for non-existent role."""
response = client.put(
f"{BASE}/roles/99999",
headers=role_auth,
json={"name": "whatever", "permissions": []},
)
assert response.status_code == 422
def test_update_role_rename_to_preset_rejected(
self, client, role_auth, existing_custom_role
):
"""PUT /roles/{id} rejects renaming to a preset name."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={"name": "staff", "permissions": existing_custom_role.permissions},
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
# ============================================================================
# DELETE /team/roles/{role_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestDeleteRole:
"""Tests for DELETE /api/v1/store/team/roles/{role_id}."""
def test_delete_custom_role_success(self, client, role_auth, existing_custom_role, db):
"""DELETE /roles/{id} removes a custom role."""
response = client.delete(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
)
assert response.status_code == 204
# Verify role is deleted
db.expire_all()
deleted = db.query(Role).filter(Role.id == existing_custom_role.id).first()
assert deleted is None
def test_delete_nonexistent_role(self, client, role_auth):
"""DELETE /roles/{id} returns 400 for non-existent role."""
response = client.delete(
f"{BASE}/roles/99999",
headers=role_auth,
)
assert response.status_code == 422
def test_delete_preset_role_rejected(self, client, role_auth, role_store, db):
"""DELETE /roles/{id} rejects deleting preset roles."""
preset_role = Role(
store_id=role_store.id,
name="staff",
permissions=["orders.view"],
)
db.add(preset_role)
db.commit()
db.refresh(preset_role)
response = client.delete(
f"{BASE}/roles/{preset_role.id}",
headers=role_auth,
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()

View File

@@ -9,6 +9,7 @@ import pytest
from app.modules.tenancy.exceptions import ( from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException, CannotRemoveOwnerException,
InvalidInvitationTokenException, InvalidInvitationTokenException,
InvalidRoleException,
UserNotFoundException, UserNotFoundException,
) )
from app.modules.tenancy.models import Role, Store, StoreUser, User from app.modules.tenancy.models import Role, Store, StoreUser, User
@@ -417,3 +418,313 @@ class TestStoreTeamServiceGetRoles:
for role in roles: for role in roles:
assert "permissions" in role assert "permissions" in role
assert isinstance(role["permissions"], list) assert isinstance(role["permissions"], list)
# =============================================================================
# CUSTOM ROLE CRUD TESTS
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceCreateCustomRole:
"""Test suite for creating custom roles."""
def test_create_custom_role_success(self, db, team_store, test_user):
"""Test creating a custom role with valid permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="content_editor",
permissions=["products.view", "products.edit"],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "content_editor"
assert role.store_id == team_store.id
assert "products.view" in role.permissions
assert "products.edit" in role.permissions
def test_create_role_with_preset_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with a preset name raises ValueError."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_preset_name_case_insensitive(self, db, team_store, test_user):
"""Test preset name check is case-insensitive."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="Manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_duplicate_role_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with duplicate name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_invalid_permissions_raises_error(self, db, team_store, test_user):
"""Test creating a role with invalid permission IDs raises ValueError."""
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="invalid_role",
permissions=["completely.fake.permission"],
actor_user_id=test_user.id,
)
def test_create_role_with_empty_permissions(self, db, team_store, test_user):
"""Test creating a role with empty permissions list."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="observer",
permissions=[],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "observer"
assert role.permissions == []
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceUpdateCustomRole:
"""Test suite for updating roles."""
def test_update_role_name(self, db, team_store, test_user):
"""Test updating a role's name."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="old_name",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="new_name",
actor_user_id=test_user.id,
)
db.commit()
assert updated.name == "new_name"
def test_update_role_permissions(self, db, team_store, test_user):
"""Test updating a role's permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="perm_test",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["products.view", "products.edit", "orders.view"],
actor_user_id=test_user.id,
)
db.commit()
assert "products.edit" in updated.permissions
assert "orders.view" in updated.permissions
def test_update_role_not_found_raises_error(self, db, team_store):
"""Test updating a non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=99999,
name="new_name",
)
def test_update_role_rename_to_preset_raises_error(self, db, team_store, test_user):
"""Test renaming to a preset name raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="custom_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="staff",
)
def test_update_role_with_invalid_permissions(self, db, team_store, test_user):
"""Test updating with invalid permissions raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_update",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["nonexistent.perm"],
)
def test_update_role_duplicate_name_raises_error(self, db, team_store, test_user):
"""Test renaming to an existing role name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_a",
permissions=["products.view"],
actor_user_id=test_user.id,
)
role_b = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_b",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role_b.id,
name="role_a",
)
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceDeleteRole:
"""Test suite for deleting roles."""
def test_delete_custom_role_success(self, db, team_store, test_user):
"""Test deleting a custom role."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_delete",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
role_id = role.id
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role_id,
actor_user_id=test_user.id,
)
db.commit()
# Verify role is deleted
deleted = db.query(Role).filter(Role.id == role_id).first()
assert deleted is None
def test_delete_preset_role_raises_error(self, db, team_store):
"""Test deleting a preset role raises ValueError."""
# Ensure default roles exist
store_team_service.get_store_roles(db, team_store.id)
db.flush()
# Find the "staff" preset role
staff_role = (
db.query(Role)
.filter(Role.store_id == team_store.id, Role.name == "staff")
.first()
)
assert staff_role is not None
with pytest.raises(InvalidRoleException, match="preset role"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=staff_role.id,
)
def test_delete_role_not_found_raises_error(self, db, team_store):
"""Test deleting non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=99999,
)
def test_delete_role_with_members_raises_error(self, db, team_store, test_user, other_user, auth_manager):
"""Test deleting a role that has assigned members raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="in_use_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
# Assign the role to a store user
store_user = StoreUser(
store_id=team_store.id,
user_id=other_user.id,
role_id=role.id,
is_active=True,
invitation_accepted_at=datetime.utcnow(),
)
db.add(store_user)
db.flush()
with pytest.raises(InvalidRoleException, match="team member"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role.id,
)

View File

@@ -62,6 +62,7 @@
currency: '{{ storefront_currency }}', currency: '{{ storefront_currency }}',
dashboardLanguage: '{{ dashboard_language }}' dashboardLanguage: '{{ dashboard_language }}'
}; };
window.USER_PERMISSIONS = {{ user_permissions | default([]) | tojson }};
</script> </script>
<!-- 2. SECOND: Icons (before Alpine.js) --> <!-- 2. SECOND: Icons (before Alpine.js) -->

View File

@@ -0,0 +1,260 @@
# Complete Access Control Stack
The Orion platform enforces access control through a **4-layer stack**. Each layer filters at a different level, and they compose together to determine what a user can see and do.
> **Think of it as:** subscription controls **WHAT** you can do, modules control **WHERE** it exists, menu config controls **WHAT's shown**, permissions control **WHO** can do it.
## Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ INCOMING REQUEST │
└──────────────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 1: SUBSCRIPTION GATING "What you can do" │
│ │
│ TierFeatureLimit → binary/quantitative caps per subscription tier │
│ MerchantFeatureOverride → per-merchant exceptions │
│ FeatureService.check_resource_limit() → enforcement point │
│ │
│ Example: Free tier → 50 products max. Pro tier → unlimited. │
│ Example: Binary feature "advanced_analytics" → on/off per tier. │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ allowed
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 2: MODULE ENABLEMENT "Where it exists" │
│ │
│ PlatformModule table → per-platform module on/off │
│ Core modules always enabled; optional modules toggled per platform │
│ Auto-discovered from app/modules/*/definition.py │
│ │
│ Example: OMS platform has catalog + orders enabled. │
│ Example: Loyalty platform has loyalty + analytics but no inventory. │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ module enabled
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 3: MENU VISIBILITY "What's shown" │
│ │
│ AdminMenuConfig → opt-out model (only hidden items stored) │
│ Platform scope → applies to all users on platform │
│ User scope → personal preference (super admins only) │
│ MenuDiscoveryService → filtering pipeline │
│ │
│ Example: Platform admin hides "code-quality" from store sidebar. │
│ Example: Mandatory items (dashboard) cannot be hidden. │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ visible
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 4: ROLE PERMISSIONS "Who can do it" │
│ │
│ Module-declared permissions via PermissionDefinition │
│ Role presets: owner, manager, staff, support, viewer, marketing │
│ Per-store roles via StoreUser.role_id → Role.permissions │
│ Owner bypass: Merchant.owner_user_id gets all permissions │
│ │
│ Example: "viewer" role → can see products but not edit them. │
│ Example: Owner sees everything, no role record needed. │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ permitted
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER SEES / DOES THE THING │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Layer 1: Subscription Gating
Subscription gating controls **what a store can do** based on its merchant's subscription tier.
### How It Works
1. Each subscription tier (Free, Starter, Pro, Enterprise) has a set of **feature limits** defined in the `TierFeatureLimit` table.
2. Features are either **binary** (on/off) or **quantitative** (numeric cap).
3. Modules declare their billable features via `FeatureProviderProtocol` — a cross-module interface that lets each module own its feature definitions.
4. Admins can override limits per-merchant using `MerchantFeatureOverride`.
### Enforcement
```python
from app.modules.billing.services.feature_service import feature_service
# Check before allowing an action
allowed, message = feature_service.check_resource_limit(
db=db,
feature_code="products",
store_id=store.id,
)
if not allowed:
raise TierLimitExceededException(message)
```
### Key Models
| Model | Table | Purpose |
|-------|-------|---------|
| `TierFeatureLimit` | `tier_feature_limits` | Per-tier feature caps (binary/quantitative) |
| `MerchantFeatureOverride` | `merchant_feature_overrides` | Per-merchant exceptions to tier limits |
### Resolution Order
```
MerchantFeatureOverride (if exists) → TierFeatureLimit → denied
```
If a merchant has an override for a feature, it takes precedence over the tier default.
## Layer 2: Module Enablement
Module enablement controls **where functionality exists** at the platform level.
### How It Works
1. Modules are auto-discovered from `app/modules/*/definition.py`.
2. **Core modules** (core, tenancy, cms, customers, billing, payments, messaging, contracts) are always enabled.
3. **Optional modules** (catalog, orders, inventory, analytics, etc.) can be enabled/disabled per platform via the `PlatformModule` table.
4. When a module is disabled, its routes, menu items, and features are excluded from the platform.
### Key Model
| Model | Table | Purpose |
|-------|-------|---------|
| `PlatformModule` | `platform_modules` | Junction table: which modules are enabled per platform |
### Impact on Other Layers
- **Menu items** from disabled modules are automatically excluded by `MenuDiscoveryService`
- **Features** from disabled modules are not available for subscription gating
- **Permissions** from disabled modules are not shown in role management
## Layer 3: Menu Visibility
Menu visibility controls **what's shown** in the sidebar for admin and store interfaces.
### How It Works
1. Each module defines its menu items in `definition.py` using `MenuSectionDefinition` and `MenuItemDefinition`.
2. `MenuDiscoveryService` aggregates items from all enabled modules and applies filtering.
3. `AdminMenuConfig` stores **visibility overrides** using an opt-out model: all items visible by default, only hidden items stored in the database.
4. Mandatory items (`is_mandatory=True`) cannot be hidden.
### Filtering Pipeline
```
All Module Menu Items
→ Remove items from disabled modules (Layer 2)
→ Remove super_admin_only items for non-super-admins
→ Remove items hidden via AdminMenuConfig
→ Remove items requiring permissions the user lacks (Layer 4)
= Final visible menu
```
### Scope
| Scope | Who it affects | Frontend |
|-------|----------------|----------|
| Platform | All platform admins and stores on that platform | Admin + Store |
| User | Individual super admin | Admin only |
### Key Model
| Model | Table | Purpose |
|-------|-------|---------|
| `AdminMenuConfig` | `admin_menu_configs` | Visibility overrides per platform or user |
## Layer 4: Role Permissions
Role permissions control **who can do what** at the store level.
### How It Works
1. Each module declares its permissions in `definition.py` using `PermissionDefinition`:
```python
PermissionDefinition(
id="products.view",
label_key="catalog.permission.products_view",
description_key="catalog.permission.products_view_desc",
category="products",
)
```
2. `PermissionDiscoveryService` aggregates permissions from all modules.
3. Roles are collections of permission IDs, stored per-store in the `roles` table.
4. Store team members are linked to roles via `StoreUser.role_id`.
### Owner Bypass
Store owners (`Merchant.owner_user_id`) automatically receive **all permissions** without needing a role record. Ownership is checked via `User.is_owner_of(store_id)`.
### Role Presets
The system provides 5 preset roles with predefined permission sets:
| Preset | Description | Permission Count |
|--------|-------------|-----------------|
| `manager` | Full operational access | ~23 |
| `staff` | Day-to-day operations | ~10 |
| `support` | Customer service focus | ~6 |
| `viewer` | Read-only access | ~6 |
| `marketing` | Marketing and customer data | ~7 |
Preset roles are created automatically on first access. Store owners can also create custom roles with any combination of the ~75 available permissions.
### Enforcement Points
| Where | How | Pattern |
|-------|-----|---------|
| **API routes** | `require_store_permission("products.view")` | FastAPI `Depends()` — returns 403 JSON |
| **Page routes** | `require_store_page_permission("products.view")` | FastAPI `Depends()` — redirects to no-access page |
| **Sidebar menu** | `MenuItemDefinition.requires_permission` | Items hidden if user lacks permission |
| **Template UI** | `window.USER_PERMISSIONS` | Alpine.js `x-show` for button/element hiding |
### Key Models
| Model | Table | Purpose |
|-------|-------|---------|
| `Role` | `roles` | Permission set per store (preset or custom) |
| `StoreUser` | `store_users` | Links user to store with role assignment |
### Per-Store Flexibility
A user can have **different roles in different stores**. For example, a user might be a `manager` in Store A but a `viewer` in Store B, because `StoreUser` records are per-store.
## How the Layers Interact
### Example: Store team member tries to view products
```
1. SUBSCRIPTION: Does the merchant's tier include "products" feature?
→ Yes (tier allows up to 200 products) → Continue
→ No → "Upgrade your plan to access products"
2. MODULE: Is the catalog module enabled on this platform?
→ Yes (OMS platform has catalog enabled) → Continue
→ No → Products section doesn't exist at all
3. MENU VISIBILITY: Is the products menu item visible?
→ Yes (not hidden in AdminMenuConfig) → Show in sidebar
→ No → Item hidden from sidebar (but URL still works)
4. PERMISSIONS: Does the user's role include "products.view"?
→ Yes (user has "staff" role with products.view) → Allow access
→ No → 403 Forbidden / redirect to no-access page
```
### Key Distinction
- **Layers 1-3** are about **platform/store configuration** — they define the environment
- **Layer 4** is about **individual user authorization** — it defines what each person can do within that environment
## Related Documentation
- [Authentication & RBAC](auth-rbac.md) — JWT auth, user roles, enforcement methods
- [Menu Management](menu-management.md) — Menu discovery, visibility config, AdminMenuConfig
- [Module System](module-system.md) — Module architecture, auto-discovery, classification
- [Feature Gating](../implementation/feature-gating-system.md) — Tier-based feature limits

View File

@@ -44,6 +44,7 @@ nav:
- Observability: architecture/observability.md - Observability: architecture/observability.md
- Request Flow: architecture/request-flow.md - Request Flow: architecture/request-flow.md
- Authentication & RBAC: architecture/auth-rbac.md - Authentication & RBAC: architecture/auth-rbac.md
- Access Control Stack: architecture/access-control-stack.md
- UserContext Pattern: architecture/user-context-pattern.md - UserContext Pattern: architecture/user-context-pattern.md
- Frontend Structure: architecture/frontend-structure.md - Frontend Structure: architecture/frontend-structure.md
- Models Structure: architecture/models-structure.md - Models Structure: architecture/models-structure.md
@@ -54,6 +55,7 @@ nav:
- Migration Status: architecture/api-migration-status.md - Migration Status: architecture/api-migration-status.md
- Audit Provider Pattern: architecture/audit-provider-pattern.md - Audit Provider Pattern: architecture/audit-provider-pattern.md
- Cross-Module Import Rules: architecture/cross-module-import-rules.md - Cross-Module Import Rules: architecture/cross-module-import-rules.md
- Cross-Module Migration Plan: architecture/cross-module-migration-plan.md
- Customer Orders Architecture: architecture/customer-orders-architecture.md - Customer Orders Architecture: architecture/customer-orders-architecture.md
- Frontend Detection: architecture/frontend-detection.md - Frontend Detection: architecture/frontend-detection.md
- Media Architecture: architecture/media-architecture.md - Media Architecture: architecture/media-architecture.md