# Role-Based Access Control (RBAC) Developer Guide **Version:** 2.0 **Last Updated:** February 2026 **Audience:** Development Team --- ## Table of Contents 1. [Introduction](#introduction) 2. [RBAC Overview](#rbac-overview) 3. [System Architecture](#system-architecture) 4. [User Types & Contexts](#user-types-contexts) 5. [Database Schema](#database-schema) 6. [Permission System](#permission-system) 7. [Authentication Flow](#authentication-flow) 8. [Authorization Implementation](#authorization-implementation) 9. [Team Management](#team-management) 10. [Code Examples](#code-examples) 11. [Best Practices](#best-practices) 12. [Testing Guidelines](#testing-guidelines) 13. [Troubleshooting](#troubleshooting) --- ## Introduction This guide documents the Role-Based Access Control (RBAC) system implemented in our multi-tenant e-commerce platform. The system provides granular access control across three distinct contexts: Platform Administration, Store Management, and Customer Shopping. ### Purpose The RBAC system ensures that: - Users can only access resources they're authorized to see - Permissions are granular and context-specific - Multi-tenancy is enforced at the database and application level - Team collaboration is secure and auditable ### Key Principles 1. **Context Isolation** - Admin, store, and customer contexts are completely isolated 2. **Least Privilege** - Users have only the permissions they need 3. **Owner Authority** - Store owners have complete control over their store 4. **Team Flexibility** - Store teams can be structured with various role types 5. **Security First** - Cookie path isolation and role enforcement prevent unauthorized access --- ## RBAC Overview ### Two-Tier Permission Model ``` ┌─────────────────────────────────────────────────────────────┐ │ PLATFORM LEVEL │ │ User.role (4-value enum) │ │ │ │ ┌──────────────┐ ┌────────────────┐ │ │ │ Super Admin │ │ Platform Admin │ Platform admins │ │ │(super_admin) │ │(platform_admin)│ (is_admin = True) │ │ └──────────────┘ └────────────────┘ │ │ │ │ ┌────────────────┐ ┌──────────────┐ │ │ │ Merchant Owner │ │ Store Member │ Store users │ │ │(merchant_owner)│ │(store_member)│ (is_store_user=True) │ │ └────────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ STORE PERMISSION LEVEL │ │ Role.permissions │ │ │ │ Merchant owners bypass permission checks (all permissions) │ │ Store members get permissions from assigned Role │ │ │ │ Manager, Staff, Support, Viewer, Marketing, Custom │ └─────────────────────────────────────────────────────────────┘ ``` ### Context Separation The application operates in three isolated contexts: | Context | Routes | Authentication | User Roles | |---------|--------|----------------|------------| | **Admin** | `/admin/*` | `admin_token` cookie | `super_admin`, `platform_admin` | | **Store** | `/store/*` | `store_token` cookie | `merchant_owner`, `store_member` | | **Shop** | `/shop/account/*` | `customer_token` cookie | Customers | **Important:** These contexts are security boundaries. Admin users cannot access store routes, store users cannot access admin routes, and customers are entirely separate. --- ## System Architecture ### High-Level Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Request │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Middleware Layer │ │ │ │ • StoreContextMiddleware │ │ • StoreDetectionMiddleware │ │ • AuthenticationMiddleware │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ FastAPI Route Handler │ │ │ │ Dependencies: │ │ • get_current_admin_from_cookie_or_header() │ │ • get_current_store_from_cookie_or_header() │ │ • require_store_permission("permission.name") │ │ • require_store_owner() │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Service Layer │ │ │ │ • store_team_service │ │ • auth_service │ │ • customer_service │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Database Layer │ │ │ │ • User, StoreUser, Role, Customer │ └─────────────────────────────────────────────────────────────┘ ``` ### Component Responsibilities #### Authentication Layer - Validates JWT tokens - Verifies cookie paths match routes - Manages token lifecycle (creation, refresh, expiry) - Handles dual storage (cookies + headers) #### Authorization Layer - Checks user roles and permissions - Enforces store ownership rules - Validates team member access - Blocks cross-context access #### Service Layer - Implements business logic - Manages team invitations - Handles role assignments - Provides reusable authorization checks --- ## User Types & Contexts ### Super Admins **Characteristics:** - `User.role = "super_admin"` - Full access to `/admin/*` routes - Manage all platforms, stores, and users - Cannot access store or customer portals - `User.is_super_admin` property returns `True` (computed from `role == "super_admin"`) **Use Cases:** - Full platform configuration - Multi-platform management - Super-level user management - System monitoring **Authentication:** ```python # Login endpoint POST /api/v1/admin/auth/login # Cookie set admin_token (path=/admin, httponly=true) # Access routes GET /admin/dashboard GET /admin/stores POST /admin/users/{user_id}/suspend ``` ### Platform Admins **Characteristics:** - `User.role = "platform_admin"` - Access to `/admin/*` routes scoped to assigned platforms - `User.is_admin` property returns `True` (shared with super_admin) - `User.is_platform_admin` property returns `True` - Cannot access store or customer portals **Use Cases:** - Platform-scoped configuration - Store approval/verification - User management within assigned platforms - System monitoring ### Merchant Owners **Characteristics:** - `User.role = "merchant_owner"` - Automatic full permissions within their stores - Ownership determined via `Merchant.owner_user_id` (not a column on StoreUser) - Can invite and manage team members - Cannot be removed from their store - `User.is_merchant_owner` property returns `True` - `User.is_store_user` property returns `True` **Use Cases:** - Complete store management - Team administration - Financial oversight - Settings configuration **Special Privileges:** ```python # Automatic permissions - owners bypass permission checks def has_permission(self, permission: str) -> bool: if self.is_owner: return True # Owners bypass permission checks ``` ### Store Team Members **Characteristics:** - `User.role = "store_member"` - Permissions defined by `Role.permissions` via `StoreUser.role_id` - Invited by merchant owner via email - Can be assigned different roles (Manager, Staff, etc.) - `User.is_store_user` property returns `True` **Use Cases:** - Day-to-day operations based on role - Collaborative store management - Specialized functions (marketing, support) **Role Examples:** ```python # Manager - Nearly full access permissions = [ "products.view", "products.create", "products.edit", "orders.view", "orders.edit", "orders.cancel", "customers.view", "customers.edit", "reports.view", "reports.financial" ] # Staff - Operational access permissions = [ "products.view", "products.create", "products.edit", "orders.view", "orders.edit", "customers.view" ] # Support - Customer service focus permissions = [ "orders.view", "orders.edit", "customers.view", "customers.edit" ] ``` ### Customers **Characteristics:** - Separate `Customer` model (not in `User` table) - Store-scoped authentication - Can self-register on store shops - Access only their own account + shop catalog **Use Cases:** - Browse store products - Place orders - Manage account information - View order history **Important:** Customers are NOT in the User table. They use a separate authentication system and cannot access admin or store portals. --- ## Database Schema ### Entity Relationship Diagram ``` ┌──────────────────┐ │ users │ │ │ │ id (PK) │◄──────┐ │ email │ │ │ username │ │ │ role │ │ owner_user_id │ ('super_admin'| │ │ │ 'platform_ │ │ │ admin' | │ │ │ 'merchant_ │ │ │ owner' | │ │ │ 'store_member') │ │ │ is_active │ │ │ is_email_ │ │ │ verified │ │ └──────────────────┘ │ │ │ │ │ │ │ ▼ │ ┌──────────────────┐ │ │ store_users │ │ │ │ │ │ id (PK) │ │ │ store_id (FK) ─┼───┐ │ │ user_id (FK) ───┼─┐ │ │ │ role_id (FK) │ │ │ │ │ invitation_ │ │ │ │ │ token │ │ │ │ │ invitation_ │ │ │ │ │ sent_at │ │ │ │ │ invitation_ │ │ │ │ │ accepted_at │ │ │ │ │ invited_by (FK) │ │ │ │ │ is_active │ │ │ │ └──────────────────┘ │ │ │ │ │ │ │ │ role_id │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────────────┐ │ │ │ │ roles │ │ │ │ │ │ │ │ │ │ id (PK) │ │ │ │ │ store_id (FK) ─┼─┘ │ │ │ name │ │ │ │ permissions │ │ │ │ (JSONB) │ │ │ └──────────────────┘ │ │ │ │ ▼ │ ┌──────────────────┐ │ │ stores │ │ │ │ │ │ id (PK) │ │ │ store_code │ │ │ subdomain │ │ │ name │ │ │ owner_user_id ──┼───────┘ │ is_active │ │ is_verified │ └──────────────────┘ │ │ ▼ ┌──────────────────┐ │ customers │ │ (SEPARATE AUTH) │ │ │ │ id (PK) │ │ store_id (FK) │ │ email │ │ hashed_password │ │ customer_number │ │ is_active │ └──────────────────┘ ``` **Note:** The `store_users` table no longer has a `user_type` column. Ownership is determined by `Merchant.owner_user_id` and `User.role == "merchant_owner"`, not by a field on StoreUser. ### Key Tables #### users Primary platform user table for all user types. ```python class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) email = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False) hashed_password = Column(String, nullable=False) role = Column(String, nullable=False) # 'super_admin', 'platform_admin', 'merchant_owner', or 'store_member' is_active = Column(Boolean, default=True) is_email_verified = Column(Boolean, default=False) # Computed properties (not database columns): # is_super_admin: bool -> role == "super_admin" # is_admin: bool -> role in ("super_admin", "platform_admin") # is_platform_admin: bool -> role == "platform_admin" # is_merchant_owner: bool -> role == "merchant_owner" # is_store_user: bool -> role in ("merchant_owner", "store_member") ``` **Important Fields:** - `role`: Contains one of 4 values: `"super_admin"`, `"platform_admin"`, `"merchant_owner"`, or `"store_member"` - `is_email_verified`: Required for team member invitations **Note:** The `is_super_admin` column was removed. It is now a computed property: `self.role == "super_admin"`. Similarly, `is_admin` checks `role in ("super_admin", "platform_admin")`. #### stores Store entities representing businesses on the platform. ```python class Store(Base): __tablename__ = "stores" id = Column(Integer, primary_key=True) store_code = Column(String, unique=True, nullable=False) subdomain = Column(String, unique=True, nullable=False) name = Column(String, nullable=False) owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) is_active = Column(Boolean, default=True) is_verified = Column(Boolean, default=False) ``` **Important Fields:** - `owner_user_id`: The user who owns this store (full permissions) - `store_code`: Used in URLs for store context - `subdomain`: For subdomain-based routing #### store_users Junction table linking users to stores with role information. ```python class StoreUser(Base): __tablename__ = "store_users" id = Column(Integer, primary_key=True) store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) role_id = Column(Integer, ForeignKey("roles.id"), nullable=True) invited_by = Column(Integer, ForeignKey("users.id")) invitation_token = Column(String, nullable=True) invitation_sent_at = Column(DateTime, nullable=True) invitation_accepted_at = Column(DateTime, nullable=True) is_active = Column(Boolean, default=False) # Activated on acceptance ``` **Important Fields:** - `role_id`: NULL for merchant owners (they have all permissions), set for store members - `invitation_*`: Fields for tracking invitation workflow - `is_active`: FALSE until invitation accepted (for team members) **Note:** The `user_type` column was removed from StoreUser. Ownership is now determined by `User.role == "merchant_owner"` and `Merchant.owner_user_id`, not by a field on this table. Use `User.is_owner_of(store_id)` to check ownership. #### roles Store-specific role definitions with permissions. ```python class Role(Base): __tablename__ = "roles" id = Column(Integer, primary_key=True) store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) name = Column(String, nullable=False) permissions = Column(JSONB, default=[]) # PostgreSQL JSONB ``` **Important Fields:** - `store_id`: Roles are store-scoped, not platform-wide - `name`: Role name (e.g., "Manager", "Staff", "Support") - `permissions`: Array of permission strings (e.g., `["products.view", "products.create"]`) #### customers Separate customer authentication system (store-scoped). ```python class Customer(Base): __tablename__ = "customers" id = Column(Integer, primary_key=True) store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) email = Column(String, nullable=False) # Unique within store hashed_password = Column(String, nullable=False) customer_number = Column(String, nullable=False) is_active = Column(Boolean, default=True) ``` **Important Note:** Customers are NOT in the `users` table. They have a completely separate authentication system and are scoped to individual stores. --- ## Permission System ### Permission Structure Permissions follow a hierarchical naming convention: `resource.action` ```python # Format "{resource}.{action}" # Examples "products.view" # View products "products.create" # Create new products "products.edit" # Edit existing products "products.delete" # Delete products "orders.cancel" # Cancel orders "team.invite" # Invite team members (owner only) "settings.edit" # Edit store settings "reports.financial" # View financial reports ``` ### Available Permissions #### Dashboard ```python "dashboard.view" # View dashboard ``` #### Products ```python "products.view" # View product list "products.create" # Create new products "products.edit" # Edit products "products.delete" # Delete products "products.import" # Import products from CSV/marketplace "products.export" # Export products ``` #### Stock/Inventory ```python "stock.view" # View stock levels "stock.edit" # Adjust stock quantities "stock.transfer" # Transfer stock between locations ``` #### Orders ```python "orders.view" # View orders "orders.edit" # Edit order details "orders.cancel" # Cancel orders "orders.refund" # Process refunds ``` #### Customers ```python "customers.view" # View customer list "customers.edit" # Edit customer details "customers.delete" # Delete customers "customers.export" # Export customer data ``` #### Marketing ```python "marketing.view" # View marketing campaigns "marketing.create" # Create campaigns "marketing.send" # Send marketing emails ``` #### Reports ```python "reports.view" # View basic reports "reports.financial" # View financial reports "reports.export" # Export report data ``` #### Settings ```python "settings.view" # View settings "settings.edit" # Edit basic settings "settings.theme" # Edit theme/branding "settings.domains" # Manage custom domains ``` #### Team Management ```python "team.view" # View team members "team.invite" # Invite new members (owner only) "team.edit" # Edit member roles (owner only) "team.remove" # Remove members (owner only) ``` #### Marketplace Imports ```python "imports.view" # View import jobs "imports.create" # Create import jobs "imports.cancel" # Cancel import jobs ``` ### Permission Constants All permissions are defined in `app/core/permissions.py`: ```python from enum import Enum class StorePermissions(str, Enum): """All available store permissions.""" # Dashboard DASHBOARD_VIEW = "dashboard.view" # Products PRODUCTS_VIEW = "products.view" PRODUCTS_CREATE = "products.create" PRODUCTS_EDIT = "products.edit" PRODUCTS_DELETE = "products.delete" PRODUCTS_IMPORT = "products.import" PRODUCTS_EXPORT = "products.export" # ... (see permissions.py for complete list) ``` ### Role Presets Pre-configured role templates for common team structures: ```python class PermissionGroups: """Pre-defined permission sets for common roles.""" # Owner - All permissions (automatic) OWNER = set(p.value for p in StorePermissions) # Manager - Most permissions except team management MANAGER = { "dashboard.view", "products.view", "products.create", "products.edit", "products.delete", "stock.view", "stock.edit", "stock.transfer", "orders.view", "orders.edit", "orders.cancel", "orders.refund", "customers.view", "customers.edit", "customers.export", "marketing.view", "marketing.create", "marketing.send", "reports.view", "reports.financial", "reports.export", "settings.view", "settings.theme", "imports.view", "imports.create" } # Staff - Day-to-day operations STAFF = { "dashboard.view", "products.view", "products.create", "products.edit", "stock.view", "stock.edit", "orders.view", "orders.edit", "customers.view" } # Support - Customer service focused SUPPORT = { "dashboard.view", "products.view", "orders.view", "orders.edit", "customers.view", "customers.edit" } # Viewer - Read-only access VIEWER = { "dashboard.view", "products.view", "stock.view", "orders.view", "customers.view", "reports.view" } # Marketing - Marketing and customer communication MARKETING = { "dashboard.view", "customers.view", "customers.export", "marketing.view", "marketing.create", "marketing.send", "reports.view" } ``` ### Custom Roles Owners can create custom roles with specific permission sets: ```python # Creating a custom role custom_permissions = [ "products.view", "products.create", "orders.view", "customers.view" ] role = Role( store_id=store.id, name="Product Manager", permissions=custom_permissions ) ``` --- ## Authentication Flow ### Admin Authentication ``` ┌─────────────┐ │ Client │ └──────┬──────┘ │ │ POST /api/v1/admin/auth/login │ { username, password } ▼ ┌─────────────────────────────┐ │ Admin Auth Endpoint │ │ │ │ 1. Validate credentials │ │ 2. Check is_admin │ │ (super_admin or │ │ platform_admin) │ │ 3. Generate JWT │ └──────┬──────────────────────┘ │ │ Set-Cookie: admin_token= │ Path=/admin │ HttpOnly=true │ Secure=true (prod) │ SameSite=Lax │ │ Response: { access_token, user } ▼ ┌─────────────┐ │ Client │ │ │ │ 🍪 admin_token (path=/admin) │ │ 💾 localStorage.token │ └─────────────┘ ``` ### Store Authentication ``` ┌─────────────┐ │ Client │ └──────┬──────┘ │ │ POST /api/v1/store/auth/login │ { username, password } ▼ ┌─────────────────────────────┐ │ Store Auth Endpoint │ │ │ │ 1. Validate credentials │ │ 2. Block if is_admin │ │ 3. Find store membership │ │ 4. Determine ownership │ │ via Merchant. │ │ owner_user_id │ │ 5. Generate JWT │ └──────┬──────────────────────┘ │ │ Set-Cookie: store_token= │ Path=/store │ HttpOnly=true │ Secure=true (prod) │ SameSite=Lax │ │ Response: { access_token, user, store, role } ▼ ┌─────────────┐ │ Client │ │ │ │ 🍪 store_token (path=/store) │ │ 💾 localStorage.token │ └─────────────┘ ``` ### Customer Authentication ``` ┌─────────────┐ │ Client │ └──────┬──────┘ │ │ POST /api/v1/platform/stores/{id}/customers/login │ { username, password } ▼ ┌─────────────────────────────┐ │ Customer Auth Endpoint │ │ │ │ 1. Validate store │ │ 2. Validate credentials │ │ 3. Generate JWT │ └──────┬──────────────────────┘ │ │ Set-Cookie: customer_token= │ Path=/shop │ HttpOnly=true │ Secure=true (prod) │ SameSite=Lax │ │ Response: { access_token, user } ▼ ┌─────────────┐ │ Client │ │ │ │ 🍪 customer_token (path=/shop) │ │ 💾 localStorage.token │ └─────────────┘ ``` ### Cookie Path Isolation **Critical Security Feature:** Cookies are restricted by path to prevent cross-context authentication: ```python # Admin cookie response.set_cookie( key="admin_token", value=jwt_token, path="/admin", # Only sent to /admin/* routes httponly=True, secure=True, samesite="lax" ) # Store cookie response.set_cookie( key="store_token", value=jwt_token, path="/store", # Only sent to /store/* routes httponly=True, secure=True, samesite="lax" ) # Customer cookie response.set_cookie( key="customer_token", value=jwt_token, path="/shop", # Only sent to /shop/* routes httponly=True, secure=True, samesite="lax" ) ``` **Why This Matters:** - Admin cookies are never sent to store routes - Store cookies are never sent to admin routes - Customer cookies are never sent to admin/store routes - Prevents accidental cross-context authorization ### Dual Token Storage The system uses dual token storage for flexibility: 1. **HTTP-Only Cookie** - For page navigation (automatic) 2. **localStorage** - For API calls (manual headers) ```javascript // Login stores both const response = await fetch('/api/v1/store/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }); const data = await response.json(); // Cookie set automatically by server // Store token for API calls localStorage.setItem('token', data.access_token); // Page navigation - cookie sent automatically window.location.href = '/store/ACME/dashboard'; // API call - use stored token fetch('/api/v1/store/ACME/products', { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); ``` --- ## Authorization Implementation ### FastAPI Dependencies The system uses FastAPI dependencies for consistent authorization checks. #### Location All authorization dependencies are in `app/api/deps.py`. #### Basic Authentication Dependencies ```python from fastapi import Depends, Request from sqlalchemy.orm import Session from app.core.database import get_db from models.database.user import User # Admin authentication (cookie OR header) def get_current_admin_from_cookie_or_header( request: Request, db: Session = Depends(get_db) ) -> User: """ Get current admin user from cookie OR Authorization header. Checks: 1. admin_token cookie (path=/admin) 2. Authorization: Bearer header 3. Validates is_admin (role in: super_admin, platform_admin) Use for: Admin HTML pages """ # Implementation checks cookie first, then header # Returns User object if authenticated as admin # Raises AdminRequiredException if not admin # Store authentication (cookie OR header) def get_current_store_from_cookie_or_header( request: Request, db: Session = Depends(get_db) ) -> User: """ Get current store user from cookie OR Authorization header. Checks: 1. store_token cookie (path=/store) 2. Authorization: Bearer header 3. Blocks admin users (super_admin, platform_admin) 4. Validates store membership (merchant_owner or store_member) Use for: Store HTML pages """ # Implementation checks cookie first, then header # Returns User object if authenticated as store user # Raises InsufficientPermissionsException if admin # API-only authentication (header required) def get_current_admin_api( request: Request, db: Session = Depends(get_db) ) -> User: """ Get current admin from Authorization header only. Use for: Admin API endpoints """ def get_current_store_api( request: Request, db: Session = Depends(get_db) ) -> User: """ Get current store from Authorization header only. Use for: Store API endpoints """ ``` #### Permission-Based Dependencies ```python from app.core.permissions import StorePermissions def require_store_permission(permission: str): """ Dependency factory for requiring specific permission. Usage: @router.post("/products") def create_product( user: User = Depends(require_store_permission("products.create")) ): # User verified to have products.create permission ... """ def permission_checker( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_store_from_cookie_or_header) ) -> User: store = request.state.store # Set by middleware if not current_user.has_store_permission(store.id, permission): raise InsufficientStorePermissionsException( required_permission=permission, store_code=store.store_code ) return current_user return permission_checker def require_store_owner( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_store_from_cookie_or_header) ) -> User: """ Require store owner role. Usage: @router.post("/team/invite") def invite_member( user: User = Depends(require_store_owner) ): # User verified to be store owner ... """ store = request.state.store if not current_user.is_owner_of(store.id): raise StoreOwnerOnlyException( operation="team management", store_code=store.store_code ) return current_user def require_any_store_permission(*permissions: str): """ Require ANY of the specified permissions. Usage: @router.get("/dashboard") def dashboard( user: User = Depends(require_any_store_permission( "dashboard.view", "reports.view" )) ): # User has at least one permission ... """ def require_all_store_permissions(*permissions: str): """ Require ALL of the specified permissions. Usage: @router.post("/products/bulk-delete") def bulk_delete( user: User = Depends(require_all_store_permissions( "products.view", "products.delete" )) ): # User has all permissions ... """ def get_user_permissions( request: Request, current_user: User = Depends(get_current_store_from_cookie_or_header) ) -> list: """ Get all permissions for current user. Returns list of permission strings. Usage: @router.get("/me/permissions") def my_permissions( permissions: list = Depends(get_user_permissions) ): return {"permissions": permissions} """ ``` ### Model Helper Methods #### User Model ```python # In models/database/user.py class User(Base): # ... fields ... @property def is_super_admin(self) -> bool: """Check if user is super admin (computed from role).""" return self.role == "super_admin" @property def is_admin(self) -> bool: """Check if user is any type of platform admin.""" return self.role in ("super_admin", "platform_admin") @property def is_platform_admin(self) -> bool: """Check if user is a (non-super) platform admin.""" return self.role == "platform_admin" @property def is_merchant_owner(self) -> bool: """Check if user is a merchant owner.""" return self.role == "merchant_owner" @property def is_store_user(self) -> bool: """Check if user is a store-level user (owner or member).""" return self.role in ("merchant_owner", "store_member") def is_owner_of(self, store_id: int) -> bool: """Check if user owns a specific store.""" return any(v.id == store_id for v in self.owned_stores) def is_member_of(self, store_id: int) -> bool: """Check if user is member of store (owner or team).""" if self.is_owner_of(store_id): return True return any( vm.store_id == store_id and vm.is_active for vm in self.store_memberships ) def get_store_role(self, store_id: int) -> str: """Get role name within specific store.""" if self.is_owner_of(store_id): return "owner" for vm in self.store_memberships: if vm.store_id == store_id and vm.is_active: return vm.role.name if vm.role else "member" return None def has_store_permission(self, store_id: int, permission: str) -> bool: """Check if user has specific permission in store.""" # Owners have all permissions if self.is_owner_of(store_id): return True # Check team member permissions for vm in self.store_memberships: if vm.store_id == store_id and vm.is_active: if vm.role and permission in vm.role.permissions: return True return False ``` #### StoreUser Model ```python # In models/database/store.py class StoreUser(Base): # ... fields ... @property def is_owner(self) -> bool: """Check if this user is the store owner. Determined by User.role == 'merchant_owner' and Merchant.owner_user_id, NOT by a column on StoreUser (user_type was removed). """ return self.user.is_merchant_owner and self.user.is_owner_of(self.store_id) @property def is_team_member(self) -> bool: """Check if this is a team member (not owner).""" return not self.is_owner @property def is_invitation_pending(self) -> bool: """Check if invitation is pending acceptance.""" return ( self.invitation_token is not None and self.invitation_accepted_at is None ) def has_permission(self, permission: str) -> bool: """Check if this membership has specific permission.""" # Owners have all permissions if self.is_owner: return True # Inactive users have no permissions if not self.is_active: return False # Check role permissions if self.role and self.role.permissions: return permission in self.role.permissions return False def get_all_permissions(self) -> list: """Get all permissions for this membership.""" if self.is_owner: from app.core.permissions import StorePermissions return [p.value for p in StorePermissions] if self.role and self.role.permissions: return self.role.permissions return [] ``` --- ## Team Management ### Invitation Flow The system uses email-based invitations for team member onboarding. #### Complete Flow Diagram ``` ┌──────────────────────────────────────────────────────────────┐ │ INVITATION WORKFLOW │ └──────────────────────────────────────────────────────────────┘ 1. Owner initiates invitation └─> POST /api/v1/store/{code}/team/invite Body: { email, role } 2. System creates/updates records ├─> User record (if doesn't exist) │ - email: from invitation │ - username: auto-generated │ - role: "store_member" │ - is_active: FALSE │ - is_email_verified: FALSE │ └─> StoreUser record - store_id: current store - user_id: from User - role_id: from role selection - invitation_token: secure random string - invitation_sent_at: now() - invited_by: current user - is_active: FALSE 3. Email sent to invitee └─> Contains: invitation link with token Link: /store/invitation/accept?token={invitation_token} 4. Invitee clicks link └─> GET /store/invitation/accept?token={token} Displays form: password, first_name, last_name 5. Invitee submits form └─> POST /api/v1/store/team/accept-invitation Body: { invitation_token, password, first_name, last_name } 6. System activates account ├─> User updates: │ - hashed_password: from form │ - first_name, last_name: from form │ - is_active: TRUE │ - is_email_verified: TRUE │ └─> StoreUser updates: - is_active: TRUE - invitation_accepted_at: now() - invitation_token: NULL (cleared) 7. Member can now login └─> POST /api/v1/store/auth/login Redirect to store dashboard ``` ### Service Layer Implementation Team management is handled by `StoreTeamService` in `app/services/store_team_service.py`. #### Key Methods ```python class StoreTeamService: def invite_team_member( self, db: Session, store: Store, inviter: User, email: str, role_name: str, custom_permissions: Optional[List[str]] = None ) -> Dict[str, Any]: """ Invite a new team member. Steps: 1. Check team size limits 2. Find or create User account 3. Create or update StoreUser with invitation 4. Generate secure invitation token 5. Send invitation email Returns: { "invitation_token": str, "email": str, "role": str, "existing_user": bool } """ def accept_invitation( self, db: Session, invitation_token: str, password: str, first_name: Optional[str] = None, last_name: Optional[str] = None ) -> Dict[str, Any]: """ Accept team invitation and activate account. Steps: 1. Validate invitation token 2. Check token not expired (7 days) 3. Update User (password, name, active status) 4. Update StoreUser (active, accepted timestamp) 5. Clear invitation token Returns: { "user": User, "store": Store, "role": str } """ def remove_team_member( self, db: Session, store: Store, user_id: int ) -> bool: """ Remove team member (soft delete). Cannot remove owner. Sets StoreUser.is_active = False """ def update_member_role( self, db: Session, store: Store, user_id: int, new_role_name: str, custom_permissions: Optional[List[str]] = None ) -> StoreUser: """ Update team member's role. Cannot change owner's role. Creates new role if custom permissions provided. """ def get_team_members( self, db: Session, store: Store, include_inactive: bool = False ) -> List[Dict[str, Any]]: """ Get all team members for a store. Returns list of member info including: - Basic user info - Role and permissions - Invitation status - Active status """ ``` ### API Routes Complete team management routes in `app/api/v1/store/team.py`. ```python router = APIRouter(prefix="/team") # List team members @router.get("/members") def list_team_members( request: Request, user: User = Depends(require_store_permission("team.view")) ): """List all team members.""" # Invite team member (owner only) @router.post("/invite") def invite_team_member( invitation: InviteTeamMemberRequest, user: User = Depends(require_store_owner) ): """Send team invitation email.""" # Accept invitation (public, no auth) @router.post("/accept-invitation") def accept_invitation( acceptance: AcceptInvitationRequest ): """Accept invitation and activate account.""" # Remove team member (owner only) @router.delete("/members/{user_id}") def remove_team_member( user_id: int, user: User = Depends(require_store_owner) ): """Remove team member from store.""" # Update member role (owner only) @router.put("/members/{user_id}/role") def update_member_role( user_id: int, role_update: UpdateMemberRoleRequest, user: User = Depends(require_store_owner) ): """Change team member's role.""" # Get current user's permissions @router.get("/me/permissions") def get_my_permissions( permissions: list = Depends(get_user_permissions) ): """Get current user's permission list.""" ``` ### Security Considerations #### Owner Protection Owners cannot be removed or have their role changed: ```python # In remove_team_member if store_user.is_owner: raise CannotRemoveStoreOwnerException(store.store_code) # In update_member_role if store_user.is_owner: raise CannotRemoveStoreOwnerException(store.store_code) ``` #### Invitation Token Security - Tokens are 32-byte cryptographically secure random strings - Single-use (cleared after acceptance) - Expire after 7 days - Unique per invitation ```python def _generate_invitation_token(self) -> str: """Generate secure invitation token.""" import secrets return secrets.token_urlsafe(32) ``` #### Admin Blocking Platform admins are blocked from store routes: ```python # In store auth endpoint if current_user.is_admin: # role in ("super_admin", "platform_admin") raise InvalidCredentialsException( "Admins cannot access store portal" ) # In store dependencies if current_user.is_admin: raise InsufficientPermissionsException( "Store access only" ) ``` --- ## Code Examples ### Example 1: Protected Route with Permission Check ```python from fastapi import APIRouter, Depends from app.api.deps import require_store_permission from app.core.permissions import StorePermissions from models.database.user import User router = APIRouter() @router.post("/products") def create_product( product_data: ProductCreate, user: User = Depends(require_store_permission( StorePermissions.PRODUCTS_CREATE.value )) ): """ Create a new product. Requires: products.create permission The dependency automatically: 1. Authenticates the user 2. Gets store from request.state 3. Checks user has products.create permission 4. Returns User if authorized 5. Raises InsufficientStorePermissionsException if not """ store = request.state.store # User is authenticated and authorized # Proceed with business logic product = product_service.create( db=db, store_id=store.id, user_id=user.id, data=product_data ) return {"product": product} ``` ### Example 2: Owner-Only Route ```python from app.api.deps import require_store_owner @router.delete("/team/members/{member_id}") def remove_team_member( member_id: int, user: User = Depends(require_store_owner) ): """ Remove a team member. Requires: Store owner role The dependency automatically: 1. Authenticates the user 2. Checks user is owner of current store 3. Returns User if owner 4. Raises StoreOwnerOnlyException if not owner """ store = request.state.store # User is verified owner store_team_service.remove_team_member( db=db, store=store, user_id=member_id ) return {"message": "Member removed"} ``` ### Example 3: Multi-Permission Route ```python from app.api.deps import require_all_store_permissions @router.post("/store/{code}/products/bulk-import") def bulk_import_products( file: UploadFile, user: User = Depends(require_all_store_permissions( StorePermissions.PRODUCTS_VIEW.value, StorePermissions.PRODUCTS_CREATE.value, StorePermissions.PRODUCTS_IMPORT.value )) ): """ Bulk import products from CSV. Requires ALL of: - products.view - products.create - products.import The dependency checks user has ALL specified permissions. """ store = request.state.store # User has all required permissions result = import_service.process_csv( db=db, store_id=store.id, file=file ) return {"imported": result.success_count} ``` ### Example 4: Frontend Permission Checking ```javascript // On login, fetch user's permissions async function login(username, password) { const response = await fetch('/api/v1/store/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); // Store token localStorage.setItem('token', data.access_token); // Fetch permissions const permResponse = await fetch('/api/v1/store/team/me/permissions', { headers: { 'Authorization': `Bearer ${data.access_token}` } }); const { permissions } = await permResponse.json(); // Store permissions localStorage.setItem('permissions', JSON.stringify(permissions)); // Navigate window.location.href = '/store/dashboard'; } // Check permission before showing UI element function canCreateProducts() { const permissions = JSON.parse(localStorage.getItem('permissions') || '[]'); return permissions.includes('products.create'); } // In React/Alpine.js component {canCreateProducts() && ( )} // Disable button if no permission ``` --- ## Best Practices ### 1. Route-Level Authorization **✅ DO: Check permissions at route level using dependencies** ```python @router.post("/products") def create_product( data: ProductCreate, user: User = Depends(require_store_permission("products.create")) ): # Permission already verified return product_service.create(data) ``` **❌ DON'T: Check permissions in service layer** ```python # BAD def create_product(db: Session, user: User, data: ProductCreate): if not user.has_permission("products.create"): raise Exception("No permission") # ... ``` ### 2. Use Type-Safe Permission Constants **✅ DO: Use StorePermissions enum** ```python from app.core.permissions import StorePermissions require_store_permission(StorePermissions.PRODUCTS_CREATE.value) ``` **❌ DON'T: Use magic strings** ```python # BAD - typos won't be caught require_store_permission("products.creat") # Typo! ``` ### 3. Owner Permission Bypass **✅ DO: Let owners bypass permission checks automatically** ```python def has_permission(self, permission: str) -> bool: if self.is_owner: return True # Owners have all permissions # Check role permissions... ``` **❌ DON'T: Explicitly check owner in every route** ```python # BAD - redundant if not user.is_owner and not user.has_permission("products.create"): raise Exception() ``` ### 4. Service Layer Design **✅ DO: Keep services authorization-agnostic** ```python # Service assumes caller is authorized def create_product(db: Session, store_id: int, data: dict) -> Product: product = Product(store_id=store_id, **data) db.add(product) db.commit() return product ``` **❌ DON'T: Mix authorization into services** ```python # BAD def create_product(db: Session, user: User, data: dict): if not user.is_active: raise Exception() # ... ``` ### 5. Frontend Permission Checks **✅ DO: Hide/disable UI elements without permission** ```javascript // Hide button if no permission {hasPermission('products.delete') && ( )} // Disable button if no permission ``` **❌ DON'T: Rely only on frontend checks** Backend MUST always verify permissions. Frontend checks are for UX only. --- ## Testing Guidelines ### Unit Tests Test permission logic in isolation. ```python # tests/unit/test_permissions.py def test_owner_has_all_permissions(): """Merchant owners have all permissions automatically.""" user = create_user(role="merchant_owner") store = create_store(owner=user) store_user = create_store_user( user=user, store=store ) assert store_user.has_permission("products.create") assert store_user.has_permission("orders.delete") assert store_user.has_permission("team.invite") # All permissions should return True def test_team_member_respects_role(): """Store members have only their role's permissions.""" user = create_user(role="store_member") store = create_store() role = create_role( store=store, name="Staff", permissions=["products.view", "products.create"] ) store_user = create_store_user( user=user, store=store, role=role ) assert store_user.has_permission("products.view") assert store_user.has_permission("products.create") assert not store_user.has_permission("products.delete") assert not store_user.has_permission("team.invite") def test_inactive_user_has_no_permissions(): """Inactive users have no permissions.""" user = create_user() store = create_store() role = create_role( store=store, permissions=["products.view"] ) store_user = create_store_user( user=user, store=store, role=role, is_active=False # Inactive ) assert not store_user.has_permission("products.view") ``` ### Integration Tests Test full request/response cycles with authentication. ```python # tests/integration/test_product_routes.py def test_create_product_with_permission(client, auth_headers): """Authenticated user with permission can create product.""" # Setup: Create user with products.create permission user = create_store_team_member( permissions=["products.create"] ) token = create_auth_token(user) # Request response = client.post( "/api/v1/store/ACME/products", json={"name": "Test Product", "price": 9.99}, headers={"Authorization": f"Bearer {token}"} ) # Assert assert response.status_code == 201 assert response.json()["name"] == "Test Product" def test_create_product_without_permission(client): """User without permission cannot create product.""" # Setup: Create user WITHOUT products.create user = create_store_team_member( permissions=["products.view"] # Can view but not create ) token = create_auth_token(user) # Request response = client.post( "/api/v1/store/ACME/products", json={"name": "Test Product"}, headers={"Authorization": f"Bearer {token}"} ) # Assert assert response.status_code == 403 assert "INSUFFICIENT_STORE_PERMISSIONS" in response.json()["error_code"] def test_owner_bypasses_permission_check(client): """Store owner can create products without explicit permission.""" # Setup: Create owner (no specific role) user, store = create_store_with_owner() token = create_auth_token(user) # Request response = client.post( f"/api/v1/store/{store.store_code}/products", json={"name": "Test Product"}, headers={"Authorization": f"Bearer {token}"} ) # Assert - Owner can create even without explicit permission assert response.status_code == 201 def test_admin_blocked_from_store_route(client): """Admins cannot access store routes.""" # Setup: Create admin user admin = create_admin_user() token = create_auth_token(admin) # Request response = client.get( "/api/v1/store/ACME/products", headers={"Authorization": f"Bearer {token}"} ) # Assert assert response.status_code == 403 assert "INSUFFICIENT_PERMISSIONS" in response.json()["error_code"] ``` --- ## Troubleshooting ### Common Issues #### Issue: "INVALID_TOKEN" error **Symptoms:** - API calls return 401 Unauthorized - Error message: "Invalid token" **Causes:** 1. Token expired (default 30 minutes) 2. Token malformed 3. Token signature invalid **Solutions:** ```python # Check token expiry import jwt token = "eyJ0eXAi..." decoded = jwt.decode(token, verify=False) print(decoded['exp']) # Unix timestamp # Compare with current time import time if decoded['exp'] < time.time(): print("Token expired - user needs to re-login") # Verify token manually from middleware.auth import AuthManager auth = AuthManager() try: user_data = auth.verify_token(token) print("Token valid") except Exception as e: print(f"Token invalid: {e}") ``` #### Issue: User can't access route despite having permission **Symptoms:** - Route returns 403 Forbidden - User believes they have required permission **Debug Steps:** ```python # 1. Check user's actual permissions user = db.query(User).get(user_id) store = db.query(Store).get(store_id) print(f"Is owner? {user.is_owner_of(store.id)}") if not user.is_owner_of(store.id): # Get team membership store_user = db.query(StoreUser).filter_by( user_id=user.id, store_id=store.id ).first() print(f"Has membership? {store_user is not None}") print(f"Is active? {store_user.is_active if store_user else 'N/A'}") print(f"Role: {store_user.role.name if store_user and store_user.role else 'N/A'}") print(f"Permissions: {store_user.role.permissions if store_user and store_user.role else []}") # 2. Check specific permission permission = "products.create" has_perm = user.has_store_permission(store.id, permission) print(f"Has {permission}? {has_perm}") ``` #### Issue: Admin can't access store routes **Symptoms:** - Admin user gets 403 on store routes **This is intentional!** Admins are blocked from store routes for security. **Solutions:** 1. Create separate store account for store management 2. Have admin create store, then use store owner account ```python # Admin workflow # 1. Admin creates store (from admin portal) # 2. System creates store owner user automatically # 3. Admin logs out of admin portal # 4. Store owner logs into store portal ``` --- ## Quick Reference See [RBAC Quick Reference](../backend/rbac-quick-reference.md) for a condensed cheat sheet of common imports, route patterns, and permission constants. --- ## Related Documentation - [Authentication System](authentication.md) - JWT authentication implementation - [Architecture Overview](../architecture/auth-rbac.md) - System-wide authentication and RBAC - [Backend Development](../backend/overview.md) - Backend development guide - [API Reference](../backend/middleware-reference.md) - Auto-generated API documentation --- **Document Version:** 2.0 **Last Updated:** February 2026 **Maintained By:** Backend Team