Some checks failed
- 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>
2012 lines
58 KiB
Markdown
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
|