From 03395a9dfafe5153aa122053c29da3f87bb5f728 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 1 Feb 2026 21:42:13 +0100 Subject: [PATCH] refactor: implement module-driven permissions and relocate business logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/config/__init__.py | 8 - app/core/permissions.py | 161 +++---- app/modules/analytics/routes/api/vendor.py | 2 +- app/modules/base.py | 52 ++- app/modules/billing/dependencies/__init__.py | 14 + .../billing/dependencies}/feature_gate.py | 0 app/modules/catalog/definition.py | 48 ++- .../cms/services}/theme_presets.py | 0 .../cms/services/vendor_theme_service.py | 2 +- app/modules/core/definition.py | 41 +- app/modules/customers/definition.py | 34 +- app/modules/inventory/definition.py | 28 +- app/modules/orders/definition.py | 34 +- .../orders/routes/api/vendor_invoices.py | 2 +- app/modules/tenancy/definition.py | 37 +- .../services/permission_discovery_service.py | 407 ++++++++++++++++++ 16 files changed, 749 insertions(+), 121 deletions(-) delete mode 100644 app/config/__init__.py create mode 100644 app/modules/billing/dependencies/__init__.py rename app/{core => modules/billing/dependencies}/feature_gate.py (100%) rename app/{core => modules/cms/services}/theme_presets.py (100%) create mode 100644 app/modules/tenancy/services/permission_discovery_service.py diff --git a/app/config/__init__.py b/app/config/__init__.py deleted file mode 100644 index 0e0265da..00000000 --- a/app/config/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# app/config/__init__.py -"""Configuration modules for the application.""" - -# Note: menu_registry.py has been deprecated in favor of module-driven menus. -# Menu items are now defined in each module's definition.py file and aggregated -# by MenuDiscoveryService (app/modules/core/services/menu_discovery_service.py). - -__all__ = [] diff --git a/app/core/permissions.py b/app/core/permissions.py index be02f0ef..251a8b9f 100644 --- a/app/core/permissions.py +++ b/app/core/permissions.py @@ -2,26 +2,38 @@ """ Permission constants and checking logic for RBAC. -This module defines: -- Vendor-specific permissions -- Permission groups (for easier role creation) -- Permission checking utilities +NOTE: This module now uses the module-driven permission system. +Permissions are defined in each module's definition.py file and +discovered by PermissionDiscoveryService. + +This file provides backward-compatible exports for existing code. +New code should use: + from app.modules.tenancy.services.permission_discovery_service import ( + permission_discovery_service + ) """ from enum import Enum +from app.modules.tenancy.services.permission_discovery_service import ( + permission_discovery_service, +) + class VendorPermissions(str, Enum): """ All available permissions within a vendor context. + NOTE: This enum is maintained for backward compatibility. + Permissions are now defined in module definition.py files. + Naming convention: RESOURCE_ACTION """ - # Dashboard + # Dashboard (from core module) DASHBOARD_VIEW = "dashboard.view" - # Products + # Products (from catalog module) PRODUCTS_VIEW = "products.view" PRODUCTS_CREATE = "products.create" PRODUCTS_EDIT = "products.edit" @@ -29,133 +41,91 @@ class VendorPermissions(str, Enum): PRODUCTS_IMPORT = "products.import" PRODUCTS_EXPORT = "products.export" - # Stock/Inventory + # Stock/Inventory (from inventory module) STOCK_VIEW = "stock.view" STOCK_EDIT = "stock.edit" STOCK_TRANSFER = "stock.transfer" - # Orders + # Orders (from orders module) ORDERS_VIEW = "orders.view" ORDERS_EDIT = "orders.edit" ORDERS_CANCEL = "orders.cancel" ORDERS_REFUND = "orders.refund" - # Customers + # Customers (from customers module) CUSTOMERS_VIEW = "customers.view" CUSTOMERS_EDIT = "customers.edit" CUSTOMERS_DELETE = "customers.delete" CUSTOMERS_EXPORT = "customers.export" - # Marketing + # Marketing (from messaging module - to be added) MARKETING_VIEW = "marketing.view" MARKETING_CREATE = "marketing.create" MARKETING_SEND = "marketing.send" - # Reports + # Reports (from analytics module - to be added) REPORTS_VIEW = "reports.view" REPORTS_FINANCIAL = "reports.financial" REPORTS_EXPORT = "reports.export" - # Settings + # Settings (from core module) SETTINGS_VIEW = "settings.view" SETTINGS_EDIT = "settings.edit" SETTINGS_THEME = "settings.theme" SETTINGS_DOMAINS = "settings.domains" - # Team Management + # Team Management (from tenancy module) TEAM_VIEW = "team.view" TEAM_INVITE = "team.invite" TEAM_EDIT = "team.edit" TEAM_REMOVE = "team.remove" - # Marketplace Imports + # Marketplace Imports (from marketplace module - to be added) IMPORTS_VIEW = "imports.view" IMPORTS_CREATE = "imports.create" IMPORTS_CANCEL = "imports.cancel" class PermissionGroups: - """Pre-defined permission groups for common roles.""" + """ + Pre-defined permission groups for common roles. - # Full access (for owners) - OWNER: set[str] = set(p.value for p in VendorPermissions) + NOTE: These now delegate to permission_discovery_service for consistency. + """ - # Manager - Can do most things except team management and critical settings - MANAGER: set[str] = { - VendorPermissions.DASHBOARD_VIEW.value, - VendorPermissions.PRODUCTS_VIEW.value, - VendorPermissions.PRODUCTS_CREATE.value, - VendorPermissions.PRODUCTS_EDIT.value, - VendorPermissions.PRODUCTS_DELETE.value, - VendorPermissions.PRODUCTS_IMPORT.value, - VendorPermissions.PRODUCTS_EXPORT.value, - VendorPermissions.STOCK_VIEW.value, - VendorPermissions.STOCK_EDIT.value, - VendorPermissions.STOCK_TRANSFER.value, - VendorPermissions.ORDERS_VIEW.value, - VendorPermissions.ORDERS_EDIT.value, - VendorPermissions.ORDERS_CANCEL.value, - VendorPermissions.ORDERS_REFUND.value, - VendorPermissions.CUSTOMERS_VIEW.value, - VendorPermissions.CUSTOMERS_EDIT.value, - VendorPermissions.CUSTOMERS_EXPORT.value, - VendorPermissions.MARKETING_VIEW.value, - VendorPermissions.MARKETING_CREATE.value, - VendorPermissions.MARKETING_SEND.value, - VendorPermissions.REPORTS_VIEW.value, - VendorPermissions.REPORTS_FINANCIAL.value, - VendorPermissions.REPORTS_EXPORT.value, - VendorPermissions.SETTINGS_VIEW.value, - VendorPermissions.SETTINGS_THEME.value, - VendorPermissions.IMPORTS_VIEW.value, - VendorPermissions.IMPORTS_CREATE.value, - VendorPermissions.IMPORTS_CANCEL.value, - } + @property + def OWNER(self) -> set[str]: + """Full access (for owners) - all permissions.""" + return permission_discovery_service.get_preset_permissions("owner") - # Staff - Can view and edit products/orders but limited access - STAFF: set[str] = { - VendorPermissions.DASHBOARD_VIEW.value, - VendorPermissions.PRODUCTS_VIEW.value, - VendorPermissions.PRODUCTS_CREATE.value, - VendorPermissions.PRODUCTS_EDIT.value, - VendorPermissions.STOCK_VIEW.value, - VendorPermissions.STOCK_EDIT.value, - VendorPermissions.ORDERS_VIEW.value, - VendorPermissions.ORDERS_EDIT.value, - VendorPermissions.CUSTOMERS_VIEW.value, - VendorPermissions.CUSTOMERS_EDIT.value, - } + @property + def MANAGER(self) -> set[str]: + """Manager - Can do most things except team management.""" + return permission_discovery_service.get_preset_permissions("manager") - # Support - Can view and assist with orders/customers - SUPPORT: set[str] = { - VendorPermissions.DASHBOARD_VIEW.value, - VendorPermissions.PRODUCTS_VIEW.value, - VendorPermissions.ORDERS_VIEW.value, - VendorPermissions.ORDERS_EDIT.value, - VendorPermissions.CUSTOMERS_VIEW.value, - VendorPermissions.CUSTOMERS_EDIT.value, - } + @property + def STAFF(self) -> set[str]: + """Staff - Can view and edit products/orders but limited access.""" + return permission_discovery_service.get_preset_permissions("staff") - # Viewer - Read-only access - VIEWER: set[str] = { - VendorPermissions.DASHBOARD_VIEW.value, - VendorPermissions.PRODUCTS_VIEW.value, - VendorPermissions.STOCK_VIEW.value, - VendorPermissions.ORDERS_VIEW.value, - VendorPermissions.CUSTOMERS_VIEW.value, - VendorPermissions.REPORTS_VIEW.value, - } + @property + def SUPPORT(self) -> set[str]: + """Support - Can view and assist with orders/customers.""" + return permission_discovery_service.get_preset_permissions("support") - # Marketing - Focused on marketing and customer communication - MARKETING: set[str] = { - VendorPermissions.DASHBOARD_VIEW.value, - VendorPermissions.CUSTOMERS_VIEW.value, - VendorPermissions.CUSTOMERS_EXPORT.value, - VendorPermissions.MARKETING_VIEW.value, - VendorPermissions.MARKETING_CREATE.value, - VendorPermissions.MARKETING_SEND.value, - VendorPermissions.REPORTS_VIEW.value, - } + @property + def VIEWER(self) -> set[str]: + """Viewer - Read-only access.""" + return permission_discovery_service.get_preset_permissions("viewer") + + @property + def MARKETING(self) -> set[str]: + """Marketing - Focused on marketing and customer communication.""" + return permission_discovery_service.get_preset_permissions("marketing") + + +# Singleton instance for backward compatibility +_permission_groups = PermissionGroups() class PermissionChecker: @@ -188,23 +158,14 @@ class PermissionChecker: return [perm for perm in required_permissions if perm not in permissions] -# Helper function to get permissions for a role preset def get_preset_permissions(preset_name: str) -> set[str]: """ Get permissions for a preset role. Args: - preset_name: Name of the preset (manager, staff, support, viewer, marketing) + preset_name: Name of the preset (owner, manager, staff, support, viewer, marketing) Returns: Set of permission strings """ - presets = { - "owner": PermissionGroups.OWNER, - "manager": PermissionGroups.MANAGER, - "staff": PermissionGroups.STAFF, - "support": PermissionGroups.SUPPORT, - "viewer": PermissionGroups.VIEWER, - "marketing": PermissionGroups.MARKETING, - } - return presets.get(preset_name.lower(), set()) + return permission_discovery_service.get_preset_permissions(preset_name) diff --git a/app/modules/analytics/routes/api/vendor.py b/app/modules/analytics/routes/api/vendor.py index d7268514..7bc13fa0 100644 --- a/app/modules/analytics/routes/api/vendor.py +++ b/app/modules/analytics/routes/api/vendor.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, get_db, require_module_access -from app.core.feature_gate import RequireFeature +from app.modules.billing.dependencies.feature_gate import RequireFeature from app.modules.analytics.services import stats_service from app.modules.analytics.schemas import ( VendorAnalyticsCatalog, diff --git a/app/modules/base.py b/app/modules/base.py index 73ce69ce..93cb2b1a 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -134,6 +134,48 @@ class MenuSectionDefinition: is_collapsible: bool = True +# ============================================================================= +# Permission Definitions +# ============================================================================= + + +@dataclass +class PermissionDefinition: + """ + Definition of a permission that a module exposes. + + Permissions are granular capabilities that can be assigned to roles/users. + Each module defines its own permissions, which are then discovered and + aggregated by the tenancy module for role assignment. + + Attributes: + id: Unique identifier in format "resource.action" (e.g., "products.view") + label_key: i18n key for the permission label + description_key: i18n key for permission description + category: Grouping category for UI organization (e.g., "products", "orders") + is_owner_only: If True, only vendor owners can have this permission + + Example: + PermissionDefinition( + id="products.view", + label_key="catalog.permissions.products_view", + description_key="catalog.permissions.products_view_desc", + category="products", + ) + """ + + id: str + label_key: str + description_key: str = "" + category: str = "general" + is_owner_only: bool = False + + +# ============================================================================= +# Scheduled Task Definitions +# ============================================================================= + + @dataclass class ScheduledTask: """ @@ -275,7 +317,7 @@ class ModuleDefinition: # ========================================================================= features: list[str] = field(default_factory=list) menu_items: dict[FrontendType, list[str]] = field(default_factory=dict) - permissions: list[str] = field(default_factory=list) + permissions: list[PermissionDefinition] = field(default_factory=list) # ========================================================================= # Menu Definitions (Module-Driven Menus) @@ -400,9 +442,13 @@ class ModuleDefinition: """Check if this module provides a specific feature.""" return feature_code in self.features - def has_permission(self, permission_code: str) -> bool: + def has_permission(self, permission_id: str) -> bool: """Check if this module defines a specific permission.""" - return permission_code in self.permissions + return any(p.id == permission_id for p in self.permissions) + + def get_permission_ids(self) -> set[str]: + """Get all permission IDs defined by this module.""" + return {p.id for p in self.permissions} # ========================================================================= # Dependency Methods diff --git a/app/modules/billing/dependencies/__init__.py b/app/modules/billing/dependencies/__init__.py new file mode 100644 index 00000000..1d5c1f15 --- /dev/null +++ b/app/modules/billing/dependencies/__init__.py @@ -0,0 +1,14 @@ +# app/modules/billing/dependencies/__init__.py +"""FastAPI dependencies for the billing module.""" + +from .feature_gate import ( + require_feature, + RequireFeature, + FeatureNotAvailableError, +) + +__all__ = [ + "require_feature", + "RequireFeature", + "FeatureNotAvailableError", +] diff --git a/app/core/feature_gate.py b/app/modules/billing/dependencies/feature_gate.py similarity index 100% rename from app/core/feature_gate.py rename to app/modules/billing/dependencies/feature_gate.py diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index da22e568..ba672021 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -1,7 +1,12 @@ # app/modules/catalog/definition.py """Catalog module definition.""" -from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) from app.modules.enums import FrontendType module = ModuleDefinition( @@ -11,7 +16,46 @@ module = ModuleDefinition( version="1.0.0", is_self_contained=True, requires=["inventory"], - # New module-driven menu definitions + # Module-driven permissions + permissions=[ + PermissionDefinition( + id="products.view", + label_key="catalog.permissions.products_view", + description_key="catalog.permissions.products_view_desc", + category="products", + ), + PermissionDefinition( + id="products.create", + label_key="catalog.permissions.products_create", + description_key="catalog.permissions.products_create_desc", + category="products", + ), + PermissionDefinition( + id="products.edit", + label_key="catalog.permissions.products_edit", + description_key="catalog.permissions.products_edit_desc", + category="products", + ), + PermissionDefinition( + id="products.delete", + label_key="catalog.permissions.products_delete", + description_key="catalog.permissions.products_delete_desc", + category="products", + ), + PermissionDefinition( + id="products.import", + label_key="catalog.permissions.products_import", + description_key="catalog.permissions.products_import_desc", + category="products", + ), + PermissionDefinition( + id="products.export", + label_key="catalog.permissions.products_export", + description_key="catalog.permissions.products_export_desc", + category="products", + ), + ], + # Module-driven menu definitions menus={ FrontendType.VENDOR: [ MenuSectionDefinition( diff --git a/app/core/theme_presets.py b/app/modules/cms/services/theme_presets.py similarity index 100% rename from app/core/theme_presets.py rename to app/modules/cms/services/theme_presets.py diff --git a/app/modules/cms/services/vendor_theme_service.py b/app/modules/cms/services/vendor_theme_service.py index 157c6963..98b375b7 100644 --- a/app/modules/cms/services/vendor_theme_service.py +++ b/app/modules/cms/services/vendor_theme_service.py @@ -11,7 +11,7 @@ import re from sqlalchemy.orm import Session -from app.core.theme_presets import ( +from app.modules.cms.services.theme_presets import ( THEME_PRESETS, apply_preset, get_available_presets, diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index 3b1694f6..84abd9cc 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -6,7 +6,12 @@ Dashboard, settings, and profile management. Required for basic 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 core_module = ModuleDefinition( @@ -16,6 +21,40 @@ core_module = ModuleDefinition( version="1.0.0", is_core=True, is_self_contained=True, + # Module-driven permissions + permissions=[ + PermissionDefinition( + id="dashboard.view", + label_key="core.permissions.dashboard_view", + description_key="core.permissions.dashboard_view_desc", + category="dashboard", + ), + PermissionDefinition( + id="settings.view", + label_key="core.permissions.settings_view", + description_key="core.permissions.settings_view_desc", + category="settings", + ), + PermissionDefinition( + id="settings.edit", + label_key="core.permissions.settings_edit", + description_key="core.permissions.settings_edit_desc", + category="settings", + ), + PermissionDefinition( + id="settings.theme", + label_key="core.permissions.settings_theme", + description_key="core.permissions.settings_theme_desc", + category="settings", + ), + PermissionDefinition( + id="settings.domains", + label_key="core.permissions.settings_domains", + description_key="core.permissions.settings_domains_desc", + category="settings", + is_owner_only=True, # Only owners can manage domains + ), + ], features=[ "dashboard", "settings", diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index 6cafaef3..0e721e94 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -6,7 +6,12 @@ Defines the customers module including its features, menu items, route configurations, and self-contained module settings. """ -from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) from app.modules.enums import FrontendType @@ -30,6 +35,33 @@ customers_module = ModuleDefinition( name="Customer Management", description="Customer database, profiles, addresses, and segmentation.", version="1.0.0", + # Module-driven permissions + permissions=[ + PermissionDefinition( + id="customers.view", + label_key="customers.permissions.customers_view", + description_key="customers.permissions.customers_view_desc", + category="customers", + ), + PermissionDefinition( + id="customers.edit", + label_key="customers.permissions.customers_edit", + description_key="customers.permissions.customers_edit_desc", + category="customers", + ), + PermissionDefinition( + id="customers.delete", + label_key="customers.permissions.customers_delete", + description_key="customers.permissions.customers_delete_desc", + category="customers", + ), + PermissionDefinition( + id="customers.export", + label_key="customers.permissions.customers_export", + description_key="customers.permissions.customers_export_desc", + category="customers", + ), + ], features=[ "customer_view", # View customer profiles "customer_export", # Export customer data diff --git a/app/modules/inventory/definition.py b/app/modules/inventory/definition.py index 4b64d1ff..26b45de7 100644 --- a/app/modules/inventory/definition.py +++ b/app/modules/inventory/definition.py @@ -6,7 +6,12 @@ Defines the inventory module including its features, menu items, route configurations, and self-contained module settings. """ -from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) from app.modules.enums import FrontendType @@ -33,6 +38,27 @@ inventory_module = ModuleDefinition( "transaction history, and bulk imports." ), version="1.0.0", + # Module-driven permissions + permissions=[ + PermissionDefinition( + id="stock.view", + label_key="inventory.permissions.stock_view", + description_key="inventory.permissions.stock_view_desc", + category="stock", + ), + PermissionDefinition( + id="stock.edit", + label_key="inventory.permissions.stock_edit", + description_key="inventory.permissions.stock_edit_desc", + category="stock", + ), + PermissionDefinition( + id="stock.transfer", + label_key="inventory.permissions.stock_transfer", + description_key="inventory.permissions.stock_transfer_desc", + category="stock", + ), + ], features=[ "inventory_basic", # Basic stock tracking "inventory_locations", # Multiple warehouse locations diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index a61d1bd6..34ab658b 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -6,7 +6,12 @@ Defines the orders module including its features, menu items, route configurations, and self-contained module settings. """ -from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) from app.modules.enums import FrontendType @@ -34,6 +39,33 @@ orders_module = ModuleDefinition( ), version="1.0.0", requires=["payments"], # Depends on payments module for checkout + # Module-driven permissions + permissions=[ + PermissionDefinition( + id="orders.view", + label_key="orders.permissions.orders_view", + description_key="orders.permissions.orders_view_desc", + category="orders", + ), + PermissionDefinition( + id="orders.edit", + label_key="orders.permissions.orders_edit", + description_key="orders.permissions.orders_edit_desc", + category="orders", + ), + PermissionDefinition( + id="orders.cancel", + label_key="orders.permissions.orders_cancel", + description_key="orders.permissions.orders_cancel_desc", + category="orders", + ), + PermissionDefinition( + id="orders.refund", + label_key="orders.permissions.orders_refund", + description_key="orders.permissions.orders_refund_desc", + category="orders", + ), + ], features=[ "order_management", # Basic order CRUD "order_bulk_actions", # Bulk status updates diff --git a/app/modules/orders/routes/api/vendor_invoices.py b/app/modules/orders/routes/api/vendor_invoices.py index 2111ab32..dce43bb0 100644 --- a/app/modules/orders/routes/api/vendor_invoices.py +++ b/app/modules/orders/routes/api/vendor_invoices.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.core.feature_gate import RequireFeature +from app.modules.billing.dependencies.feature_gate import RequireFeature from app.modules.orders.exceptions import ( InvoicePDFNotFoundException, ) diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 1d6abcb0..75f14a1d 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -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", diff --git a/app/modules/tenancy/services/permission_discovery_service.py b/app/modules/tenancy/services/permission_discovery_service.py new file mode 100644 index 00000000..5bb913d0 --- /dev/null +++ b/app/modules/tenancy/services/permission_discovery_service.py @@ -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", +]