diff --git a/app/api/deps.py b/app/api/deps.py index a9760d14..f913abe9 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1557,6 +1557,55 @@ def get_user_permissions( 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) # ============================================================================ diff --git a/app/modules/analytics/routes/pages/store.py b/app/modules/analytics/routes/pages/store.py index ba96233d..2b37e4e0 100644 --- a/app/modules/analytics/routes/pages/store.py +++ b/app/modules/analytics/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.services.platform_settings_service import ( platform_settings_service, # MOD-004 - shared platform service @@ -82,7 +82,7 @@ def get_store_context( async def store_analytics_page( request: Request, 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), ): """ diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 5cb26951..a7afbce7 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -241,6 +241,7 @@ billing_module = ModuleDefinition( icon="currency-euro", route="/store/{store_code}/invoices", order=30, + requires_permission="billing.view_invoices", ), ], ), @@ -256,6 +257,7 @@ billing_module = ModuleDefinition( icon="credit-card", route="/store/{store_code}/billing", order=30, + requires_permission="billing.view_subscriptions", ), ], ), diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 670c2cd7..e41a7c86 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -121,6 +121,7 @@ catalog_module = ModuleDefinition( route="/store/{store_code}/products", order=10, is_mandatory=True, + requires_permission="products.view", ), ], ), diff --git a/app/modules/catalog/routes/pages/store.py b/app/modules/catalog/routes/pages/store.py index 8b911a56..9e571dbb 100644 --- a/app/modules/catalog/routes/pages/store.py +++ b/app/modules/catalog/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -34,7 +34,7 @@ router = APIRouter() async def store_products_page( request: Request, 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), ): """ @@ -55,7 +55,7 @@ async def store_products_page( async def store_product_create_page( request: Request, 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), ): """ diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 8328b813..16d21280 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -240,6 +240,7 @@ cms_module = ModuleDefinition( icon="document-text", route="/store/{store_code}/content-pages", order=10, + requires_permission="cms.view_pages", ), MenuItemDefinition( id="media", @@ -247,6 +248,7 @@ cms_module = ModuleDefinition( icon="photograph", route="/store/{store_code}/media", order=20, + requires_permission="cms.view_media", ), ], ), diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index 12944a6b..eebddf3d 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -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", ), ], ), diff --git a/app/modules/core/routes/api/store_menu.py b/app/modules/core/routes/api/store_menu.py index 22bf5053..033b8edd 100644 --- a/app/modules/core/routes/api/store_menu.py +++ b/app/modules/core/routes/api/store_menu.py @@ -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 diff --git a/app/modules/core/services/menu_discovery_service.py b/app/modules/core/services/menu_discovery_service.py index 85b353a1..afd02a4c 100644 --- a/app/modules/core/services/menu_discovery_service.py +++ b/app/modules/core/services/menu_discovery_service.py @@ -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) diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index a13c91a4..d31b90eb 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -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, ) # ========================================================================= diff --git a/app/modules/core/tests/unit/test_menu_discovery_service.py b/app/modules/core/tests/unit/test_menu_discovery_service.py index ac206e58..33f3cf34 100644 --- a/app/modules/core/tests/unit/test_menu_discovery_service.py +++ b/app/modules/core/tests/unit/test_menu_discovery_service.py @@ -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 diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py index 66f8e92b..60fdb0a6 100644 --- a/app/modules/core/utils/page_context.py +++ b/app/modules/core/utils/page_context.py @@ -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, diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index 07330311..032ce8cf 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -128,6 +128,7 @@ customers_module = ModuleDefinition( icon="user-group", route="/store/{store_code}/customers", order=10, + requires_permission="customers.view", ), ], ), diff --git a/app/modules/customers/routes/pages/store.py b/app/modules/customers/routes/pages/store.py index e5776738..e1ab6e15 100644 --- a/app/modules/customers/routes/pages/store.py +++ b/app/modules/customers/routes/pages/store.py @@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -33,7 +33,7 @@ router = APIRouter() async def store_customers_page( request: Request, 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), ): """ diff --git a/app/modules/inventory/definition.py b/app/modules/inventory/definition.py index 994627cf..7279d997 100644 --- a/app/modules/inventory/definition.py +++ b/app/modules/inventory/definition.py @@ -136,6 +136,7 @@ inventory_module = ModuleDefinition( icon="clipboard-list", route="/store/{store_code}/inventory", order=20, + requires_permission="stock.view", ), ], ), diff --git a/app/modules/inventory/routes/pages/store.py b/app/modules/inventory/routes/pages/store.py index 1efbb791..4d38312f 100644 --- a/app/modules/inventory/routes/pages/store.py +++ b/app/modules/inventory/routes/pages/store.py @@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -33,7 +33,7 @@ router = APIRouter() async def store_inventory_page( request: Request, 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), ): """ diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index d1ef845f..54eaf86d 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -168,6 +168,7 @@ loyalty_module = ModuleDefinition( icon="gift", route="/store/{store_code}/loyalty/terminal", order=10, + requires_permission="loyalty.view_programs", ), MenuItemDefinition( id="cards", @@ -175,6 +176,7 @@ loyalty_module = ModuleDefinition( icon="identification", route="/store/{store_code}/loyalty/cards", order=20, + requires_permission="loyalty.view_programs", ), MenuItemDefinition( id="stats", @@ -182,6 +184,7 @@ loyalty_module = ModuleDefinition( icon="chart-bar", route="/store/{store_code}/loyalty/stats", order=30, + requires_permission="loyalty.view_programs", ), ], ), diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py index a3716f48..76cd15d9 100644 --- a/app/modules/messaging/definition.py +++ b/app/modules/messaging/definition.py @@ -147,6 +147,7 @@ messaging_module = ModuleDefinition( icon="chat-bubble-left-right", route="/store/{store_code}/messages", order=20, + requires_permission="messaging.view_messages", ), MenuItemDefinition( id="notifications", @@ -154,6 +155,7 @@ messaging_module = ModuleDefinition( icon="bell", route="/store/{store_code}/notifications", order=30, + requires_permission="messaging.view_messages", ), ], ), @@ -169,6 +171,7 @@ messaging_module = ModuleDefinition( icon="mail", route="/store/{store_code}/email-templates", order=40, + requires_permission="messaging.manage_templates", ), ], ), diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 4d9f8afa..aa570167 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -133,6 +133,7 @@ orders_module = ModuleDefinition( route="/store/{store_code}/orders", order=10, is_mandatory=True, + requires_permission="orders.view", ), ], ), diff --git a/app/modules/orders/routes/pages/store.py b/app/modules/orders/routes/pages/store.py index 76862cc4..d29865eb 100644 --- a/app/modules/orders/routes/pages/store.py +++ b/app/modules/orders/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -34,7 +34,7 @@ router = APIRouter() async def store_orders_page( request: Request, 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), ): """ @@ -56,7 +56,7 @@ async def store_order_detail_page( request: Request, store_code: str = Depends(get_resolved_store_code), 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), ): """ diff --git a/app/modules/tenancy/routes/api/store_team.py b/app/modules/tenancy/routes/api/store_team.py index 60138545..336c35de 100644 --- a/app/modules/tenancy/routes/api/store_team.py +++ b/app/modules/tenancy/routes/api/store_team.py @@ -28,7 +28,10 @@ from app.modules.tenancy.schemas.team import ( InvitationAccept, InvitationAcceptResponse, InvitationResponse, + RoleCreate, RoleListResponse, + RoleResponse, + RoleUpdate, TeamMemberInvite, TeamMemberListResponse, TeamMemberResponse, @@ -392,6 +395,86 @@ def list_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 # ============================================================================ diff --git a/app/modules/tenancy/routes/pages/store.py b/app/modules/tenancy/routes/pages/store.py index bbc04eaf..991df930 100644 --- a/app/modules/tenancy/routes/pages/store.py +++ b/app/modules/tenancy/routes/pages/store.py @@ -19,6 +19,7 @@ from app.api.deps import ( get_current_store_optional, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -90,7 +91,7 @@ async def store_login_page( async def store_team_page( request: Request, 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), ): """ @@ -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( "/profile", response_class=HTMLResponse, include_in_schema=False ) diff --git a/app/modules/tenancy/services/store_team_service.py b/app/modules/tenancy/services/store_team_service.py index 3e4ff120..26c1978a 100644 --- a/app/modules/tenancy/services/store_team_service.py +++ b/app/modules/tenancy/services/store_team_service.py @@ -26,9 +26,11 @@ def get_preset_permissions(preset_name: str) -> set[str]: """Get permissions for a preset role.""" return permission_discovery_service.get_preset_permissions(preset_name) from app.modules.billing.exceptions import TierLimitExceededException +from app.modules.core.services.audit_aggregator import audit_aggregator from app.modules.tenancy.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, + InvalidRoleException, TeamInvitationAlreadyAcceptedException, TeamMemberAlreadyExistsException, UserNotFoundException, @@ -174,6 +176,20 @@ class StoreTeamService: # TODO: Send invitation email # 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 { "invitation_token": invitation_token, "email": email, @@ -274,6 +290,7 @@ class StoreTeamService: db: Session, store: Store, user_id: int, + actor_user_id: int | None = None, ) -> bool: """ Remove a team member from a store. @@ -309,6 +326,21 @@ class StoreTeamService: store_user.is_active = False 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 except (UserNotFoundException, CannotRemoveOwnerException): @@ -324,6 +356,7 @@ class StoreTeamService: user_id: int, new_role_name: str, custom_permissions: list[str] | None = None, + actor_user_id: int | None = None, ) -> StoreUser: """ Update a team member's role. @@ -363,14 +396,31 @@ class StoreTeamService: custom_permissions=custom_permissions, ) + old_role_name = store_user.role.name if store_user.role else "none" store_user.role_id = new_role.id db.flush() + db.refresh(store_user) logger.info( f"Updated role for user {user_id} in store {store.store_code} " 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 except (UserNotFoundException, CannotRemoveOwnerException): @@ -470,6 +520,210 @@ class StoreTeamService: 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 def _generate_invitation_token(self) -> str: diff --git a/app/modules/tenancy/templates/tenancy/store/roles.html b/app/modules/tenancy/templates/tenancy/store/roles.html new file mode 100644 index 00000000..1a65dac6 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/store/roles.html @@ -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 %} + +{% call page_header_flex(title='Role Management', subtitle='Create and manage custom roles with granular permissions') %} +
+ {{ refresh_button(loading_var='loading', onclick='loadRoles()', variant='secondary') }} + +
+{% endcall %} + +{{ loading_state('Loading roles...') }} + +{{ error_state('Error loading roles') }} + + +
+ + + +
+ + +{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %} +
+ +
+ + +
+ + +
+ +
+ +
+
+ + +
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/tests/integration/test_store_team_roles_api.py b/app/modules/tenancy/tests/integration/test_store_team_roles_api.py new file mode 100644 index 00000000..7d09ebc6 --- /dev/null +++ b/app/modules/tenancy/tests/integration/test_store_team_roles_api.py @@ -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() diff --git a/app/modules/tenancy/tests/unit/test_store_team_service.py b/app/modules/tenancy/tests/unit/test_store_team_service.py index 0d0b6663..04a1f4ca 100644 --- a/app/modules/tenancy/tests/unit/test_store_team_service.py +++ b/app/modules/tenancy/tests/unit/test_store_team_service.py @@ -9,6 +9,7 @@ import pytest from app.modules.tenancy.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, + InvalidRoleException, UserNotFoundException, ) from app.modules.tenancy.models import Role, Store, StoreUser, User @@ -417,3 +418,313 @@ class TestStoreTeamServiceGetRoles: for role in roles: assert "permissions" in role 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, + ) diff --git a/app/templates/store/base.html b/app/templates/store/base.html index 73e26d98..76a17699 100644 --- a/app/templates/store/base.html +++ b/app/templates/store/base.html @@ -62,6 +62,7 @@ currency: '{{ storefront_currency }}', dashboardLanguage: '{{ dashboard_language }}' }; + window.USER_PERMISSIONS = {{ user_permissions | default([]) | tojson }}; diff --git a/docs/architecture/access-control-stack.md b/docs/architecture/access-control-stack.md new file mode 100644 index 00000000..82256952 --- /dev/null +++ b/docs/architecture/access-control-stack.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index dec7321a..ee3cb994 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Observability: architecture/observability.md - Request Flow: architecture/request-flow.md - Authentication & RBAC: architecture/auth-rbac.md + - Access Control Stack: architecture/access-control-stack.md - UserContext Pattern: architecture/user-context-pattern.md - Frontend Structure: architecture/frontend-structure.md - Models Structure: architecture/models-structure.md @@ -54,6 +55,7 @@ nav: - Migration Status: architecture/api-migration-status.md - Audit Provider Pattern: architecture/audit-provider-pattern.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 - Frontend Detection: architecture/frontend-detection.md - Media Architecture: architecture/media-architecture.md