Files
orion/docs/api/rbac.md
Samir Boulahtit d648c921b7
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
docs: add consolidated dev URL reference and migrate /shop to /storefront
- Add Development URL Quick Reference section to url-routing overview
  with all login URLs, entry points, and full examples
- Replace /shop/ path segments with /storefront/ across 50 docs files
- Update file references: shop_pages.py → storefront_pages.py,
  templates/shop/ → templates/storefront/, api/v1/shop/ → api/v1/storefront/
- Preserve domain references (orion.shop) and /store/ staff dashboard paths
- Archive docs left unchanged (historical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:23:44 +01:00

2012 lines
58 KiB
Markdown

# 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` |
| **Storefront** | `/storefront/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=<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=/storefront) │
│ 💾 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="/storefront", # Only sent to /storefront/* 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 <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
```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() && (
<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**
```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') && (
<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.
```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