refactor: implement module-driven permissions and relocate business logic
File Relocations: - Delete app/config/ folder (empty after menu_registry removal) - Move feature_gate.py → app/modules/billing/dependencies/ - Move theme_presets.py → app/modules/cms/services/ Module-Driven Permissions System: - Add PermissionDefinition dataclass to app/modules/base.py - Create PermissionDiscoveryService in tenancy module - Update module definitions to declare their own permissions: - core: dashboard.view, settings.* - catalog: products.* - orders: orders.* - inventory: stock.* - customers: customers.* - tenancy: team.* - Update app/core/permissions.py to use discovery service - Role presets (owner, manager, staff, etc.) now use module permissions This follows the same pattern as module-driven menus: - Each module defines its permissions in definition.py - PermissionDiscoveryService aggregates all permissions at runtime - Tenancy module handles role-to-permission assignment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,12 @@ Platform, company, vendor, and admin user management.
|
||||
Required for multi-tenant operation - cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.base import (
|
||||
MenuItemDefinition,
|
||||
MenuSectionDefinition,
|
||||
ModuleDefinition,
|
||||
PermissionDefinition,
|
||||
)
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
tenancy_module = ModuleDefinition(
|
||||
@@ -16,6 +21,36 @@ tenancy_module = ModuleDefinition(
|
||||
version="1.0.0",
|
||||
is_core=True,
|
||||
is_self_contained=True,
|
||||
# Module-driven permissions
|
||||
permissions=[
|
||||
PermissionDefinition(
|
||||
id="team.view",
|
||||
label_key="tenancy.permissions.team_view",
|
||||
description_key="tenancy.permissions.team_view_desc",
|
||||
category="team",
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="team.invite",
|
||||
label_key="tenancy.permissions.team_invite",
|
||||
description_key="tenancy.permissions.team_invite_desc",
|
||||
category="team",
|
||||
is_owner_only=True,
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="team.edit",
|
||||
label_key="tenancy.permissions.team_edit",
|
||||
description_key="tenancy.permissions.team_edit_desc",
|
||||
category="team",
|
||||
is_owner_only=True,
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="team.remove",
|
||||
label_key="tenancy.permissions.team_remove",
|
||||
description_key="tenancy.permissions.team_remove_desc",
|
||||
category="team",
|
||||
is_owner_only=True,
|
||||
),
|
||||
],
|
||||
features=[
|
||||
"platform_management",
|
||||
"company_management",
|
||||
|
||||
407
app/modules/tenancy/services/permission_discovery_service.py
Normal file
407
app/modules/tenancy/services/permission_discovery_service.py
Normal file
@@ -0,0 +1,407 @@
|
||||
# app/modules/tenancy/services/permission_discovery_service.py
|
||||
"""
|
||||
Permission Discovery Service - Discovers and aggregates permissions from all modules.
|
||||
|
||||
This service implements the module-driven permission system where each module
|
||||
defines its own permissions through PermissionDefinition in its definition.py file.
|
||||
|
||||
Key Features:
|
||||
- Discovers permission definitions from all loaded modules
|
||||
- Groups permissions by category for UI organization
|
||||
- Provides role preset mappings (owner, manager, staff, etc.)
|
||||
- Supports permission checking and validation
|
||||
|
||||
Usage:
|
||||
from app.modules.tenancy.services.permission_discovery_service import (
|
||||
permission_discovery_service
|
||||
)
|
||||
|
||||
# Get all permissions
|
||||
all_perms = permission_discovery_service.get_all_permissions()
|
||||
|
||||
# Get permissions grouped by category
|
||||
grouped = permission_discovery_service.get_permissions_by_category()
|
||||
|
||||
# Get permission IDs for a role preset
|
||||
staff_perms = permission_discovery_service.get_preset_permissions("staff")
|
||||
|
||||
# Check if a permission ID is valid
|
||||
is_valid = permission_discovery_service.is_valid_permission("products.view")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.modules.base import PermissionDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredPermission:
|
||||
"""
|
||||
A permission discovered from a module, enriched with module info.
|
||||
|
||||
Extends PermissionDefinition with module context for tracking
|
||||
which module provides each permission.
|
||||
"""
|
||||
|
||||
id: str
|
||||
label_key: str
|
||||
description_key: str
|
||||
category: str
|
||||
is_owner_only: bool
|
||||
module_code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionCategory:
|
||||
"""
|
||||
A category grouping related permissions for UI organization.
|
||||
"""
|
||||
|
||||
id: str
|
||||
label_key: str
|
||||
permissions: list[DiscoveredPermission] = field(default_factory=list)
|
||||
|
||||
|
||||
class PermissionDiscoveryService:
|
||||
"""
|
||||
Service to discover and aggregate permissions from all modules.
|
||||
|
||||
This service:
|
||||
1. Collects permission definitions from all module definition.py files
|
||||
2. Groups permissions by category for UI display
|
||||
3. Provides role preset definitions (owner, manager, staff, etc.)
|
||||
4. Validates permission IDs
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Role Presets
|
||||
# =========================================================================
|
||||
# Role presets define default permission sets. These reference permission IDs
|
||||
# that should be defined by modules. The service validates these at runtime.
|
||||
|
||||
ROLE_PRESETS = {
|
||||
"owner": None, # Special: owners get ALL permissions
|
||||
"manager": {
|
||||
# Dashboard
|
||||
"dashboard.view",
|
||||
# Products
|
||||
"products.view",
|
||||
"products.create",
|
||||
"products.edit",
|
||||
"products.delete",
|
||||
"products.import",
|
||||
"products.export",
|
||||
# Stock/Inventory
|
||||
"stock.view",
|
||||
"stock.edit",
|
||||
"stock.transfer",
|
||||
# Orders
|
||||
"orders.view",
|
||||
"orders.edit",
|
||||
"orders.cancel",
|
||||
"orders.refund",
|
||||
# Customers
|
||||
"customers.view",
|
||||
"customers.edit",
|
||||
"customers.export",
|
||||
# Marketing
|
||||
"marketing.view",
|
||||
"marketing.create",
|
||||
"marketing.send",
|
||||
# Reports
|
||||
"reports.view",
|
||||
"reports.financial",
|
||||
"reports.export",
|
||||
# Settings (limited)
|
||||
"settings.view",
|
||||
"settings.theme",
|
||||
# Imports
|
||||
"imports.view",
|
||||
"imports.create",
|
||||
"imports.cancel",
|
||||
},
|
||||
"staff": {
|
||||
# Dashboard
|
||||
"dashboard.view",
|
||||
# Products
|
||||
"products.view",
|
||||
"products.create",
|
||||
"products.edit",
|
||||
# Stock
|
||||
"stock.view",
|
||||
"stock.edit",
|
||||
# Orders
|
||||
"orders.view",
|
||||
"orders.edit",
|
||||
# Customers
|
||||
"customers.view",
|
||||
"customers.edit",
|
||||
},
|
||||
"support": {
|
||||
# Dashboard
|
||||
"dashboard.view",
|
||||
# Products (view only)
|
||||
"products.view",
|
||||
# Orders
|
||||
"orders.view",
|
||||
"orders.edit",
|
||||
# Customers
|
||||
"customers.view",
|
||||
"customers.edit",
|
||||
},
|
||||
"viewer": {
|
||||
# Read-only access
|
||||
"dashboard.view",
|
||||
"products.view",
|
||||
"stock.view",
|
||||
"orders.view",
|
||||
"customers.view",
|
||||
"reports.view",
|
||||
},
|
||||
"marketing": {
|
||||
# Marketing-focused role
|
||||
"dashboard.view",
|
||||
"customers.view",
|
||||
"customers.export",
|
||||
"marketing.view",
|
||||
"marketing.create",
|
||||
"marketing.send",
|
||||
"reports.view",
|
||||
},
|
||||
}
|
||||
|
||||
# Category labels for UI grouping
|
||||
CATEGORY_LABELS = {
|
||||
"dashboard": "tenancy.permissions.category.dashboard",
|
||||
"products": "tenancy.permissions.category.products",
|
||||
"stock": "tenancy.permissions.category.stock",
|
||||
"orders": "tenancy.permissions.category.orders",
|
||||
"customers": "tenancy.permissions.category.customers",
|
||||
"marketing": "tenancy.permissions.category.marketing",
|
||||
"reports": "tenancy.permissions.category.reports",
|
||||
"settings": "tenancy.permissions.category.settings",
|
||||
"team": "tenancy.permissions.category.team",
|
||||
"imports": "tenancy.permissions.category.imports",
|
||||
"general": "tenancy.permissions.category.general",
|
||||
}
|
||||
|
||||
def get_all_permissions(self) -> list[DiscoveredPermission]:
|
||||
"""
|
||||
Discover all permissions from all loaded modules.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredPermission objects from all modules
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
permissions = []
|
||||
|
||||
for module_code, module_def in MODULES.items():
|
||||
for perm in module_def.permissions:
|
||||
discovered = DiscoveredPermission(
|
||||
id=perm.id,
|
||||
label_key=perm.label_key,
|
||||
description_key=perm.description_key,
|
||||
category=perm.category,
|
||||
is_owner_only=perm.is_owner_only,
|
||||
module_code=module_code,
|
||||
)
|
||||
permissions.append(discovered)
|
||||
|
||||
return sorted(permissions, key=lambda p: (p.category, p.id))
|
||||
|
||||
def get_all_permission_ids(self) -> set[str]:
|
||||
"""
|
||||
Get set of all permission IDs from all modules.
|
||||
|
||||
Returns:
|
||||
Set of permission ID strings
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
ids = set()
|
||||
for module_def in MODULES.values():
|
||||
ids.update(module_def.get_permission_ids())
|
||||
|
||||
return ids
|
||||
|
||||
def get_permission(self, permission_id: str) -> DiscoveredPermission | None:
|
||||
"""
|
||||
Get a specific permission by ID.
|
||||
|
||||
Args:
|
||||
permission_id: Permission ID to look up
|
||||
|
||||
Returns:
|
||||
DiscoveredPermission if found, None otherwise
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
for module_code, module_def in MODULES.items():
|
||||
for perm in module_def.permissions:
|
||||
if perm.id == permission_id:
|
||||
return DiscoveredPermission(
|
||||
id=perm.id,
|
||||
label_key=perm.label_key,
|
||||
description_key=perm.description_key,
|
||||
category=perm.category,
|
||||
is_owner_only=perm.is_owner_only,
|
||||
module_code=module_code,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def get_permissions_by_category(self) -> list[PermissionCategory]:
|
||||
"""
|
||||
Get all permissions grouped by category.
|
||||
|
||||
Returns:
|
||||
List of PermissionCategory objects with permissions
|
||||
"""
|
||||
all_perms = self.get_all_permissions()
|
||||
|
||||
# Group by category
|
||||
categories_map: dict[str, list[DiscoveredPermission]] = {}
|
||||
for perm in all_perms:
|
||||
if perm.category not in categories_map:
|
||||
categories_map[perm.category] = []
|
||||
categories_map[perm.category].append(perm)
|
||||
|
||||
# Build category objects
|
||||
categories = []
|
||||
for cat_id, perms in sorted(categories_map.items()):
|
||||
label_key = self.CATEGORY_LABELS.get(
|
||||
cat_id, f"tenancy.permissions.category.{cat_id}"
|
||||
)
|
||||
categories.append(
|
||||
PermissionCategory(
|
||||
id=cat_id,
|
||||
label_key=label_key,
|
||||
permissions=perms,
|
||||
)
|
||||
)
|
||||
|
||||
return categories
|
||||
|
||||
def get_permissions_for_module(
|
||||
self, module_code: str
|
||||
) -> list[DiscoveredPermission]:
|
||||
"""
|
||||
Get all permissions defined by a specific module.
|
||||
|
||||
Args:
|
||||
module_code: Module code to get permissions for
|
||||
|
||||
Returns:
|
||||
List of DiscoveredPermission from the specified module
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
module_def = MODULES.get(module_code)
|
||||
if not module_def:
|
||||
return []
|
||||
|
||||
return [
|
||||
DiscoveredPermission(
|
||||
id=perm.id,
|
||||
label_key=perm.label_key,
|
||||
description_key=perm.description_key,
|
||||
category=perm.category,
|
||||
is_owner_only=perm.is_owner_only,
|
||||
module_code=module_code,
|
||||
)
|
||||
for perm in module_def.permissions
|
||||
]
|
||||
|
||||
def is_valid_permission(self, permission_id: str) -> bool:
|
||||
"""
|
||||
Check if a permission ID is valid (defined by some module).
|
||||
|
||||
Args:
|
||||
permission_id: Permission ID to validate
|
||||
|
||||
Returns:
|
||||
True if permission exists, False otherwise
|
||||
"""
|
||||
return permission_id in self.get_all_permission_ids()
|
||||
|
||||
def get_preset_permissions(self, preset_name: str) -> set[str]:
|
||||
"""
|
||||
Get permission IDs for a role preset.
|
||||
|
||||
Args:
|
||||
preset_name: Name of preset (owner, manager, staff, support, viewer, marketing)
|
||||
|
||||
Returns:
|
||||
Set of permission IDs for the preset, or empty set if not found.
|
||||
For "owner", returns all permission IDs.
|
||||
"""
|
||||
preset = self.ROLE_PRESETS.get(preset_name.lower())
|
||||
|
||||
if preset_name.lower() == "owner":
|
||||
# Owners get all permissions
|
||||
return self.get_all_permission_ids()
|
||||
|
||||
if preset is None:
|
||||
logger.warning(f"Unknown role preset: {preset_name}")
|
||||
return set()
|
||||
|
||||
# Filter to only permissions that actually exist
|
||||
all_perms = self.get_all_permission_ids()
|
||||
return preset & all_perms
|
||||
|
||||
def get_available_presets(self) -> list[str]:
|
||||
"""
|
||||
Get list of available role preset names.
|
||||
|
||||
Returns:
|
||||
List of preset names
|
||||
"""
|
||||
return list(self.ROLE_PRESETS.keys())
|
||||
|
||||
def validate_permissions(self, permission_ids: list[str]) -> list[str]:
|
||||
"""
|
||||
Validate a list of permission IDs.
|
||||
|
||||
Args:
|
||||
permission_ids: List of permission IDs to validate
|
||||
|
||||
Returns:
|
||||
List of invalid permission IDs (empty if all valid)
|
||||
"""
|
||||
all_perms = self.get_all_permission_ids()
|
||||
return [pid for pid in permission_ids if pid not in all_perms]
|
||||
|
||||
def get_owner_only_permissions(self) -> set[str]:
|
||||
"""
|
||||
Get permission IDs that are owner-only.
|
||||
|
||||
These permissions cannot be assigned to team members.
|
||||
|
||||
Returns:
|
||||
Set of owner-only permission IDs
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
owner_only = set()
|
||||
for module_def in MODULES.values():
|
||||
for perm in module_def.permissions:
|
||||
if perm.is_owner_only:
|
||||
owner_only.add(perm.id)
|
||||
|
||||
return owner_only
|
||||
|
||||
|
||||
# Singleton instance
|
||||
permission_discovery_service = PermissionDiscoveryService()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"permission_discovery_service",
|
||||
"PermissionDiscoveryService",
|
||||
"DiscoveredPermission",
|
||||
"PermissionCategory",
|
||||
]
|
||||
Reference in New Issue
Block a user