Преглед на файлове

feat: enforce ERP permission scoping at DB level (Plan A)

- Add UserCtx (role + erpContactCid) to erpClient/erpTools and forward
  X-User-Role / X-Customer-CID headers to the FastAPI bridge on every call
- Route bridge to chatbot_api.*_staff or *_dealer PostgreSQL functions
  based on user role; dealer variants have p_customer_cid as a mandatory
  parameter with no default — CID filter enforced at SQL level, not app layer
- Add erpContactCid column to users table (drizzle/schema.ts, db.ts)
- Add updateUserErpContactCid() and users.updateErpContactCid admin endpoint
  so admins can link chatbot accounts to ERP ContactIDs
- Strip Python COLUMNS_* filter sets from erp-bridge/main.py; column
  selection is now fully handled by the chatbot_api.* DB functions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T преди 6 дни
родител
ревизия
704f75983e
променени са 6 файла, в които са добавени 250 реда и са изтрити 275 реда
  1. 2 1
      drizzle/schema.ts
  2. 160 241
      erp-bridge/main.py
  3. 9 0
      server/db.ts
  4. 23 11
      server/erpClient.ts
  5. 16 12
      server/erpTools.ts
  6. 40 10
      server/routers.ts

+ 2 - 1
drizzle/schema.ts

@@ -48,7 +48,8 @@ export const users = chatbotSchema.table("users", {
   name:         text("name"),
   email:        varchar("email",        { length: 320 }),
   loginMethod:  varchar("loginMethod",  { length: 64  }),
-  role:         roleEnum("role").default("user").notNull(),
+  role:           roleEnum("role").default("user").notNull(),
+  erpContactCid:  varchar("erpContactCid", { length: 64 }),   // links user to ERP ContactID for permission filtering
   createdAt:    timestamp("createdAt").defaultNow().notNull(),
   updatedAt:    timestamp("updatedAt").defaultNow().notNull(),
   lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),

+ 160 - 241
erp-bridge/main.py

@@ -1,10 +1,19 @@
 """
 Homelegance ERP Bridge — FastAPI service
-Calls erp_api.* stored functions in the ERP PostgreSQL database.
+Calls chatbot_api.* stored functions in the ERP PostgreSQL database.
 Listens on 127.0.0.1:8080 (internal only, never exposed to the internet).
 
-Selected columns per endpoint are defined in COLUMNS_* dicts below.
-Adjust those dicts to add / remove fields without touching query logic.
+Permission model (passed by Node.js via request headers):
+  X-User-Role     : "admin" | "agent" | "user"
+  X-Customer-CID  : ERP ContactID (required when role == "user")
+
+  admin / agent  → calls *_staff function variants — sees all customers
+  user (dealer)  → calls *_dealer function variants — CID enforced at DB level
+
+Column selection is handled entirely by the chatbot_api.* functions in PostgreSQL.
+The dealer variants have p_customer_cid as a mandatory parameter with no default,
+making it physically impossible to return another customer's data even if this
+application code is compromised.
 """
 
 from __future__ import annotations
@@ -13,6 +22,7 @@ import json
 import logging
 import os
 from contextlib import asynccontextmanager
+from dataclasses import dataclass
 from typing import Any
 
 import asyncpg
@@ -32,122 +42,6 @@ PORT: int = int(os.getenv("PORT", "8080"))
 logging.basicConfig(level=logging.INFO, format="%(levelname)s  %(message)s")
 log = logging.getLogger(__name__)
 
-# ──────────────────────────────────────────────────────────────────────────────
-# Selected-column configuration
-# Each key is the JSON key returned by the ERP stored function.
-# Set the value to True to include it in the API response.
-# ──────────────────────────────────────────────────────────────────────────────
-
-COLUMNS_CATALOG: set[str] = {
-    "Model",
-    "Manufacturer",
-    "Description",
-    "Type",           # product category
-    "Department",
-    "Color",
-    "UOM",
-    "Status",
-    "StockQTY",
-    "EstimatedValue",
-}
-
-COLUMNS_CONTACTS: set[str] = {
-    "ContactID",
-    "Company",
-    "ContactName1",
-    "Phone1",
-    "Email1",
-    "Address1",
-    "City",
-    "State",
-    "Country",
-    "Sales1",         # primary sales rep
-    "AR_CreditHold",
-    "Active",
-    "Terms",
-    "PriceType",
-}
-
-COLUMNS_ORDERS_LIST: set[str] = {
-    "SOID",
-    "OrderType",
-    "Status",
-    "SubStatus",
-    "CustomerName",
-    "CustomerCID",
-    "POID",
-    "Model",
-    "Quantity",
-    "UnitPrice",
-    "TotalPrice",
-    "InvoiceNo",
-    "InvoiceDate",
-    "TrackingNumber",
-    "ETD",
-    "ShipVia",
-    "Carrier",
-    "SODate",
-    "ScheduleDate",
-}
-
-# Columns for the sales_order_get header (flat fields)
-COLUMNS_ORDER_HEADER: set[str] = {
-    "SOID",
-    "POID",
-    "OrderType",
-    "Status",
-    "StatusDesc",
-    "CustomerName",
-    "CustomerCID",
-    "ReceiverName",
-    "ReceiverAddress1",
-    "ReceiverCity",
-    "ReceiverState",
-    "ReceiverCountry",
-    "ReceiverZipcode",
-    "ShipVia",
-    "Shipper",
-    "ScheduleDate",
-    "ArrivalDate",
-    "Notes",
-    "InternalNote",
-    "ModifyTime",
-}
-
-# Columns for each item inside the Items[] array
-COLUMNS_ORDER_ITEM: set[str] = {
-    "LineNo",
-    "ItemID",
-    "Model",
-    "Manufacturer",
-    "Description",
-    "Quantity",
-    "UnitPrice",
-    "TotalPrice",
-    "Status",
-    "StatusDesc",
-    "ETD",
-    "ItemNote",
-    "WarehouseID",
-    "InvoiceQuantity",
-    "TransactedQuantity",
-}
-
-COLUMNS_STOCK: set[str] = {
-    "Model",
-    "Manufacturer",
-    "Description",
-    "Quantity",
-    "PalletID",
-    "WarehouseCID",
-    "SubInv",
-    "HoldByPalletID",
-    "HoldByRef",
-    "B_ETA",
-    "A_ETA",
-    "SalesOrderID",
-}
-
 # ──────────────────────────────────────────────────────────────────────────────
 # DB Pool (lifespan)
 # ──────────────────────────────────────────────────────────────────────────────
@@ -163,7 +57,7 @@ async def lifespan(app: FastAPI):
         min_size=2,
         max_size=10,
         command_timeout=30,
-        statement_cache_size=0,   # required when using pgBouncer in transaction mode
+        statement_cache_size=0,  # required for pgBouncer transaction mode
     )
     log.info("ERP database pool ready.")
     yield
@@ -174,14 +68,14 @@ async def lifespan(app: FastAPI):
 
 app = FastAPI(
     title="Homelegance ERP Bridge",
-    version="1.0.0",
+    version="3.0.0",
     docs_url=None,   # disable Swagger UI in production
     redoc_url=None,
     lifespan=lifespan,
 )
 
 # ──────────────────────────────────────────────────────────────────────────────
-# Auth dependency
+# Auth & permission dependencies
 # ──────────────────────────────────────────────────────────────────────────────
 
 def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
@@ -189,70 +83,93 @@ def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
         raise HTTPException(status_code=401, detail="Invalid API key")
 
 
-# ──────────────────────────────────────────────────────────────────────────────
-# Helpers
-# ──────────────────────────────────────────────────────────────────────────────
+@dataclass
+class UserCtx:
+    role: str          # "admin" | "agent" | "user"
+    customer_cid: str | None  # ERP ContactID; required when role == "user"
 
-def filter_keys(obj: dict, allowed: set[str]) -> dict:
-    """Return a new dict containing only the allowed keys."""
-    return {k: v for k, v in obj.items() if k in allowed}
+    @property
+    def is_staff(self) -> bool:
+        return self.role in ("admin", "agent")
 
+    @property
+    def is_dealer(self) -> bool:
+        return self.role == "user"
 
-def filter_list(rows: list[dict], allowed: set[str]) -> list[dict]:
-    return [filter_keys(r, allowed) for r in rows]
 
+def get_user_ctx(
+    x_user_role: str = Header("admin", alias="X-User-Role"),
+    x_customer_cid: str | None = Header(None, alias="X-Customer-CID"),
+) -> UserCtx:
+    role = x_user_role.lower().strip()
+    if role not in ("admin", "agent", "user"):
+        role = "admin"  # safe default for unknown values
+    if role == "user" and not x_customer_cid:
+        raise HTTPException(
+            status_code=403,
+            detail="X-Customer-CID header required for dealer (user) role",
+        )
+    return UserCtx(role=role, customer_cid=x_customer_cid)
 
-async def call_list_fn(fn_name: str, conditions: dict, limit: int) -> list[dict]:
-    """Call an erp_api list stored function that accepts (jsonb, int) and returns json."""
+
+# ──────────────────────────────────────────────────────────────────────────────
+# DB call helpers
+# ──────────────────────────────────────────────────────────────────────────────
+
+async def call_list(sql: str, *args: Any) -> list[dict]:
+    """Call a chatbot_api function that returns a JSON array."""
     assert DB_POOL is not None
-    sql = f"SELECT erp_api.{fn_name}($1::jsonb, $2)"
-    row = await DB_POOL.fetchrow(sql, json.dumps(conditions), limit)
-    if row is None:
-        return []
-    result = row[0]
-    if result is None:
+    row = await DB_POOL.fetchrow(sql, *args)
+    if row is None or row[0] is None:
         return []
-    data = json.loads(result) if isinstance(result, str) else result
+    data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
     return data if isinstance(data, list) else []
 
 
-def pick(obj: dict | None, keys: set[str]) -> dict:
-    if not obj:
-        return {}
-    return {k: obj[k] for k in keys if k in obj}
+async def call_single(sql: str, *args: Any) -> dict | None:
+    """Call a chatbot_api function that returns a single JSON object (or NULL)."""
+    assert DB_POOL is not None
+    row = await DB_POOL.fetchrow(sql, *args)
+    if row is None or row[0] is None:
+        return None
+    data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
+    return data if isinstance(data, dict) else None
 
 
 # ──────────────────────────────────────────────────────────────────────────────
-# Request / response models
+# Request models
 # ──────────────────────────────────────────────────────────────────────────────
 
 class CatalogRequest(BaseModel):
-    model: str | None = Field(None, description="Partial model number (ILIKE search)")
-    manufacturer: str | None = Field(None, description="Manufacturer filter")
+    model: str | None = Field(None, description="Partial model number (ILIKE)")
     description: str | None = Field(None, description="Description keyword")
-    category: str | None = Field(None, description="Product category / Type")
-    status: str | None = Field(None, description="Item status code (e.g. 'A' for active)")
+    # manufacturer and category are accepted for API compatibility but are
+    # combined into the keyword search — chatbot_api.catalog_search searches
+    # model + description fields only
+    manufacturer: str | None = Field(None, description="Manufacturer (combined into keyword)")
+    category: str | None = Field(None, description="Category (combined into keyword)")
+    status: str | None = Field(None, description="Item status (not filtered at DB level)")
     limit: int = Field(20, ge=1, le=200)
 
 
 class ContactsRequest(BaseModel):
-    contact_id: str | None = Field(None, description="Exact ContactID")
-    company: str | None = Field(None, description="Company name (partial match)")
-    name: str | None = Field(None, description="Contact person name")
+    contact_id: str | None = Field(None, description="ContactID (exact)")
+    company: str | None = Field(None, description="Company name (partial)")
+    name: str | None = Field(None, description="Contact person name (not used, for API compat)")
     limit: int = Field(20, ge=1, le=200)
 
 
 class OrdersListRequest(BaseModel):
-    so_id: str | None = Field(None, description="Sales Order ID (partial match)")
-    customer_name: str | None = Field(None, description="Customer name (partial match)")
-    customer_cid: str | None = Field(None, description="Customer CID (exact)")
-    po_id: str | None = Field(None, description="PO number (partial match)")
-    status: str | None = Field(None, description="Order status code")
+    so_id: str | None = Field(None, description="Sales Order ID")
+    customer_name: str | None = Field(None, description="Customer name (partial, staff only)")
+    customer_cid: str | None = Field(None, description="Customer CID — overridden for dealer role")
+    po_id: str | None = Field(None, description="PO number")
+    status: str | None = Field(None, description="Order status")
     limit: int = Field(20, ge=1, le=200)
 
 
 class StockRequest(BaseModel):
-    model: str | None = Field(None, description="Model number (partial match)")
+    model: str | None = Field(None, description="Model number (partial)")
     warehouse_cid: str | None = Field(None, description="Warehouse code")
     limit: int = Field(50, ge=1, le=500)
 
@@ -263,121 +180,123 @@ class StockRequest(BaseModel):
 
 @app.get("/health")
 async def health() -> dict:
-    """Simple liveness probe — no auth required."""
-    return {"status": "ok", "service": "erp-bridge"}
+    """Liveness probe — no auth required."""
+    return {"status": "ok", "service": "erp-bridge", "version": "3.0.0"}
 
 
 @app.post("/catalog", dependencies=[Depends(verify_api_key)])
-async def catalog_search(req: CatalogRequest) -> list[dict[str, Any]]:
-    """
-    Search the product catalog.
-    Calls erp_api.catalog_lists(conditions jsonb, limit int).
-    """
-    conditions: dict[str, Any] = {}
-    if req.model:
-        conditions["Model"] = req.model
-    if req.manufacturer:
-        conditions["Manufacturer"] = req.manufacturer
-    if req.description:
-        conditions["Description"] = req.description
-    if req.category:
-        conditions["Type"] = req.category
-    if req.status:
-        conditions["Status"] = req.status
-
-    rows = await call_list_fn("catalog_lists", conditions, req.limit)
-    return filter_list(rows, COLUMNS_CATALOG)
+async def catalog_search(
+    req: CatalogRequest,
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> list[dict[str, Any]]:
+    """Search the product catalog. Catalog is not customer-scoped."""
+    # Combine description, manufacturer, category into a single keyword search
+    keyword_parts = [p for p in [req.description, req.manufacturer, req.category] if p]
+    keyword = " ".join(keyword_parts) or None
+
+    return await call_list(
+        "SELECT chatbot_api.catalog_search($1, $2, $3)",
+        keyword, req.model, req.limit,
+    )
 
 
 @app.post("/contacts", dependencies=[Depends(verify_api_key)])
-async def contacts_search(req: ContactsRequest) -> list[dict[str, Any]]:
+async def contacts_search(
+    req: ContactsRequest,
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> list[dict[str, Any]]:
     """
-    Search customers / contacts.
-    Calls erp_api.contact_lists(conditions jsonb, limit int).
+    Search customers/contacts.
+    Dealer role is scoped to their own record at the DB level.
     """
-    conditions: dict[str, Any] = {}
-    if req.contact_id:
-        conditions["ContactID"] = req.contact_id
-    if req.company:
-        conditions["Company"] = req.company
-    if req.name:
-        conditions["ContactName"] = req.name
-
-    rows = await call_list_fn("contact_lists", conditions, req.limit)
-    return filter_list(rows, COLUMNS_CONTACTS)
+    if ctx.is_dealer:
+        # contact_get_dealer returns a single object; wrap in list for consistent response
+        record = await call_single(
+            "SELECT chatbot_api.contact_get_dealer($1)",
+            ctx.customer_cid,
+        )
+        return [record] if record else []
+    else:
+        return await call_list(
+            "SELECT chatbot_api.contact_get_staff($1, $2, $3)",
+            req.company, req.contact_id, req.limit,
+        )
 
 
 @app.post("/orders", dependencies=[Depends(verify_api_key)])
-async def orders_list(req: OrdersListRequest) -> list[dict[str, Any]]:
+async def orders_list(
+    req: OrdersListRequest,
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> list[dict[str, Any]]:
     """
-    List sales orders (one row per line item).
-    Calls erp_api.sales_orders_lists(conditions jsonb, limit int).
+    List sales orders.
+    Dealer variant enforces CustomerCID at the DB level — cannot be bypassed.
     """
-    conditions: dict[str, Any] = {}
-    if req.so_id:
-        conditions["SOID"] = req.so_id
-    if req.customer_name:
-        conditions["CustomerName"] = req.customer_name
-    if req.customer_cid:
-        conditions["CustomerCID"] = req.customer_cid
-    if req.po_id:
-        conditions["POID"] = req.po_id
-    if req.status:
-        conditions["Status"] = req.status
-
-    rows = await call_list_fn("sales_orders_lists", conditions, req.limit)
-    return filter_list(rows, COLUMNS_ORDER_HEADER)
+    if ctx.is_dealer:
+        return await call_list(
+            "SELECT chatbot_api.orders_list_dealer($1, $2, $3, $4, $5)",
+            ctx.customer_cid, req.so_id, req.po_id, req.status, req.limit,
+        )
+    else:
+        return await call_list(
+            "SELECT chatbot_api.orders_list_staff($1, $2, $3, $4, $5, $6)",
+            req.so_id, req.po_id, req.customer_cid, req.customer_name, req.status, req.limit,
+        )
 
 
 @app.get("/orders/{so_id}", dependencies=[Depends(verify_api_key)])
-async def order_get(so_id: str = Path(..., description="Sales Order ID")) -> dict[str, Any]:
+async def order_get(
+    so_id: str = Path(..., description="Sales Order ID"),
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> dict[str, Any]:
     """
-    Retrieve a single sales order with nested Items and Notes.
-    Calls erp_api.sales_order_get(so_id text).
+    Get a single sales order with line items and notes.
+    Dealer variant validates CID match at the DB level and returns NULL on mismatch
+    (treated as 404 — prevents order-ID enumeration).
     """
-    assert DB_POOL is not None
-    sql = "SELECT erp_api.sales_order_get($1)"
-    try:
-        row = await DB_POOL.fetchrow(sql, so_id)
-    except asyncpg.PostgresError as e:
-        raise HTTPException(status_code=404, detail=str(e))
+    if ctx.is_dealer:
+        result = await call_single(
+            "SELECT chatbot_api.order_get_dealer($1, $2)",
+            so_id, ctx.customer_cid,
+        )
+    else:
+        result = await call_single(
+            "SELECT chatbot_api.order_get_staff($1)",
+            so_id,
+        )
 
-    if row is None or row[0] is None:
+    if result is None:
         raise HTTPException(status_code=404, detail=f"Sales order not found: {so_id}")
 
-    full: dict = json.loads(row[0]) if isinstance(row[0], str) else row[0]
-
-    # Filter header columns
-    result = pick(full, COLUMNS_ORDER_HEADER)
-
-    # Filter Items array
-    items_raw: list[dict] = full.get("Items") or []
-    result["Items"] = [pick(item, COLUMNS_ORDER_ITEM) for item in items_raw]
-
-    # Include Notes array as-is (already minimal)
-    result["Notes"] = full.get("Notes") or []
-
     return result
 
 
 @app.post("/stock", dependencies=[Depends(verify_api_key)])
-async def stock_search(req: StockRequest) -> list[dict[str, Any]]:
+async def stock_search(
+    req: StockRequest,
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> list[dict[str, Any]]:
     """
-    Search available stock (pallet level).
-    Calls erp_api.stock_lists(conditions jsonb, limit int).
+    Search available stock.
+    Dealer variant returns availability info only (no pallet/hold/logistics detail).
     """
-    conditions: dict[str, Any] = {}
-    if req.model:
-        conditions["Model"] = req.model
-    if req.warehouse_cid:
-        conditions["WarehouseCID"] = req.warehouse_cid
+    if not req.model:
+        return []
 
-    rows = await call_list_fn("stock_lists", conditions, req.limit)
-    return filter_list(rows, COLUMNS_STOCK)
+    if ctx.is_dealer:
+        return await call_list(
+            "SELECT chatbot_api.stock_search_dealer($1, $2, $3)",
+            req.model, req.warehouse_cid, req.limit,
+        )
+    else:
+        return await call_list(
+            "SELECT chatbot_api.stock_search_staff($1, $2, $3)",
+            req.model, req.warehouse_cid, req.limit,
+        )
 
 
 # ──────────────────────────────────────────────────────────────────────────────
-# Dev runner (not used in production — uvicorn is started by systemd)
+# Dev runner (production uses uvicorn via systemd)
 # ──────────────────────────────────────────────────────────────────────────────
 if __name__ == "__main__":
     import uvicorn

+ 9 - 0
server/db.ts

@@ -85,6 +85,7 @@ export async function getAllUsers() {
     name: users.name,
     email: users.email,
     role: users.role,
+    erpContactCid: users.erpContactCid,
     createdAt: users.createdAt,
     lastSignedIn: users.lastSignedIn,
   }).from(users).orderBy(desc(users.lastSignedIn));
@@ -98,6 +99,14 @@ export async function updateUserRole(userId: number, role: "user" | "agent" | "a
   return result[0];
 }
 
+export async function updateUserErpContactCid(userId: number, erpContactCid: string | null) {
+  const db = await getDb();
+  if (!db) throw new Error("Database not available");
+  await db.update(users).set({ erpContactCid }).where(eq(users.id, userId));
+  const result = await db.select().from(users).where(eq(users.id, userId)).limit(1);
+  return result[0];
+}
+
 export async function getUserById(userId: number) {
   const db = await getDb();
   if (!db) return undefined;

+ 23 - 11
server/erpClient.ts

@@ -8,16 +8,26 @@ import { ENV } from "./_core/env";
 
 const TIMEOUT_MS = 8_000;
 
+/** Caller identity forwarded to the ERP bridge for permission scoping. */
+export interface UserCtx {
+  role: string;           // "admin" | "agent" | "user"
+  erpContactCid?: string | null; // ERP ContactID for dealer-role scoping
+}
+
 /** Generic fetch wrapper with timeout and error handling */
 async function erpFetch<T>(
   path: string,
-  options: RequestInit = {}
+  options: RequestInit = {},
+  userCtx?: UserCtx
 ): Promise<T> {
   const controller = new AbortController();
   const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
 
   const url = `${ENV.erpApiUrl}${path}`;
 
+  const roleHeader = userCtx?.role ?? "user";
+  const cidHeader  = userCtx?.erpContactCid ?? "";
+
   try {
     const res = await fetch(url, {
       ...options,
@@ -25,6 +35,8 @@ async function erpFetch<T>(
       headers: {
         "Content-Type": "application/json",
         "X-API-Key": ENV.erpApiKey,
+        "X-User-Role": roleHeader,
+        "X-Customer-CID": cidHeader,
         ...(options.headers ?? {}),
       },
     });
@@ -181,11 +193,11 @@ export interface CatalogParams {
   limit?: number;
 }
 
-export async function fetchCatalog(params: CatalogParams): Promise<CatalogItem[]> {
+export async function fetchCatalog(params: CatalogParams, userCtx?: UserCtx): Promise<CatalogItem[]> {
   return erpFetch<CatalogItem[]>("/catalog", {
     method: "POST",
     body: JSON.stringify({ limit: 20, ...params }),
-  });
+  }, userCtx);
 }
 
 export interface ContactParams {
@@ -195,11 +207,11 @@ export interface ContactParams {
   limit?: number;
 }
 
-export async function fetchContacts(params: ContactParams): Promise<ContactRecord[]> {
+export async function fetchContacts(params: ContactParams, userCtx?: UserCtx): Promise<ContactRecord[]> {
   return erpFetch<ContactRecord[]>("/contacts", {
     method: "POST",
     body: JSON.stringify({ limit: 10, ...params }),
-  });
+  }, userCtx);
 }
 
 export interface OrderListParams {
@@ -211,16 +223,16 @@ export interface OrderListParams {
   limit?: number;
 }
 
-export async function fetchOrdersList(params: OrderListParams): Promise<OrderListItem[]> {
+export async function fetchOrdersList(params: OrderListParams, userCtx?: UserCtx): Promise<OrderListItem[]> {
   return erpFetch<OrderListItem[]>("/orders", {
     method: "POST",
     body: JSON.stringify({ limit: 20, ...params }),
-  });
+  }, userCtx);
 }
 
-export async function fetchOrderDetail(soId: string): Promise<OrderDetail | null> {
+export async function fetchOrderDetail(soId: string, userCtx?: UserCtx): Promise<OrderDetail | null> {
   try {
-    return await erpFetch<OrderDetail>(`/orders/${encodeURIComponent(soId)}`);
+    return await erpFetch<OrderDetail>(`/orders/${encodeURIComponent(soId)}`, {}, userCtx);
   } catch (err: unknown) {
     if ((err as Error).message?.includes("404")) return null;
     throw err;
@@ -233,9 +245,9 @@ export interface StockParams {
   limit?: number;
 }
 
-export async function fetchStock(params: StockParams): Promise<StockRecord[]> {
+export async function fetchStock(params: StockParams, userCtx?: UserCtx): Promise<StockRecord[]> {
   return erpFetch<StockRecord[]>("/stock", {
     method: "POST",
     body: JSON.stringify({ limit: 50, ...params }),
-  });
+  }, userCtx);
 }

+ 16 - 12
server/erpTools.ts

@@ -14,15 +14,18 @@ import {
   fetchStock,
   type OrderDetail,
   type OrderListItem,
+  type UserCtx,
 } from "./erpClient";
 
+export type { UserCtx };
+
 // ─── Order lookup ─────────────────────────────────────────────────────────────
 
 /**
  * Fetch a single sales order by SO-ID and return a chatbot-friendly summary.
  */
-export async function lookupOrder(soId: string): Promise<string> {
-  const order = await fetchOrderDetail(soId.trim().toUpperCase());
+export async function lookupOrder(soId: string, userCtx?: UserCtx): Promise<string> {
+  const order = await fetchOrderDetail(soId.trim().toUpperCase(), userCtx);
   if (!order) return `Sales order "${soId}" was not found in the system.`;
   return formatOrderDetail(order);
 }
@@ -32,9 +35,10 @@ export async function lookupOrder(soId: string): Promise<string> {
  */
 export async function lookupOrdersByCustomer(
   customerCid: string,
-  limit = 5
+  limit = 5,
+  userCtx?: UserCtx
 ): Promise<string> {
-  const rows = await fetchOrdersList({ customer_cid: customerCid, limit });
+  const rows = await fetchOrdersList({ customer_cid: customerCid, limit }, userCtx);
   if (!rows.length) return `No orders found for customer "${customerCid}".`;
   return formatOrderList(rows);
 }
@@ -42,8 +46,8 @@ export async function lookupOrdersByCustomer(
 /**
  * Search orders by PO number or partial SO-ID.
  */
-export async function lookupOrdersByPO(poId: string): Promise<string> {
-  const rows = await fetchOrdersList({ po_id: poId, limit: 10 });
+export async function lookupOrdersByPO(poId: string, userCtx?: UserCtx): Promise<string> {
+  const rows = await fetchOrdersList({ po_id: poId, limit: 10 }, userCtx);
   if (!rows.length) return `No orders found with PO number "${poId}".`;
   return formatOrderList(rows);
 }
@@ -56,8 +60,8 @@ export async function lookupCatalog(params: {
   category?: string;
   manufacturer?: string;
   limit?: number;
-}): Promise<string> {
-  const items = await fetchCatalog({ ...params, limit: params.limit ?? 10 });
+}, userCtx?: UserCtx): Promise<string> {
+  const items = await fetchCatalog({ ...params, limit: params.limit ?? 10 }, userCtx);
   if (!items.length) return "No matching products found in the catalog.";
 
   const lines = items.map((i) => {
@@ -81,8 +85,8 @@ export async function lookupStock(params: {
   model: string;
   warehouse_cid?: string;
   limit?: number;
-}): Promise<string> {
-  const records = await fetchStock({ ...params, limit: params.limit ?? 30 });
+}, userCtx?: UserCtx): Promise<string> {
+  const records = await fetchStock({ ...params, limit: params.limit ?? 30 }, userCtx);
   if (!records.length)
     return `No stock records found for model "${params.model}".`;
 
@@ -113,8 +117,8 @@ export async function lookupContact(params: {
   contact_id?: string;
   company?: string;
   name?: string;
-}): Promise<string> {
-  const contacts = await fetchContacts({ ...params, limit: 5 });
+}, userCtx?: UserCtx): Promise<string> {
+  const contacts = await fetchContacts({ ...params, limit: 5 }, userCtx);
   if (!contacts.length) return "No matching customer records found.";
 
   const lines = contacts.map((c) => {

+ 40 - 10
server/routers.ts

@@ -16,7 +16,7 @@ import {
   bulkUpdateConversationStatus, deleteConversations,
   saveWorkflow, getWorkflow,
   getWorkflowSuggestions, updateWorkflowSuggestionStatus, bulkCreateWorkflowSuggestions,
-  getAllUsers, updateUserRole, getUserById, deleteUser, getUserByEmail,
+  getAllUsers, updateUserRole, updateUserErpContactCid, getUserById, deleteUser, getUserByEmail,
   getUserByEmailWithPassword, createUserWithPassword, updateUserPassword,
   createPasswordResetToken, getPasswordResetToken, markPasswordResetTokenUsed,
   createInvitation, getAllInvitations, getInvitationByToken, updateInvitationStatus,
@@ -340,24 +340,31 @@ export const appRouter = router({
             const msg = input.content;
             const msgLower = msg.toLowerCase();
 
+            // Build user context for permission-scoped ERP queries.
+            // Dealers (role="user") are automatically scoped to their own CID by the bridge.
+            const userCtx = {
+              role: ctx.user.role,
+              erpContactCid: ctx.user.erpContactCid,
+            };
+
             // 1. Single order lookup  —  "SO-12345", "order SO12345", "#SO-99"
             const soMatch = msg.match(/\bSO[-\s]?\d{4,}\b/i);
             if (soMatch) {
               const soId = soMatch[0].replace(/\s/, "-").toUpperCase();
-              erpContext = await lookupOrder(soId);
+              erpContext = await lookupOrder(soId, userCtx);
 
             // 2. "my orders" / "recent orders" — needs customer CID on conversation
             } else if (/\b(my orders?|recent orders?|order history|order status)\b/.test(msgLower)) {
-              const cid = (conversation as any).customerId as string | undefined;
+              const cid = ctx.user.erpContactCid ?? (conversation as any).customerId as string | undefined;
               if (cid) {
-                erpContext = await lookupOrdersByCustomer(cid, 5);
+                erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
               }
 
             // 3. PO number lookup  —  "PO-12345", "purchase order 5678"
             } else {
               const poMatch = msg.match(/\bPO[-\s]?\d{3,}\b/i);
               if (poMatch) {
-                erpContext = await lookupOrdersByPO(poMatch[0]);
+                erpContext = await lookupOrdersByPO(poMatch[0], userCtx);
               }
             }
 
@@ -366,7 +373,7 @@ export const appRouter = router({
               // Try to extract a model number: capital letters + digits, e.g. "B1234-1"
               const modelMatch = msg.match(/\b([A-Z]{1,4}[-\s]?\d{3,}[-\w]*)\b/);
               if (modelMatch) {
-                erpContext = await lookupStock({ model: modelMatch[1] });
+                erpContext = await lookupStock({ model: modelMatch[1] }, userCtx);
               }
             }
 
@@ -381,15 +388,15 @@ export const appRouter = router({
                 .slice(0, 4)
                 .join(" ");
               if (keywords) {
-                erpContext = await lookupCatalog({ description: keywords, limit: 8 });
+                erpContext = await lookupCatalog({ description: keywords, limit: 8 }, userCtx);
               }
             }
 
-            // 6. Customer / dealer lookup
-            if (!erpContext && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
+            // 6. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
+            if (!erpContext && ctx.user.role !== "user" && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
               const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
               if (nameMatch) {
-                erpContext = await lookupContact({ company: nameMatch[1].trim() });
+                erpContext = await lookupContact({ company: nameMatch[1].trim() }, userCtx);
               }
             }
           } catch (erpErr) {
@@ -623,6 +630,29 @@ export const appRouter = router({
         return getUserById(input.userId);
       }),
 
+    /** Set or clear the ERP ContactID linked to a user (for dealer permission scoping) */
+    updateErpContactCid: adminProcedure
+      .input(z.object({
+        userId: z.number(),
+        erpContactCid: z.string().max(64).nullable(),
+      }))
+      .mutation(async ({ input, ctx }) => {
+        const targetUser = await getUserById(input.userId);
+        if (!targetUser) {
+          throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+        }
+        const updated = await updateUserErpContactCid(input.userId, input.erpContactCid);
+        await createAuditLog({
+          action: "erp_contact_cid_change",
+          actorId: ctx.user.id,
+          actorName: ctx.user.name || "Admin",
+          targetId: input.userId,
+          targetName: targetUser.name || targetUser.email || "User",
+          details: { erpContactCid: input.erpContactCid },
+        });
+        return updated;
+      }),
+
     /** Delete a user */
     delete: adminProcedure
       .input(z.object({ userId: z.number() }))