Files
orion/docs/api/rbac.md
Samir Boulahtit fb8cb14506 refactor: rename public routes and templates to platform
Complete the public -> platform naming migration across the codebase.
This aligns with the naming convention where "platform" refers to
the marketing/public-facing pages of the platform itself.

Changes:
- Update all imports from public to platform modules
- Update template references from public/ to platform/
- Update route registrations to use platform prefix
- Update documentation to reflect new naming
- Update test files for platform API endpoints

Files affected:
- app/api/main.py - router imports
- app/modules/*/routes/*/platform.py - route definitions
- app/modules/*/templates/*/platform/ - template files
- app/modules/routes.py - route discovery
- docs/* - documentation updates
- tests/integration/api/v1/platform/ - test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:49:39 +01:00

56 KiB

Role-Based Access Control (RBAC) Developer Guide

Version: 1.0 Last Updated: November 2025 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, Vendor 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, vendor, and customer contexts are completely isolated
  2. Least Privilege - Users have only the permissions they need
  3. Owner Authority - Vendor owners have complete control over their vendor
  4. Team Flexibility - Vendor teams can be structured with various role types
  5. Security First - Cookie path isolation and role enforcement prevent unauthorized access

RBAC Overview

Three-Tier Permission Model

┌─────────────────────────────────────────────────────────┐
│                   PLATFORM LEVEL                         │
│                   User.role                              │
│                                                           │
│  ┌──────────┐                  ┌──────────┐            │
│  │  Admin   │                  │  Vendor  │            │
│  │ (admin)  │                  │ (vendor) │            │
│  └──────────┘                  └──────────┘            │
└─────────────────────────────────────────────────────────┘
                                        │
                                        ▼
┌─────────────────────────────────────────────────────────┐
│                   VENDOR LEVEL                           │
│                VendorUser.user_type                      │
│                                                           │
│  ┌──────────┐                  ┌──────────────┐        │
│  │  Owner   │                  │ Team Member  │        │
│  │ (owner)  │                  │  (member)    │        │
│  └──────────┘                  └──────────────┘        │
└─────────────────────────────────────────────────────────┘
                                        │
                                        ▼
┌─────────────────────────────────────────────────────────┐
│                 PERMISSION LEVEL                         │
│                  Role.permissions                        │
│                                                           │
│  Manager, Staff, Support, Viewer, Marketing, Custom     │
└─────────────────────────────────────────────────────────┘

Context Separation

The application operates in three isolated contexts:

Context Routes Authentication User Type
Admin /admin/* admin_token cookie Platform Admins
Vendor /vendor/* vendor_token cookie Vendor Owners & Teams
Shop /shop/account/* customer_token cookie Customers

Important: These contexts are security boundaries. Admin users cannot access vendor routes, vendor users cannot access admin routes, and customers are entirely separate.


System Architecture

High-Level Architecture

┌─────────────────────────────────────────────────────────────┐
│                        Request                               │
└────────────────┬────────────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                  Middleware Layer                            │
│                                                              │
│  • VendorContextMiddleware                                  │
│  • VendorDetectionMiddleware                                │
│  • AuthenticationMiddleware                                 │
└────────────────┬────────────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│              FastAPI Route Handler                           │
│                                                              │
│  Dependencies:                                               │
│  • get_current_admin_from_cookie_or_header()                │
│  • get_current_vendor_from_cookie_or_header()               │
│  • require_vendor_permission("permission.name")             │
│  • require_vendor_owner()                                   │
└────────────────┬────────────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                    Service Layer                             │
│                                                              │
│  • vendor_team_service                                      │
│  • auth_service                                             │
│  • customer_service                                         │
└────────────────┬────────────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                   Database Layer                             │
│                                                              │
│  • User, VendorUser, 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 vendor 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

Platform Admins

Characteristics:

  • User.role = "admin"
  • Full access to /admin/* routes
  • Manage all vendors and users
  • Cannot access vendor or customer portals

Use Cases:

  • Platform configuration
  • Vendor approval/verification
  • 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/vendors
POST /admin/users/{user_id}/suspend

Vendor Owners

Characteristics:

  • User.role = "vendor"
  • VendorUser.user_type = "owner"
  • Automatic full permissions within their vendor
  • Can invite and manage team members
  • Cannot be removed from their vendor

Use Cases:

  • Complete vendor management
  • Team administration
  • Financial oversight
  • Settings configuration

Special Privileges:

# Automatic permissions
def has_permission(self, permission: str) -> bool:
    if self.is_owner:
        return True  # Owners bypass permission checks

Vendor Team Members

Characteristics:

  • User.role = "vendor"
  • VendorUser.user_type = "member"
  • Permissions defined by Role.permissions
  • Invited by vendor owner via email
  • Can be assigned different roles (Manager, Staff, etc.)

Use Cases:

  • Day-to-day operations based on role
  • Collaborative vendor 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)
  • Vendor-scoped authentication
  • Can self-register on vendor shops
  • Access only their own account + shop catalog

Use Cases:

  • Browse vendor 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 vendor portals.


Database Schema

Entity Relationship Diagram

┌──────────────────┐
│      users       │
│                  │
│  id (PK)         │◄──────┐
│  email           │       │
│  username        │       │
│  role            │       │  owner_user_id
│  ('admin' |      │       │
│   'vendor')      │       │
│  is_active       │       │
│  is_email_       │       │
│  verified        │       │
└──────────────────┘       │
        │                  │
        │                  │
        │                  │
        ▼                  │
┌──────────────────┐       │
│  vendor_users    │       │
│                  │       │
│  id (PK)         │       │
│  vendor_id (FK) ─┼───┐   │
│  user_id (FK) ───┼─┐ │   │
│  role_id (FK)    │ │ │   │
│  user_type       │ │ │   │
│  ('owner' |      │ │ │   │
│   'member')      │ │ │   │
│  invitation_     │ │ │   │
│  token           │ │ │   │
│  invitation_     │ │ │   │
│  sent_at         │ │ │   │
│  invitation_     │ │ │   │
│  accepted_at     │ │ │   │
│  invited_by (FK) │ │ │   │
│  is_active       │ │ │   │
└──────────────────┘ │ │   │
        │            │ │   │
        │ role_id    │ │   │
        │            │ │   │
        ▼            │ │   │
┌──────────────────┐ │ │   │
│      roles       │ │ │   │
│                  │ │ │   │
│  id (PK)         │ │ │   │
│  vendor_id (FK) ─┼─┘ │   │
│  name            │   │   │
│  permissions     │   │   │
│  (JSONB)         │   │   │
└──────────────────┘   │   │
                       │   │
                       ▼   │
┌──────────────────┐       │
│     vendors      │       │
│                  │       │
│  id (PK)         │       │
│  vendor_code     │       │
│  subdomain       │       │
│  name            │       │
│  owner_user_id ──┼───────┘
│  is_active       │
│  is_verified     │
└──────────────────┘
        │
        │
        ▼
┌──────────────────┐
│    customers     │
│ (SEPARATE AUTH)  │
│                  │
│  id (PK)         │
│  vendor_id (FK)  │
│  email           │
│  hashed_password │
│  customer_number │
│  is_active       │
└──────────────────┘

Key Tables

users

Primary platform user table for admins and vendors.

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)  # 'admin' or 'vendor'
    is_active = Column(Boolean, default=True)
    is_email_verified = Column(Boolean, default=False)

Important Fields:

  • role: Only contains "admin" or "vendor" (platform-level role)
  • is_email_verified: Required for team member invitations

vendors

Vendor entities representing businesses on the platform.

class Vendor(Base):
    __tablename__ = "vendors"

    id = Column(Integer, primary_key=True)
    vendor_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 vendor (full permissions)
  • vendor_code: Used in URLs for vendor context
  • subdomain: For subdomain-based routing

vendor_users

Junction table linking users to vendors with role information.

class VendorUser(Base):
    __tablename__ = "vendor_users"

    id = Column(Integer, primary_key=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    user_type = Column(String, nullable=False)  # 'owner' or 'member'
    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:

  • user_type: Distinguishes owners ("owner") from team members ("member")
  • role_id: NULL for owners (they have all permissions), set for team members
  • invitation_*: Fields for tracking invitation workflow
  • is_active: FALSE until invitation accepted (for team members)

roles

Vendor-specific role definitions with permissions.

class Role(Base):
    __tablename__ = "roles"

    id = Column(Integer, primary_key=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
    name = Column(String, nullable=False)
    permissions = Column(JSONB, default=[])  # PostgreSQL JSONB

Important Fields:

  • vendor_id: Roles are vendor-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 (vendor-scoped).

class Customer(Base):
    __tablename__ = "customers"

    id = Column(Integer, primary_key=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
    email = Column(String, nullable=False)  # Unique within vendor
    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 vendors.


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 vendor 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 VendorPermissions(str, Enum):
    """All available vendor 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 VendorPermissions)

    # 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(
    vendor_id=vendor.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 role == "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              │
└─────────────┘

Vendor Authentication

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ POST /api/v1/vendor/auth/login
       │ { username, password }
       ▼
┌─────────────────────────────┐
│  Vendor Auth Endpoint       │
│                             │
│  1. Validate credentials    │
│  2. Block if admin          │
│  3. Find vendor membership  │
│  4. Get role (owner/member) │
│  5. Generate JWT            │
└──────┬──────────────────────┘
       │
       │ Set-Cookie: vendor_token=<JWT>
       │             Path=/vendor
       │             HttpOnly=true
       │             Secure=true (prod)
       │             SameSite=Lax
       │
       │ Response: { access_token, user, vendor, role }
       ▼
┌─────────────┐
│   Client    │
│             │
│ 🍪 vendor_token (path=/vendor)    │
│ 💾 localStorage.token              │
└─────────────┘

Customer Authentication

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

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

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

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

Why This Matters:

  • Admin cookies are never sent to vendor routes
  • Vendor cookies are never sent to admin routes
  • Customer cookies are never sent to admin/vendor 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/vendor/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 = '/vendor/ACME/dashboard';

// API call - use stored token
fetch('/api/v1/vendor/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 role == "admin"

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

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

    Checks:
    1. vendor_token cookie (path=/vendor)
    2. Authorization: Bearer <token> header
    3. Blocks admin users
    4. Validates vendor membership

    Use for: Vendor HTML pages
    """
    # Implementation checks cookie first, then header
    # Returns User object if authenticated as vendor
    # 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_vendor_api(
    request: Request,
    db: Session = Depends(get_db)
) -> User:
    """
    Get current vendor from Authorization header only.

    Use for: Vendor API endpoints
    """

Permission-Based Dependencies

from app.core.permissions import VendorPermissions

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

    Usage:
        @router.post("/products")
        def create_product(
            user: User = Depends(require_vendor_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_vendor_from_cookie_or_header)
    ) -> User:
        vendor = request.state.vendor  # Set by middleware

        if not current_user.has_vendor_permission(vendor.id, permission):
            raise InsufficientVendorPermissionsException(
                required_permission=permission,
                vendor_code=vendor.vendor_code
            )

        return current_user

    return permission_checker

def require_vendor_owner(
    request: Request,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_vendor_from_cookie_or_header)
) -> User:
    """
    Require vendor owner role.

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

    if not current_user.is_owner_of(vendor.id):
        raise VendorOwnerOnlyException(
            operation="team management",
            vendor_code=vendor.vendor_code
        )

    return current_user

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

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

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

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

def get_user_permissions(
    request: Request,
    current_user: User = Depends(get_current_vendor_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_admin(self) -> bool:
        """Check if user is platform admin."""
        return self.role == "admin"

    @property
    def is_vendor(self) -> bool:
        """Check if user is vendor."""
        return self.role == "vendor"

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

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

    def get_vendor_role(self, vendor_id: int) -> str:
        """Get role name within specific vendor."""
        if self.is_owner_of(vendor_id):
            return "owner"

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

        return None

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

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

        return False

VendorUser Model

# In models/database/vendor.py

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

    @property
    def is_owner(self) -> bool:
        """Check if this is an owner membership."""
        return self.user_type == "owner"

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

    @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 VendorPermissions
            return [p.value for p in VendorPermissions]

        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/vendor/{code}/team/invite
       Body: { email, role }

2. System creates/updates records
   ├─> User record (if doesn't exist)
   │   - email: from invitation
   │   - username: auto-generated
   │   - role: "vendor"
   │   - is_active: FALSE
   │   - is_email_verified: FALSE
   │
   └─> VendorUser record
       - vendor_id: current vendor
       - user_id: from User
       - user_type: "member"
       - 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: /vendor/invitation/accept?token={invitation_token}

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

5. Invitee submits form
   └─> POST /api/v1/vendor/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
   │
   └─> VendorUser updates:
       - is_active: TRUE
       - invitation_accepted_at: now()
       - invitation_token: NULL (cleared)

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

Service Layer Implementation

Team management is handled by VendorTeamService in app/services/vendor_team_service.py.

Key Methods

class VendorTeamService:

    def invite_team_member(
        self,
        db: Session,
        vendor: Vendor,
        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 VendorUser 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 VendorUser (active, accepted timestamp)
        5. Clear invitation token

        Returns:
            {
                "user": User,
                "vendor": Vendor,
                "role": str
            }
        """

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

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

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

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

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

        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/vendor/team.py.

router = APIRouter(prefix="/team")

# List team members
@router.get("/members")
def list_team_members(
    request: Request,
    user: User = Depends(require_vendor_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_vendor_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_vendor_owner)
):
    """Remove team member from vendor."""

# 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_vendor_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 vendor_user.is_owner:
    raise CannotRemoveVendorOwnerException(vendor.vendor_code)

# In update_member_role
if vendor_user.is_owner:
    raise CannotRemoveVendorOwnerException(vendor.vendor_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

Admins are blocked from vendor routes:

# In vendor auth endpoint
if user.role == "admin":
    raise InvalidCredentialsException(
        "Admins cannot access vendor portal"
    )

# In vendor dependencies
if current_user.role == "admin":
    raise InsufficientPermissionsException(
        "Vendor access only"
    )

Code Examples

Example 1: Protected Route with Permission Check

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

router = APIRouter()

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

    Requires: products.create permission

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

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

    return {"product": product}

Example 2: Owner-Only Route

from app.api.deps import require_vendor_owner

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

    Requires: Vendor owner role

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

    # User is verified owner
    vendor_team_service.remove_team_member(
        db=db,
        vendor=vendor,
        user_id=member_id
    )

    return {"message": "Member removed"}

Example 3: Multi-Permission Route

from app.api.deps import require_all_vendor_permissions

@router.post("/vendor/{code}/products/bulk-import")
def bulk_import_products(
    file: UploadFile,
    user: User = Depends(require_all_vendor_permissions(
        VendorPermissions.PRODUCTS_VIEW.value,
        VendorPermissions.PRODUCTS_CREATE.value,
        VendorPermissions.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.
    """
    vendor = request.state.vendor

    # User has all required permissions
    result = import_service.process_csv(
        db=db,
        vendor_id=vendor.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/vendor/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/vendor/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 = '/vendor/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_vendor_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 VendorPermissions enum

from app.core.permissions import VendorPermissions

require_vendor_permission(VendorPermissions.PRODUCTS_CREATE.value)

DON'T: Use magic strings

# BAD - typos won't be caught
require_vendor_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, vendor_id: int, data: dict) -> Product:
    product = Product(vendor_id=vendor_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():
    """Owners have all permissions automatically."""
    user = create_user()
    vendor = create_vendor(owner=user)
    vendor_user = create_vendor_user(
        user=user,
        vendor=vendor,
        user_type="owner"
    )

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

def test_team_member_respects_role():
    """Team members have only their role's permissions."""
    user = create_user()
    vendor = create_vendor()
    role = create_role(
        vendor=vendor,
        name="Staff",
        permissions=["products.view", "products.create"]
    )
    vendor_user = create_vendor_user(
        user=user,
        vendor=vendor,
        user_type="member",
        role=role
    )

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

def test_inactive_user_has_no_permissions():
    """Inactive users have no permissions."""
    user = create_user()
    vendor = create_vendor()
    role = create_role(
        vendor=vendor,
        permissions=["products.view"]
    )
    vendor_user = create_vendor_user(
        user=user,
        vendor=vendor,
        role=role,
        is_active=False  # Inactive
    )

    assert not vendor_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_vendor_team_member(
        permissions=["products.create"]
    )
    token = create_auth_token(user)

    # Request
    response = client.post(
        "/api/v1/vendor/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_vendor_team_member(
        permissions=["products.view"]  # Can view but not create
    )
    token = create_auth_token(user)

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

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

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

    # Request
    response = client.post(
        f"/api/v1/vendor/{vendor.vendor_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_vendor_route(client):
    """Admins cannot access vendor routes."""
    # Setup: Create admin user
    admin = create_admin_user()
    token = create_auth_token(admin)

    # Request
    response = client.get(
        "/api/v1/vendor/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)
vendor = db.query(Vendor).get(vendor_id)

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

if not user.is_owner_of(vendor.id):
    # Get team membership
    vendor_user = db.query(VendorUser).filter_by(
        user_id=user.id,
        vendor_id=vendor.id
    ).first()

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

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

Issue: Admin can't access vendor routes

Symptoms:

  • Admin user gets 403 on vendor routes

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

Solutions:

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

Quick Reference

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



Document Version: 1.0 Last Updated: November 2025 Maintained By: Backend Team