feat: implement complete RBAC access control with tests
Add 4-layer access control stack (subscription → module → menu → permissions): - P1: Wire requires_permission into menu sidebar filtering - P2: Expose window.USER_PERMISSIONS for Alpine.js client-side gating - P3: Add page-level permission guards on store routes - P4: Role CRUD API endpoints and role editor UI - P5: Audit trail for all role/permission changes Includes unit tests (menu permission filtering, role CRUD service) and integration tests (role API endpoints). All 404 core+tenancy tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -121,6 +121,7 @@ catalog_module = ModuleDefinition(
|
||||
route="/store/{store_code}/products",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
requires_permission="products.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -158,6 +158,7 @@ core_module = ModuleDefinition(
|
||||
route="/store/{store_code}/dashboard",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
requires_permission="dashboard.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -181,6 +182,7 @@ core_module = ModuleDefinition(
|
||||
route="/store/{store_code}/settings",
|
||||
order=20,
|
||||
is_mandatory=True,
|
||||
requires_permission="settings.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_api
|
||||
from app.api.deps import get_current_store_api, get_user_permissions
|
||||
from app.core.database import get_db
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from app.modules.enums import FrontendType
|
||||
@@ -81,6 +81,7 @@ async def get_rendered_store_menu(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_store_api),
|
||||
user_perms: list = Depends(get_user_permissions),
|
||||
):
|
||||
"""
|
||||
Get the rendered store menu for the current user.
|
||||
@@ -89,6 +90,7 @@ async def get_rendered_store_menu(
|
||||
- Modules enabled on the store's platform
|
||||
- AdminMenuConfig visibility for the platform
|
||||
- Store code for URL placeholder replacement
|
||||
- User permissions (items with requires_permission are hidden if user lacks it)
|
||||
|
||||
Used by the store frontend to render the sidebar dynamically.
|
||||
"""
|
||||
@@ -100,12 +102,13 @@ async def get_rendered_store_menu(
|
||||
if platform_id is None:
|
||||
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
|
||||
|
||||
# Get filtered menu with platform visibility and store_code interpolation
|
||||
# Get filtered menu with platform visibility, store_code, and permission filtering
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.STORE,
|
||||
platform_id=platform_id,
|
||||
store_code=store.subdomain,
|
||||
user_permissions=user_perms,
|
||||
)
|
||||
|
||||
# Resolve language
|
||||
|
||||
@@ -212,6 +212,7 @@ class MenuDiscoveryService:
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
user_permissions: list[str] | None = None,
|
||||
) -> list[DiscoveredMenuSection]:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -220,7 +221,7 @@ class MenuDiscoveryService:
|
||||
1. Module enablement (disabled modules = hidden items)
|
||||
2. Visibility configuration (AdminMenuConfig preferences)
|
||||
3. Super admin status (hides super_admin_only items for non-super-admins)
|
||||
4. Permission requirements (future: filter by user permissions)
|
||||
4. Permission requirements (filter by user permissions)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -231,6 +232,9 @@ class MenuDiscoveryService:
|
||||
store_code: Store code for route placeholder replacement
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Passed through to get_menu_sections_for_frontend.
|
||||
user_permissions: List of permission IDs the user has. If provided,
|
||||
items with requires_permission set will be hidden unless the
|
||||
permission is in this list. None = no permission filtering.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection with filtered and sorted items
|
||||
@@ -268,6 +272,14 @@ class MenuDiscoveryService:
|
||||
if item.id not in visible_item_ids:
|
||||
continue
|
||||
|
||||
# Apply permission filtering
|
||||
if (
|
||||
user_permissions is not None
|
||||
and item.requires_permission
|
||||
and item.requires_permission not in user_permissions
|
||||
):
|
||||
continue
|
||||
|
||||
# Resolve route placeholders
|
||||
if store_code and "{store_code}" in item.route:
|
||||
item.route = item.route.replace("{store_code}", store_code)
|
||||
|
||||
@@ -229,6 +229,7 @@ class MenuService:
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
user_permissions: list[str] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -239,6 +240,7 @@ class MenuService:
|
||||
1. Module enablement (items from disabled modules are removed)
|
||||
2. Visibility configuration
|
||||
3. Super admin status
|
||||
4. User permissions (items with requires_permission hidden if user lacks it)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -250,6 +252,9 @@ class MenuService:
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Used by merchant portal where a merchant
|
||||
may have subscriptions across multiple platforms.
|
||||
user_permissions: List of permission IDs the user has. If provided,
|
||||
items with requires_permission will be hidden unless the user
|
||||
has the permission. None = no permission filtering.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection ready for rendering
|
||||
@@ -262,6 +267,7 @@ class MenuService:
|
||||
is_super_admin=is_super_admin,
|
||||
store_code=store_code,
|
||||
enabled_module_codes=enabled_module_codes,
|
||||
user_permissions=user_permissions,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -179,3 +179,82 @@ class TestMenuDiscoveryServiceEnabledModuleCodes:
|
||||
assert "billing" in section_ids
|
||||
assert "loyalty" in section_ids
|
||||
assert "account" in section_ids
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.core
|
||||
class TestMenuDiscoveryPermissionFiltering:
|
||||
"""Test user_permissions parameter on get_menu_for_frontend."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MenuDiscoveryService()
|
||||
|
||||
def test_no_permission_filter_shows_all_items(self, db):
|
||||
"""When user_permissions is None, no permission filtering occurs."""
|
||||
enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"}
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.STORE, enabled_module_codes=enabled,
|
||||
user_permissions=None,
|
||||
)
|
||||
# Should have items regardless of requires_permission
|
||||
all_items = [item for s in sections for item in s.items]
|
||||
assert len(all_items) > 0
|
||||
|
||||
def test_permission_filter_hides_unpermitted_items(self, db):
|
||||
"""Items with requires_permission not in user_permissions are hidden."""
|
||||
enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"}
|
||||
# Only allow dashboard.view — should hide products, orders, etc.
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.STORE, enabled_module_codes=enabled,
|
||||
user_permissions=["dashboard.view"],
|
||||
)
|
||||
all_item_ids = {item.id for s in sections for item in s.items}
|
||||
# Dashboard should be visible (has requires_permission="dashboard.view")
|
||||
assert "dashboard" in all_item_ids
|
||||
# Products requires "products.view" — should be hidden
|
||||
assert "products" not in all_item_ids
|
||||
|
||||
def test_permission_filter_shows_permitted_items(self, db):
|
||||
"""Items with matching permission are shown."""
|
||||
enabled = {"core", "catalog", "orders", "tenancy"}
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.STORE, enabled_module_codes=enabled,
|
||||
user_permissions=["dashboard.view", "products.view", "orders.view", "team.view"],
|
||||
)
|
||||
all_item_ids = {item.id for s in sections for item in s.items}
|
||||
assert "dashboard" in all_item_ids
|
||||
assert "products" in all_item_ids
|
||||
assert "orders" in all_item_ids
|
||||
|
||||
def test_empty_permissions_list_hides_permission_required_items(self, db):
|
||||
"""Empty permissions list hides all items that require a permission."""
|
||||
enabled = {"core", "catalog"}
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.STORE, enabled_module_codes=enabled,
|
||||
user_permissions=[],
|
||||
)
|
||||
all_item_ids = {item.id for s in sections for item in s.items}
|
||||
# Items without requires_permission should still show
|
||||
# Items with requires_permission should be hidden
|
||||
assert "products" not in all_item_ids
|
||||
assert "dashboard" not in all_item_ids
|
||||
|
||||
def test_items_without_requires_permission_always_visible(self, db):
|
||||
"""Items that have no requires_permission are shown regardless of user_permissions."""
|
||||
enabled = {"core", "catalog", "tenancy"}
|
||||
# Get all store items to check which have no requires_permission
|
||||
all_items_raw = self.service.get_all_menu_items(FrontendType.STORE)
|
||||
items_without_perm = [
|
||||
item for item in all_items_raw if item.requires_permission is None
|
||||
]
|
||||
|
||||
if items_without_perm:
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.STORE, enabled_module_codes=enabled,
|
||||
user_permissions=["some.random.permission"],
|
||||
)
|
||||
all_item_ids = {item.id for s in sections for item in s.items}
|
||||
# Items without requires_permission should still be visible
|
||||
for item in items_without_perm:
|
||||
if item.module_code in enabled:
|
||||
assert item.id in all_item_ids
|
||||
|
||||
@@ -290,18 +290,61 @@ def get_store_context(
|
||||
**extra_context: Additional variables for template
|
||||
|
||||
Returns:
|
||||
Context dict for store pages
|
||||
Context dict for store pages (includes user_permissions list)
|
||||
"""
|
||||
# Resolve user permissions for the current store
|
||||
user_permissions = _resolve_store_user_permissions(db, current_user)
|
||||
|
||||
return get_context_for_frontend(
|
||||
FrontendType.STORE,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
store_code=store_code,
|
||||
user_permissions=user_permissions,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_store_user_permissions(db: Session, current_user: Any) -> list[str]:
|
||||
"""
|
||||
Resolve the permission list for a store user.
|
||||
|
||||
Returns all permission IDs for the current user in their current store.
|
||||
Owners get all permissions. Members get permissions from their role.
|
||||
"""
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
store_id = getattr(current_user, "token_store_id", None)
|
||||
if not store_id:
|
||||
return []
|
||||
|
||||
user_id = getattr(current_user, "user_id", None) or getattr(
|
||||
current_user, "id", None
|
||||
)
|
||||
if not user_id:
|
||||
return []
|
||||
|
||||
user_model = db.query(User).filter(User.id == user_id).first()
|
||||
if not user_model:
|
||||
return []
|
||||
|
||||
# Owners get all permissions
|
||||
if user_model.is_owner_of(store_id):
|
||||
from app.modules.tenancy.services.permission_discovery_service import (
|
||||
permission_discovery_service,
|
||||
)
|
||||
|
||||
return list(permission_discovery_service.get_all_permission_ids())
|
||||
|
||||
# Members get permissions from their store role
|
||||
for membership in user_model.store_memberships:
|
||||
if membership.store_id == store_id and membership.is_active:
|
||||
return membership.get_all_permissions()
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_storefront_context(
|
||||
request: Request,
|
||||
db: Session | None = None,
|
||||
|
||||
@@ -128,6 +128,7 @@ customers_module = ModuleDefinition(
|
||||
icon="user-group",
|
||||
route="/store/{store_code}/customers",
|
||||
order=10,
|
||||
requires_permission="customers.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -136,6 +136,7 @@ inventory_module = ModuleDefinition(
|
||||
icon="clipboard-list",
|
||||
route="/store/{store_code}/inventory",
|
||||
order=20,
|
||||
requires_permission="stock.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -133,6 +133,7 @@ orders_module = ModuleDefinition(
|
||||
route="/store/{store_code}/orders",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
requires_permission="orders.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
338
app/modules/tenancy/templates/tenancy/store/roles.html
Normal file
338
app/modules/tenancy/templates/tenancy/store/roles.html
Normal 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">↻</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 %}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
currency: '{{ storefront_currency }}',
|
||||
dashboardLanguage: '{{ dashboard_language }}'
|
||||
};
|
||||
window.USER_PERMISSIONS = {{ user_permissions | default([]) | tojson }};
|
||||
</script>
|
||||
|
||||
<!-- 2. SECOND: Icons (before Alpine.js) -->
|
||||
|
||||
260
docs/architecture/access-control-stack.md
Normal file
260
docs/architecture/access-control-stack.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user