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

58 KiB

Role-Based Access Control (RBAC) Developer Guide

Version: 2.0 Last Updated: February 2026 Audience: Development Team


Table of Contents

  1. Introduction
  2. RBAC Overview
  3. System Architecture
  4. User Types & Contexts
  5. Database Schema
  6. Permission System
  7. Authentication Flow
  8. Authorization Implementation
  9. Team Management
  10. Code Examples
  11. Best Practices
  12. Testing Guidelines
  13. 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:

# 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:

# 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:

# 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.

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    username = Column(String, unique=True, nullable=False)
    hashed_password = Column(String, nullable=False)
    role = Column(String, nullable=False)
    # 'super_admin', 'platform_admin', 'merchant_owner', or 'store_member'
    is_active = Column(Boolean, default=True)
    is_email_verified = Column(Boolean, default=False)

    # Computed properties (not database columns):
    # is_super_admin: bool  -> role == "super_admin"
    # is_admin: bool        -> role in ("super_admin", "platform_admin")
    # is_platform_admin: bool -> role == "platform_admin"
    # is_merchant_owner: bool -> role == "merchant_owner"
    # is_store_user: bool   -> role in ("merchant_owner", "store_member")

Important Fields:

  • role: Contains one of 4 values: "super_admin", "platform_admin", "merchant_owner", or "store_member"
  • is_email_verified: Required for team member invitations

Note: The is_super_admin column was removed. It is now a computed property: self.role == "super_admin". Similarly, is_admin checks role in ("super_admin", "platform_admin").

stores

Store entities representing businesses on the platform.

class Store(Base):
    __tablename__ = "stores"

    id = Column(Integer, primary_key=True)
    store_code = Column(String, unique=True, nullable=False)
    subdomain = Column(String, unique=True, nullable=False)
    name = Column(String, nullable=False)
    owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    is_active = Column(Boolean, default=True)
    is_verified = Column(Boolean, default=False)

Important Fields:

  • owner_user_id: The user who owns this store (full permissions)
  • store_code: Used in URLs for store context
  • subdomain: For subdomain-based routing

store_users

Junction table linking users to stores with role information.

class StoreUser(Base):
    __tablename__ = "store_users"

    id = Column(Integer, primary_key=True)
    store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
    invited_by = Column(Integer, ForeignKey("users.id"))
    invitation_token = Column(String, nullable=True)
    invitation_sent_at = Column(DateTime, nullable=True)
    invitation_accepted_at = Column(DateTime, nullable=True)
    is_active = Column(Boolean, default=False)  # Activated on acceptance

Important Fields:

  • role_id: NULL for merchant owners (they have all permissions), set for store 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.

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).

class Customer(Base):
    __tablename__ = "customers"

    id = Column(Integer, primary_key=True)
    store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
    email = Column(String, nullable=False)  # Unique within store
    hashed_password = Column(String, nullable=False)
    customer_number = Column(String, nullable=False)
    is_active = Column(Boolean, default=True)

Important Note: Customers are NOT in the users table. They have a completely separate authentication system and are scoped to individual stores.


Permission System

Permission Structure

Permissions follow a hierarchical naming convention: resource.action

# Format
"{resource}.{action}"

# Examples
"products.view"      # View products
"products.create"    # Create new products
"products.edit"      # Edit existing products
"products.delete"    # Delete products
"orders.cancel"      # Cancel orders
"team.invite"        # Invite team members (owner only)
"settings.edit"      # Edit store settings
"reports.financial"  # View financial reports

Available Permissions

Dashboard

"dashboard.view"     # View dashboard

Products

"products.view"      # View product list
"products.create"    # Create new products
"products.edit"      # Edit products
"products.delete"    # Delete products
"products.import"    # Import products from CSV/marketplace
"products.export"    # Export products

Stock/Inventory

"stock.view"         # View stock levels
"stock.edit"         # Adjust stock quantities
"stock.transfer"     # Transfer stock between locations

Orders

"orders.view"        # View orders
"orders.edit"        # Edit order details
"orders.cancel"      # Cancel orders
"orders.refund"      # Process refunds

Customers

"customers.view"     # View customer list
"customers.edit"     # Edit customer details
"customers.delete"   # Delete customers
"customers.export"   # Export customer data

Marketing

"marketing.view"     # View marketing campaigns
"marketing.create"   # Create campaigns
"marketing.send"     # Send marketing emails

Reports

"reports.view"       # View basic reports
"reports.financial"  # View financial reports
"reports.export"     # Export report data

Settings

"settings.view"      # View settings
"settings.edit"      # Edit basic settings
"settings.theme"     # Edit theme/branding
"settings.domains"   # Manage custom domains

Team Management

"team.view"          # View team members
"team.invite"        # Invite new members (owner only)
"team.edit"          # Edit member roles (owner only)
"team.remove"        # Remove members (owner only)

Marketplace Imports

"imports.view"       # View import jobs
"imports.create"     # Create import jobs
"imports.cancel"     # Cancel import jobs

Permission Constants

All permissions are defined in app/core/permissions.py:

from enum import Enum

class StorePermissions(str, Enum):
    """All available store permissions."""

    # Dashboard
    DASHBOARD_VIEW = "dashboard.view"

    # Products
    PRODUCTS_VIEW = "products.view"
    PRODUCTS_CREATE = "products.create"
    PRODUCTS_EDIT = "products.edit"
    PRODUCTS_DELETE = "products.delete"
    PRODUCTS_IMPORT = "products.import"
    PRODUCTS_EXPORT = "products.export"

    # ... (see permissions.py for complete list)

Role Presets

Pre-configured role templates for common team structures:

class PermissionGroups:
    """Pre-defined permission sets for common roles."""

    # Owner - All permissions (automatic)
    OWNER = set(p.value for p in StorePermissions)

    # Manager - Most permissions except team management
    MANAGER = {
        "dashboard.view",
        "products.view", "products.create", "products.edit", "products.delete",
        "stock.view", "stock.edit", "stock.transfer",
        "orders.view", "orders.edit", "orders.cancel", "orders.refund",
        "customers.view", "customers.edit", "customers.export",
        "marketing.view", "marketing.create", "marketing.send",
        "reports.view", "reports.financial", "reports.export",
        "settings.view", "settings.theme",
        "imports.view", "imports.create"
    }

    # Staff - Day-to-day operations
    STAFF = {
        "dashboard.view",
        "products.view", "products.create", "products.edit",
        "stock.view", "stock.edit",
        "orders.view", "orders.edit",
        "customers.view"
    }

    # Support - Customer service focused
    SUPPORT = {
        "dashboard.view",
        "products.view",
        "orders.view", "orders.edit",
        "customers.view", "customers.edit"
    }

    # Viewer - Read-only access
    VIEWER = {
        "dashboard.view",
        "products.view",
        "stock.view",
        "orders.view",
        "customers.view",
        "reports.view"
    }

    # Marketing - Marketing and customer communication
    MARKETING = {
        "dashboard.view",
        "customers.view", "customers.export",
        "marketing.view", "marketing.create", "marketing.send",
        "reports.view"
    }

Custom Roles

Owners can create custom roles with specific permission sets:

# Creating a custom role
custom_permissions = [
    "products.view",
    "products.create",
    "orders.view",
    "customers.view"
]

role = Role(
    store_id=store.id,
    name="Product Manager",
    permissions=custom_permissions
)

Authentication Flow

Admin Authentication

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ POST /api/v1/admin/auth/login
       │ { username, password }
       ▼
┌─────────────────────────────┐
│  Admin Auth Endpoint        │
│                             │
│  1. Validate credentials    │
│  2. Check is_admin          │
│     (super_admin or         │
│      platform_admin)        │
│  3. Generate JWT            │
└──────┬──────────────────────┘
       │
       │ Set-Cookie: admin_token=<JWT>
       │             Path=/admin
       │             HttpOnly=true
       │             Secure=true (prod)
       │             SameSite=Lax
       │
       │ Response: { access_token, user }
       ▼
┌─────────────┐
│   Client    │
│             │
│ 🍪 admin_token (path=/admin)      │
│ 💾 localStorage.token              │
└─────────────┘

Store Authentication

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ POST /api/v1/store/auth/login
       │ { username, password }
       ▼
┌─────────────────────────────┐
│  Store Auth Endpoint       │
│                             │
│  1. Validate credentials    │
│  2. Block if is_admin       │
│  3. Find store membership  │
│  4. Determine ownership     │
│     via Merchant.           │
│     owner_user_id           │
│  5. Generate JWT            │
└──────┬──────────────────────┘
       │
       │ Set-Cookie: store_token=<JWT>
       │             Path=/store
       │             HttpOnly=true
       │             Secure=true (prod)
       │             SameSite=Lax
       │
       │ Response: { access_token, user, store, role }
       ▼
┌─────────────┐
│   Client    │
│             │
│ 🍪 store_token (path=/store)    │
│ 💾 localStorage.token              │
└─────────────┘

Customer Authentication

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ POST /api/v1/platform/stores/{id}/customers/login
       │ { username, password }
       ▼
┌─────────────────────────────┐
│  Customer Auth Endpoint     │
│                             │
│  1. Validate store         │
│  2. Validate credentials    │
│  3. Generate JWT            │
└──────┬──────────────────────┘
       │
       │ Set-Cookie: customer_token=<JWT>
       │             Path=/shop
       │             HttpOnly=true
       │             Secure=true (prod)
       │             SameSite=Lax
       │
       │ Response: { access_token, user }
       ▼
┌─────────────┐
│   Client    │
│             │
│ 🍪 customer_token (path=/storefront)    │
│ 💾 localStorage.token              │
└─────────────┘

Critical Security Feature:

Cookies are restricted by path to prevent cross-context authentication:

# Admin cookie
response.set_cookie(
    key="admin_token",
    value=jwt_token,
    path="/admin",  # Only sent to /admin/* routes
    httponly=True,
    secure=True,
    samesite="lax"
)

# Store cookie
response.set_cookie(
    key="store_token",
    value=jwt_token,
    path="/store",  # Only sent to /store/* routes
    httponly=True,
    secure=True,
    samesite="lax"
)

# Customer cookie
response.set_cookie(
    key="customer_token",
    value=jwt_token,
    path="/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)
// Login stores both
const response = await fetch('/api/v1/store/auth/login', {
    method: 'POST',
    body: JSON.stringify({ username, password })
});

const data = await response.json();
// Cookie set automatically by server
// Store token for API calls
localStorage.setItem('token', data.access_token);

// Page navigation - cookie sent automatically
window.location.href = '/store/ACME/dashboard';

// API call - use stored token
fetch('/api/v1/store/ACME/products', {
    headers: {
        'Authorization': `Bearer ${localStorage.getItem('token')}`
    }
});

Authorization Implementation

FastAPI Dependencies

The system uses FastAPI dependencies for consistent authorization checks.

Location

All authorization dependencies are in app/api/deps.py.

Basic Authentication Dependencies

from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from models.database.user import User

# Admin authentication (cookie OR header)
def get_current_admin_from_cookie_or_header(
    request: Request,
    db: Session = Depends(get_db)
) -> User:
    """
    Get current admin user from cookie OR Authorization header.

    Checks:
    1. admin_token cookie (path=/admin)
    2. Authorization: Bearer <token> header
    3. Validates is_admin (role in: super_admin, platform_admin)

    Use for: Admin HTML pages
    """
    # Implementation checks cookie first, then header
    # Returns User object if authenticated as admin
    # Raises AdminRequiredException if not admin

# Store authentication (cookie OR header)
def get_current_store_from_cookie_or_header(
    request: Request,
    db: Session = Depends(get_db)
) -> User:
    """
    Get current store user from cookie OR Authorization header.

    Checks:
    1. store_token cookie (path=/store)
    2. Authorization: Bearer <token> header
    3. Blocks admin users (super_admin, platform_admin)
    4. Validates store membership (merchant_owner or store_member)

    Use for: Store HTML pages
    """
    # Implementation checks cookie first, then header
    # Returns User object if authenticated as store user
    # Raises InsufficientPermissionsException if admin

# API-only authentication (header required)
def get_current_admin_api(
    request: Request,
    db: Session = Depends(get_db)
) -> User:
    """
    Get current admin from Authorization header only.

    Use for: Admin API endpoints
    """

def get_current_store_api(
    request: Request,
    db: Session = Depends(get_db)
) -> User:
    """
    Get current store from Authorization header only.

    Use for: Store API endpoints
    """

Permission-Based Dependencies

from app.core.permissions import StorePermissions

def require_store_permission(permission: str):
    """
    Dependency factory for requiring specific permission.

    Usage:
        @router.post("/products")
        def create_product(
            user: User = Depends(require_store_permission("products.create"))
        ):
            # User verified to have products.create permission
            ...
    """
    def permission_checker(
        request: Request,
        db: Session = Depends(get_db),
        current_user: User = Depends(get_current_store_from_cookie_or_header)
    ) -> User:
        store = request.state.store  # Set by middleware

        if not current_user.has_store_permission(store.id, permission):
            raise InsufficientStorePermissionsException(
                required_permission=permission,
                store_code=store.store_code
            )

        return current_user

    return permission_checker

def require_store_owner(
    request: Request,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_store_from_cookie_or_header)
) -> User:
    """
    Require store owner role.

    Usage:
        @router.post("/team/invite")
        def invite_member(
            user: User = Depends(require_store_owner)
        ):
            # User verified to be store owner
            ...
    """
    store = request.state.store

    if not current_user.is_owner_of(store.id):
        raise StoreOwnerOnlyException(
            operation="team management",
            store_code=store.store_code
        )

    return current_user

def require_any_store_permission(*permissions: str):
    """
    Require ANY of the specified permissions.

    Usage:
        @router.get("/dashboard")
        def dashboard(
            user: User = Depends(require_any_store_permission(
                "dashboard.view",
                "reports.view"
            ))
        ):
            # User has at least one permission
            ...
    """

def require_all_store_permissions(*permissions: str):
    """
    Require ALL of the specified permissions.

    Usage:
        @router.post("/products/bulk-delete")
        def bulk_delete(
            user: User = Depends(require_all_store_permissions(
                "products.view",
                "products.delete"
            ))
        ):
            # User has all permissions
            ...
    """

def get_user_permissions(
    request: Request,
    current_user: User = Depends(get_current_store_from_cookie_or_header)
) -> list:
    """
    Get all permissions for current user.

    Returns list of permission strings.

    Usage:
        @router.get("/me/permissions")
        def my_permissions(
            permissions: list = Depends(get_user_permissions)
        ):
            return {"permissions": permissions}
    """

Model Helper Methods

User Model

# In models/database/user.py

class User(Base):
    # ... fields ...

    @property
    def is_super_admin(self) -> bool:
        """Check if user is super admin (computed from role)."""
        return self.role == "super_admin"

    @property
    def is_admin(self) -> bool:
        """Check if user is any type of platform admin."""
        return self.role in ("super_admin", "platform_admin")

    @property
    def is_platform_admin(self) -> bool:
        """Check if user is a (non-super) platform admin."""
        return self.role == "platform_admin"

    @property
    def is_merchant_owner(self) -> bool:
        """Check if user is a merchant owner."""
        return self.role == "merchant_owner"

    @property
    def is_store_user(self) -> bool:
        """Check if user is a store-level user (owner or member)."""
        return self.role in ("merchant_owner", "store_member")

    def is_owner_of(self, store_id: int) -> bool:
        """Check if user owns a specific store."""
        return any(v.id == store_id for v in self.owned_stores)

    def is_member_of(self, store_id: int) -> bool:
        """Check if user is member of store (owner or team)."""
        if self.is_owner_of(store_id):
            return True
        return any(
            vm.store_id == store_id and vm.is_active
            for vm in self.store_memberships
        )

    def get_store_role(self, store_id: int) -> str:
        """Get role name within specific store."""
        if self.is_owner_of(store_id):
            return "owner"

        for vm in self.store_memberships:
            if vm.store_id == store_id and vm.is_active:
                return vm.role.name if vm.role else "member"

        return None

    def has_store_permission(self, store_id: int, permission: str) -> bool:
        """Check if user has specific permission in store."""
        # Owners have all permissions
        if self.is_owner_of(store_id):
            return True

        # Check team member permissions
        for vm in self.store_memberships:
            if vm.store_id == store_id and vm.is_active:
                if vm.role and permission in vm.role.permissions:
                    return True

        return False

StoreUser Model

# In models/database/store.py

class StoreUser(Base):
    # ... fields ...

    @property
    def is_owner(self) -> bool:
        """Check if this user is the store owner.
        Determined by User.role == 'merchant_owner' and Merchant.owner_user_id,
        NOT by a column on StoreUser (user_type was removed).
        """
        return self.user.is_merchant_owner and self.user.is_owner_of(self.store_id)

    @property
    def is_team_member(self) -> bool:
        """Check if this is a team member (not owner)."""
        return not self.is_owner

    @property
    def is_invitation_pending(self) -> bool:
        """Check if invitation is pending acceptance."""
        return (
            self.invitation_token is not None and
            self.invitation_accepted_at is None
        )

    def has_permission(self, permission: str) -> bool:
        """Check if this membership has specific permission."""
        # Owners have all permissions
        if self.is_owner:
            return True

        # Inactive users have no permissions
        if not self.is_active:
            return False

        # Check role permissions
        if self.role and self.role.permissions:
            return permission in self.role.permissions

        return False

    def get_all_permissions(self) -> list:
        """Get all permissions for this membership."""
        if self.is_owner:
            from app.core.permissions import StorePermissions
            return [p.value for p in StorePermissions]

        if self.role and self.role.permissions:
            return self.role.permissions

        return []

Team Management

Invitation Flow

The system uses email-based invitations for team member onboarding.

Complete Flow Diagram

┌──────────────────────────────────────────────────────────────┐
│                    INVITATION WORKFLOW                        │
└──────────────────────────────────────────────────────────────┘

1. Owner initiates invitation
   └─> POST /api/v1/store/{code}/team/invite
       Body: { email, role }

2. System creates/updates records
   ├─> User record (if doesn't exist)
   │   - email: from invitation
   │   - username: auto-generated
   │   - role: "store_member"
   │   - is_active: FALSE
   │   - is_email_verified: FALSE
   │
   └─> StoreUser record
       - store_id: current store
       - user_id: from User
       - role_id: from role selection
       - invitation_token: secure random string
       - invitation_sent_at: now()
       - invited_by: current user
       - is_active: FALSE

3. Email sent to invitee
   └─> Contains: invitation link with token
       Link: /store/invitation/accept?token={invitation_token}

4. Invitee clicks link
   └─> GET /store/invitation/accept?token={token}
       Displays form: password, first_name, last_name

5. Invitee submits form
   └─> POST /api/v1/store/team/accept-invitation
       Body: { invitation_token, password, first_name, last_name }

6. System activates account
   ├─> User updates:
   │   - hashed_password: from form
   │   - first_name, last_name: from form
   │   - is_active: TRUE
   │   - is_email_verified: TRUE
   │
   └─> StoreUser updates:
       - is_active: TRUE
       - invitation_accepted_at: now()
       - invitation_token: NULL (cleared)

7. Member can now login
   └─> POST /api/v1/store/auth/login
       Redirect to store dashboard

Service Layer Implementation

Team management is handled by StoreTeamService in app/services/store_team_service.py.

Key Methods

class StoreTeamService:

    def invite_team_member(
        self,
        db: Session,
        store: Store,
        inviter: User,
        email: str,
        role_name: str,
        custom_permissions: Optional[List[str]] = None
    ) -> Dict[str, Any]:
        """
        Invite a new team member.

        Steps:
        1. Check team size limits
        2. Find or create User account
        3. Create or update StoreUser with invitation
        4. Generate secure invitation token
        5. Send invitation email

        Returns:
            {
                "invitation_token": str,
                "email": str,
                "role": str,
                "existing_user": bool
            }
        """

    def accept_invitation(
        self,
        db: Session,
        invitation_token: str,
        password: str,
        first_name: Optional[str] = None,
        last_name: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Accept team invitation and activate account.

        Steps:
        1. Validate invitation token
        2. Check token not expired (7 days)
        3. Update User (password, name, active status)
        4. Update StoreUser (active, accepted timestamp)
        5. Clear invitation token

        Returns:
            {
                "user": User,
                "store": Store,
                "role": str
            }
        """

    def remove_team_member(
        self,
        db: Session,
        store: Store,
        user_id: int
    ) -> bool:
        """
        Remove team member (soft delete).

        Cannot remove owner.
        Sets StoreUser.is_active = False
        """

    def update_member_role(
        self,
        db: Session,
        store: Store,
        user_id: int,
        new_role_name: str,
        custom_permissions: Optional[List[str]] = None
    ) -> StoreUser:
        """
        Update team member's role.

        Cannot change owner's role.
        Creates new role if custom permissions provided.
        """

    def get_team_members(
        self,
        db: Session,
        store: Store,
        include_inactive: bool = False
    ) -> List[Dict[str, Any]]:
        """
        Get all team members for a store.

        Returns list of member info including:
        - Basic user info
        - Role and permissions
        - Invitation status
        - Active status
        """

API Routes

Complete team management routes in app/api/v1/store/team.py.

router = APIRouter(prefix="/team")

# List team members
@router.get("/members")
def list_team_members(
    request: Request,
    user: User = Depends(require_store_permission("team.view"))
):
    """List all team members."""

# Invite team member (owner only)
@router.post("/invite")
def invite_team_member(
    invitation: InviteTeamMemberRequest,
    user: User = Depends(require_store_owner)
):
    """Send team invitation email."""

# Accept invitation (public, no auth)
@router.post("/accept-invitation")
def accept_invitation(
    acceptance: AcceptInvitationRequest
):
    """Accept invitation and activate account."""

# Remove team member (owner only)
@router.delete("/members/{user_id}")
def remove_team_member(
    user_id: int,
    user: User = Depends(require_store_owner)
):
    """Remove team member from store."""

# Update member role (owner only)
@router.put("/members/{user_id}/role")
def update_member_role(
    user_id: int,
    role_update: UpdateMemberRoleRequest,
    user: User = Depends(require_store_owner)
):
    """Change team member's role."""

# Get current user's permissions
@router.get("/me/permissions")
def get_my_permissions(
    permissions: list = Depends(get_user_permissions)
):
    """Get current user's permission list."""

Security Considerations

Owner Protection

Owners cannot be removed or have their role changed:

# In remove_team_member
if store_user.is_owner:
    raise CannotRemoveStoreOwnerException(store.store_code)

# In update_member_role
if store_user.is_owner:
    raise CannotRemoveStoreOwnerException(store.store_code)

Invitation Token Security

  • Tokens are 32-byte cryptographically secure random strings
  • Single-use (cleared after acceptance)
  • Expire after 7 days
  • Unique per invitation
def _generate_invitation_token(self) -> str:
    """Generate secure invitation token."""
    import secrets
    return secrets.token_urlsafe(32)

Admin Blocking

Platform admins are blocked from store routes:

# In store auth endpoint
if current_user.is_admin:  # role in ("super_admin", "platform_admin")
    raise InvalidCredentialsException(
        "Admins cannot access store portal"
    )

# In store dependencies
if current_user.is_admin:
    raise InsufficientPermissionsException(
        "Store access only"
    )

Code Examples

Example 1: Protected Route with Permission Check

from fastapi import APIRouter, Depends
from app.api.deps import require_store_permission
from app.core.permissions import StorePermissions
from models.database.user import User

router = APIRouter()

@router.post("/products")
def create_product(
    product_data: ProductCreate,
    user: User = Depends(require_store_permission(
        StorePermissions.PRODUCTS_CREATE.value
    ))
):
    """
    Create a new product.

    Requires: products.create permission

    The dependency automatically:
    1. Authenticates the user
    2. Gets store from request.state
    3. Checks user has products.create permission
    4. Returns User if authorized
    5. Raises InsufficientStorePermissionsException if not
    """
    store = request.state.store

    # User is authenticated and authorized
    # Proceed with business logic
    product = product_service.create(
        db=db,
        store_id=store.id,
        user_id=user.id,
        data=product_data
    )

    return {"product": product}

Example 2: Owner-Only Route

from app.api.deps import require_store_owner

@router.delete("/team/members/{member_id}")
def remove_team_member(
    member_id: int,
    user: User = Depends(require_store_owner)
):
    """
    Remove a team member.

    Requires: Store owner role

    The dependency automatically:
    1. Authenticates the user
    2. Checks user is owner of current store
    3. Returns User if owner
    4. Raises StoreOwnerOnlyException if not owner
    """
    store = request.state.store

    # User is verified owner
    store_team_service.remove_team_member(
        db=db,
        store=store,
        user_id=member_id
    )

    return {"message": "Member removed"}

Example 3: Multi-Permission Route

from app.api.deps import require_all_store_permissions

@router.post("/store/{code}/products/bulk-import")
def bulk_import_products(
    file: UploadFile,
    user: User = Depends(require_all_store_permissions(
        StorePermissions.PRODUCTS_VIEW.value,
        StorePermissions.PRODUCTS_CREATE.value,
        StorePermissions.PRODUCTS_IMPORT.value
    ))
):
    """
    Bulk import products from CSV.

    Requires ALL of:
    - products.view
    - products.create
    - products.import

    The dependency checks user has ALL specified permissions.
    """
    store = request.state.store

    # User has all required permissions
    result = import_service.process_csv(
        db=db,
        store_id=store.id,
        file=file
    )

    return {"imported": result.success_count}

Example 4: Frontend Permission Checking

// On login, fetch user's permissions
async function login(username, password) {
    const response = await fetch('/api/v1/store/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
    });

    const data = await response.json();

    // Store token
    localStorage.setItem('token', data.access_token);

    // Fetch permissions
    const permResponse = await fetch('/api/v1/store/team/me/permissions', {
        headers: {
            'Authorization': `Bearer ${data.access_token}`
        }
    });

    const { permissions } = await permResponse.json();

    // Store permissions
    localStorage.setItem('permissions', JSON.stringify(permissions));

    // Navigate
    window.location.href = '/store/dashboard';
}

// Check permission before showing UI element
function canCreateProducts() {
    const permissions = JSON.parse(localStorage.getItem('permissions') || '[]');
    return permissions.includes('products.create');
}

// In React/Alpine.js component
{canCreateProducts() && (
    <button onClick={handleCreateProduct}>
        Create Product
    </button>
)}

// Disable button if no permission
<button
    disabled={!canCreateProducts()}
    onClick={handleCreateProduct}
>
    Create Product
</button>

Best Practices

1. Route-Level Authorization

DO: Check permissions at route level using dependencies

@router.post("/products")
def create_product(
    data: ProductCreate,
    user: User = Depends(require_store_permission("products.create"))
):
    # Permission already verified
    return product_service.create(data)

DON'T: Check permissions in service layer

# BAD
def create_product(db: Session, user: User, data: ProductCreate):
    if not user.has_permission("products.create"):
        raise Exception("No permission")
    # ...

2. Use Type-Safe Permission Constants

DO: Use StorePermissions enum

from app.core.permissions import StorePermissions

require_store_permission(StorePermissions.PRODUCTS_CREATE.value)

DON'T: Use magic strings

# BAD - typos won't be caught
require_store_permission("products.creat")  # Typo!

3. Owner Permission Bypass

DO: Let owners bypass permission checks automatically

def has_permission(self, permission: str) -> bool:
    if self.is_owner:
        return True  # Owners have all permissions
    # Check role permissions...

DON'T: Explicitly check owner in every route

# BAD - redundant
if not user.is_owner and not user.has_permission("products.create"):
    raise Exception()

4. Service Layer Design

DO: Keep services authorization-agnostic

# Service assumes caller is authorized
def create_product(db: Session, store_id: int, data: dict) -> Product:
    product = Product(store_id=store_id, **data)
    db.add(product)
    db.commit()
    return product

DON'T: Mix authorization into services

# BAD
def create_product(db: Session, user: User, data: dict):
    if not user.is_active:
        raise Exception()
    # ...

5. Frontend Permission Checks

DO: Hide/disable UI elements without permission

// Hide button if no permission
{hasPermission('products.delete') && (
    <DeleteButton />
)}

// Disable button if no permission
<button disabled={!hasPermission('products.edit')}>
    Edit
</button>

DON'T: Rely only on frontend checks

Backend MUST always verify permissions. Frontend checks are for UX only.


Testing Guidelines

Unit Tests

Test permission logic in isolation.

# tests/unit/test_permissions.py

def test_owner_has_all_permissions():
    """Merchant owners have all permissions automatically."""
    user = create_user(role="merchant_owner")
    store = create_store(owner=user)
    store_user = create_store_user(
        user=user,
        store=store
    )

    assert store_user.has_permission("products.create")
    assert store_user.has_permission("orders.delete")
    assert store_user.has_permission("team.invite")
    # All permissions should return True

def test_team_member_respects_role():
    """Store members have only their role's permissions."""
    user = create_user(role="store_member")
    store = create_store()
    role = create_role(
        store=store,
        name="Staff",
        permissions=["products.view", "products.create"]
    )
    store_user = create_store_user(
        user=user,
        store=store,
        role=role
    )

    assert store_user.has_permission("products.view")
    assert store_user.has_permission("products.create")
    assert not store_user.has_permission("products.delete")
    assert not store_user.has_permission("team.invite")

def test_inactive_user_has_no_permissions():
    """Inactive users have no permissions."""
    user = create_user()
    store = create_store()
    role = create_role(
        store=store,
        permissions=["products.view"]
    )
    store_user = create_store_user(
        user=user,
        store=store,
        role=role,
        is_active=False  # Inactive
    )

    assert not store_user.has_permission("products.view")

Integration Tests

Test full request/response cycles with authentication.

# tests/integration/test_product_routes.py

def test_create_product_with_permission(client, auth_headers):
    """Authenticated user with permission can create product."""
    # Setup: Create user with products.create permission
    user = create_store_team_member(
        permissions=["products.create"]
    )
    token = create_auth_token(user)

    # Request
    response = client.post(
        "/api/v1/store/ACME/products",
        json={"name": "Test Product", "price": 9.99},
        headers={"Authorization": f"Bearer {token}"}
    )

    # Assert
    assert response.status_code == 201
    assert response.json()["name"] == "Test Product"

def test_create_product_without_permission(client):
    """User without permission cannot create product."""
    # Setup: Create user WITHOUT products.create
    user = create_store_team_member(
        permissions=["products.view"]  # Can view but not create
    )
    token = create_auth_token(user)

    # Request
    response = client.post(
        "/api/v1/store/ACME/products",
        json={"name": "Test Product"},
        headers={"Authorization": f"Bearer {token}"}
    )

    # Assert
    assert response.status_code == 403
    assert "INSUFFICIENT_STORE_PERMISSIONS" in response.json()["error_code"]

def test_owner_bypasses_permission_check(client):
    """Store owner can create products without explicit permission."""
    # Setup: Create owner (no specific role)
    user, store = create_store_with_owner()
    token = create_auth_token(user)

    # Request
    response = client.post(
        f"/api/v1/store/{store.store_code}/products",
        json={"name": "Test Product"},
        headers={"Authorization": f"Bearer {token}"}
    )

    # Assert - Owner can create even without explicit permission
    assert response.status_code == 201

def test_admin_blocked_from_store_route(client):
    """Admins cannot access store routes."""
    # Setup: Create admin user
    admin = create_admin_user()
    token = create_auth_token(admin)

    # Request
    response = client.get(
        "/api/v1/store/ACME/products",
        headers={"Authorization": f"Bearer {token}"}
    )

    # Assert
    assert response.status_code == 403
    assert "INSUFFICIENT_PERMISSIONS" in response.json()["error_code"]

Troubleshooting

Common Issues

Issue: "INVALID_TOKEN" error

Symptoms:

  • API calls return 401 Unauthorized
  • Error message: "Invalid token"

Causes:

  1. Token expired (default 30 minutes)
  2. Token malformed
  3. Token signature invalid

Solutions:

# Check token expiry
import jwt
token = "eyJ0eXAi..."
decoded = jwt.decode(token, verify=False)
print(decoded['exp'])  # Unix timestamp

# Compare with current time
import time
if decoded['exp'] < time.time():
    print("Token expired - user needs to re-login")

# Verify token manually
from middleware.auth import AuthManager
auth = AuthManager()
try:
    user_data = auth.verify_token(token)
    print("Token valid")
except Exception as e:
    print(f"Token invalid: {e}")

Issue: User can't access route despite having permission

Symptoms:

  • Route returns 403 Forbidden
  • User believes they have required permission

Debug Steps:

# 1. Check user's actual permissions
user = db.query(User).get(user_id)
store = db.query(Store).get(store_id)

print(f"Is owner? {user.is_owner_of(store.id)}")

if not user.is_owner_of(store.id):
    # Get team membership
    store_user = db.query(StoreUser).filter_by(
        user_id=user.id,
        store_id=store.id
    ).first()

    print(f"Has membership? {store_user is not None}")
    print(f"Is active? {store_user.is_active if store_user else 'N/A'}")
    print(f"Role: {store_user.role.name if store_user and store_user.role else 'N/A'}")
    print(f"Permissions: {store_user.role.permissions if store_user and store_user.role else []}")

# 2. Check specific permission
permission = "products.create"
has_perm = user.has_store_permission(store.id, permission)
print(f"Has {permission}? {has_perm}")

Issue: Admin can't access store routes

Symptoms:

  • Admin user gets 403 on store routes

This is intentional! Admins are blocked from store routes for security.

Solutions:

  1. Create separate store account for store management
  2. Have admin create store, then use store owner account
# Admin workflow
# 1. Admin creates store (from admin portal)
# 2. System creates store owner user automatically
# 3. Admin logs out of admin portal
# 4. Store owner logs into store portal

Quick Reference

See RBAC Quick Reference for a condensed cheat sheet of common imports, route patterns, and permission constants.



Document Version: 2.0 Last Updated: February 2026 Maintained By: Backend Team