Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/CRISTIANCAMACH34/Zippi/llms.txt

Use this file to discover all available pages before exploring further.

Tenant isolation is the most critical security property in Zippi. Every user operates within a scope that limits which rows they can read or write. The scope filter must be applied at the database query level — never in Python memory after the fact. A data leak between tenants is classified as Blocker severity.

The 8 Scope Types

Zippi defines exactly eight scope types, organized in a strict containment hierarchy. Each is registered in SCOPE_TYPES in backend/app/modules/auth/domain/rbac.py.
global
 └── country
      └── city
           ├── zippi_branch        (Zippi-owned branch)
           └── business_group      (multi-brand group)
                └── business        (individual brand/business)
                     └── business_branch  (physical branch of a business)
self  (a single user: delivery_driver or customer)
Scope keyLabelWho uses it
globalGlobalsuper_admin, platform_admin
countryPaíscountry_admin
cityCiudadcity_admin, operations_admin, finance_admin, support_agent
zippi_branchSucursal Zippizippi_branch_admin
business_groupGrupo empresarialbusiness_owner
businessNegocio / marcabusiness_admin
business_branchSucursal de negociobusiness_branch_admin, kitchen_staff, cashier, waiter
selfPropiodelivery_driver, customer
A scope at a higher level contains all scopes beneath it in the same branch. A city_admin sees all businesses, branches, and orders within their city. A business_owner sees all branches across their business group. A business_branch_admin sees only their single branch.

Scope Aliases

Scope keys are normalized before comparison. The SCOPE_ALIASES dictionary in rbac.py maps Spanish-language variants to canonical keys:
SCOPE_ALIASES = {
    "pais": "country",
    "país": "country",
    "ciudad": "city",
    "sucursal_zippi": "zippi_branch",
    "zippi branch": "zippi_branch",
    "negocio": "business",
    "marca": "business",
    "sucursal": "business_branch",
    "business branch": "business_branch",
    "sucursal_negocio": "business_branch",
    "propio": "self",
    "zona": "city",
}
Always call normalize_scope_key() before comparing or storing a scope type. Never use raw strings.

The Hard Rule

Every read or write operation must include a scope filter applied at the SQL query level — not in Python memory after fetching rows.
# WRONG — fetches all rows across all tenants, then filters in Python
all_orders = session.query(Pedido).all()
return [o for o in all_orders if o.id_negocio == scope.business_id]

# CORRECT — scope filter is part of the SQL query
def list_orders(self, scope: BusinessScope, **filters):
    q = session.query(Pedido)
    q = apply_scope(q, scope)          # ALWAYS; applied first
    if filters.get("status"):
        q = q.filter(Pedido.estado == filters["status"])
    return q.order_by(Pedido.id.desc()).all()

The BusinessScope Dataclass

For business-portal requests, the scope is materialized as a BusinessScope dataclass defined in backend/app/modules/business/security/business_scope.py:
@dataclass(frozen=True)
class BusinessScope:
    role: str
    scope_type: str
    scope_id: str | None
    business_id: int | None
    branch_id: int | None
    email: str
business_scope_from_user() builds this from the JWT payload. It resolves business_id from branch_id when only the branch is set (via a database lookup on SucursalNegocio), and raises a 403 if a non-super-admin user has no business or branch assignment.
# backend/app/modules/business/security/business_scope.py

BUSINESS_ROLES = {
    "business_owner", "business_admin", "business_branch_admin",
    "kitchen_staff", "cashier", "waiter",
    "super_admin", "platform_admin",
}

def business_scope_from_user(user: dict | None) -> BusinessScope:
    if not user:
        raise HttpError("Sesion requerida", status_code=401)
    role = str(_first_value(user, "role", "rol") or "")
    if role not in BUSINESS_ROLES:
        raise HttpError("Este modulo es solo para usuarios de negocio", status_code=403)
    # ... resolves business_id and branch_id from JWT claims

apply_scope Implementation Pattern

The apply_scope function gates every query by the user’s scope type:
def apply_scope(q, scope: BusinessScope):
    if scope.role in {"super_admin", "platform_admin"}:
        return q                              # global — no filter
    if scope.branch_id is not None:
        q = q.filter(Pedido.id_sucursal == scope.branch_id)
    elif scope.business_id is not None:
        q = q.filter(Pedido.id_negocio == scope.business_id)
    if scope.scope_type == "self":
        q = q.filter(Pedido.id_usuario == scope.user_id)
    return q
Geographic scopes (country, city, zippi_branch) use their own geographic filters. “No business_id” for these roles does not mean “no filter” — it means “filter by geography”.

Row-Level Membership Check: row_in_business_scope

For single-row operations (GET by ID, PUT, DELETE), use row_in_business_scope() to confirm the row belongs to the user’s scope before returning or mutating it:
# backend/app/modules/business/security/business_scope.py

def row_in_business_scope(row: dict[str, Any], scope: BusinessScope) -> bool:
    if scope.role in {"super_admin", "platform_admin"}:
        return True
    if scope.branch_id is not None:
        return _to_int(
            row.get("businessBranchId") or row.get("branch_id")
        ) == scope.branch_id
    if scope.business_id is not None:
        return _to_int(
            row.get("businessId") or row.get("business_id")
        ) == scope.business_id
    return False

IDOR Prevention: Membership Over Existence

A valid ID does not imply access. The pattern to follow:
# WRONG — IDOR vulnerability
order = repo.get_by_id(oid)         # whose order is this?
return serialize(order)             # leaks data from another tenant

# CORRECT — scope validated inside the repository call
order = repo.get_order(scope, oid)  # filters by scope in SQL
if order is None:
    raise HttpError("No encontrado", status_code=404)   # 404, not 403
return serialize(order)
Respond with 404 (not 403) when a resource from another tenant is requested by ID. A 403 would confirm the resource exists; a 404 reveals nothing.

Example: business_admin Scope Query

A business_admin authenticates with scope_type = "business" and scope_id = 42. Here is how a product listing query is constructed:
# 1. Build scope from JWT
scope = business_scope_from_user(current_user)
# → BusinessScope(role="business_admin", scope_type="business",
#                 business_id=42, branch_id=None, ...)

# 2. Query with scope filter
q = session.query(Producto)
# apply_scope adds: WHERE id_negocio = 42
q = apply_scope(q, scope)

# 3. Additional filters come after scope
q = q.filter(Producto.activo == True)
products = q.all()
# → Only products belonging to business 42 are returned
If that same user tries to GET a product with id = 99 that belongs to business 77:
product = repo.get_product(scope, product_id=99)
# apply_scope makes the query: WHERE id = 99 AND id_negocio = 42
# → returns None (no match)
# → controller raises HttpError("No encontrado", 404)

Tenant Discriminators

Every operational entity carries the discriminator columns that bind it to a tenant:
EntityTenant discriminators
Product (Producto)id_negocio (and optionally id_sucursal)
Order (Pedido)id_negocio, id_sucursal
Settlement (Liquidacion)id_negocio
Cashier shift (TurnoCaja)id_sucursal
Operative userid_negocio or id_sucursal depending on role
Every new operational table must be created with its tenant discriminator from day one.

Write Operations and Scope

Mutations follow the same rules:
# WRONG — allows mass assignment of tenant (user sends id_negocio in body)
product = Producto(id_negocio=data.id_negocio, ...)

# CORRECT — tenant comes from the authenticated scope, never from the request body
product = Producto(
    id_negocio=scope.business_id,   # from scope, not from body
    nombre=data.nombre,
    precio=data.precio,
)
For UPDATE and DELETE, always include the tenant discriminator in the WHERE clause:
-- WRONG
UPDATE productos SET precio = :precio WHERE id = :id;

-- CORRECT
UPDATE productos SET precio = :precio
WHERE id = :id AND id_negocio = :business_id;
A data leak between tenants is classified as Blocker severity. Any query that reads or writes operational data without a scope filter at the SQL level must be treated as a security vulnerability and fixed immediately, regardless of other pending work.

self Scope: Couriers and Customers

Users with scope type self can only access their own records:
  • delivery_driver → filtered by id_domiciliario == user_id
  • customer → filtered by id_cliente == user_id
These users access dedicated endpoints (/customer/orders, /courier/deliveries) that apply the self filter automatically — they are never given access to the admin-level /orders endpoint.

Multi-Tenant Checklist

- [ ] Every new query passes through apply_scope / filters by tenant in SQL
- [ ] Access by ID validates membership (not just format or existence)
- [ ] Create operations take the tenant from scope, never from the request body
- [ ] Update / delete operations include the tenant discriminator in the WHERE clause
- [ ] Resources from another tenant respond with 404 (not 403)
- [ ] Tenant isolation is covered by automated tests

Build docs developers (and LLMs) love