Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean) into a single 4-value UserRole enum: super_admin, platform_admin, merchant_owner, store_member. Drop stale StoreUser.user_type column. Fix role="user" bug in merchant creation. Key changes: - Expand UserRole enum from 2 to 4 values with computed properties (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user) - Add Alembic migration (tenancy_003) for data migration + column drops - Remove is_super_admin from JWT token payload - Update all auth dependencies, services, routes, templates, JS, and tests - Update all RBAC documentation 66 files changed, 1219 unit tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
58 KiB
Role-Based Access Control (RBAC) Developer Guide
Version: 2.0 Last Updated: February 2026 Audience: Development Team
Table of Contents
- Introduction
- RBAC Overview
- System Architecture
- User Types & Contexts
- Database Schema
- Permission System
- Authentication Flow
- Authorization Implementation
- Team Management
- Code Examples
- Best Practices
- Testing Guidelines
- 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
- Context Isolation - Admin, store, and customer contexts are completely isolated
- Least Privilege - Users have only the permissions they need
- Owner Authority - Store owners have complete control over their store
- Team Flexibility - Store teams can be structured with various role types
- 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_adminproperty returnsTrue(computed fromrole == "super_admin")
Use Cases:
- Full platform configuration
- Multi-platform management
- Super-level user management
- System monitoring
Authentication:
# 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_adminproperty returnsTrue(shared with super_admin)User.is_platform_adminproperty returnsTrue- 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_ownerproperty returnsTrueUser.is_store_userproperty returnsTrue
Use Cases:
- Complete store management
- Team administration
- Financial oversight
- Settings configuration
Special Privileges:
# 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.permissionsviaStoreUser.role_id - Invited by merchant owner via email
- Can be assigned different roles (Manager, Staff, etc.)
User.is_store_userproperty returnsTrue
Use Cases:
- Day-to-day operations based on role
- Collaborative store management
- Specialized functions (marketing, support)
Role Examples:
# 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
Customermodel (not inUsertable) - 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.
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.
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 contextsubdomain: For subdomain-based routing
store_users
Junction table linking users to stores with role information.
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 membersinvitation_*: Fields for tracking invitation workflowis_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.
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-widename: 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).
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
# 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
"dashboard.view" # View dashboard
Products
"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
"stock.view" # View stock levels
"stock.edit" # Adjust stock quantities
"stock.transfer" # Transfer stock between locations
Orders
"orders.view" # View orders
"orders.edit" # Edit order details
"orders.cancel" # Cancel orders
"orders.refund" # Process refunds
Customers
"customers.view" # View customer list
"customers.edit" # Edit customer details
"customers.delete" # Delete customers
"customers.export" # Export customer data
Marketing
"marketing.view" # View marketing campaigns
"marketing.create" # Create campaigns
"marketing.send" # Send marketing emails
Reports
"reports.view" # View basic reports
"reports.financial" # View financial reports
"reports.export" # Export report data
Settings
"settings.view" # View settings
"settings.edit" # Edit basic settings
"settings.theme" # Edit theme/branding
"settings.domains" # Manage custom domains
Team Management
"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
"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:
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:
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:
# 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=<JWT>
│ 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=<JWT>
│ 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=<JWT>
│ 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:
# 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:
- HTTP-Only Cookie - For page navigation (automatic)
- localStorage - For API calls (manual headers)
// 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
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 <token> 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 <token> 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
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
# 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
# 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
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.
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:
# 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
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:
# 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
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
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
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
// 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() && (
<button onClick={handleCreateProduct}>
Create Product
</button>
)}
// Disable button if no permission
<button
disabled={!canCreateProducts()}
onClick={handleCreateProduct}
>
Create Product
</button>
Best Practices
1. Route-Level Authorization
✅ DO: Check permissions at route level using dependencies
@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
# 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
from app.core.permissions import StorePermissions
require_store_permission(StorePermissions.PRODUCTS_CREATE.value)
❌ DON'T: Use magic strings
# BAD - typos won't be caught
require_store_permission("products.creat") # Typo!
3. Owner Permission Bypass
✅ DO: Let owners bypass permission checks automatically
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
# 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
# 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
# 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
// Hide button if no permission
{hasPermission('products.delete') && (
<DeleteButton />
)}
// Disable button if no permission
<button disabled={!hasPermission('products.edit')}>
Edit
</button>
❌ 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.
# 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.
# 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:
- Token expired (default 30 minutes)
- Token malformed
- Token signature invalid
Solutions:
# 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:
# 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:
- Create separate store account for store management
- Have admin create store, then use store owner account
# 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 for a condensed cheat sheet of common imports, route patterns, and permission constants.
Related Documentation
- Authentication System - JWT authentication implementation
- Architecture Overview - System-wide authentication and RBAC
- Backend Development - Backend development guide
- API Reference - Auto-generated API documentation
Document Version: 2.0 Last Updated: February 2026 Maintained By: Backend Team