Skip to content
SarvexDocs
Esc
↑↓navigate↵open⌘Jpreview
On this page

Tenancy patterns

Sarvex operates a two-level tenancy model: - Account data: partitioned by merchant_id - Catalog/serving data: partitioned by store_id (each store belongs to exactly one merchant)

Generated from docs/TENANCY-PATTERNS.md in the Sarvex repository — edit the source there, not this page.

Overview

Sarvex operates a two-level tenancy model:

  • Account data: partitioned by merchant_id
  • Catalog/serving data: partitioned by store_id (each store belongs to exactly one merchant)

Tenant isolation is non-negotiable and enforced via query-level fencing, not handler-level checks alone.

The Pattern: Query-Level Fencing

Every repository method that accepts a tenant identifier (merchantID or storeID) must include it as a Mongo query filter. This is mandatory, not optional.

Why Query-Level Fencing?

Handler-level authorization checks alone are insufficient:

  • They fail open if a call site forgets to validate
  • They don’t protect non-HTTP callers (event consumers, background jobs)
  • Tenant isolation becomes a property of “did the reviewer remember to check?” rather than a property of the database query

Query-level fencing is structurally the strongest: an unscoped Mongo query literally cannot find a document under the wrong tenant key. This makes tenant isolation a database-level guarantee, not a review-time assumption.

Implementation Requirements

For Repository Methods

// ❌ WRONG: accepts merchantID but omits from filter
func (r *mongoRepo) GetAccount(ctx context.Context, merchantID string) (*Account, error) {
  var acc Account
  err := r.col.FindOne(ctx, bson.M{}).Decode(&acc) // BUG: no merchant_id filter
  return &acc, err
}

// ✅ CORRECT: includes merchantID in the Mongo query filter
func (r *mongoRepo) GetAccount(ctx context.Context, merchantID string) (*Account, error) {
  var acc Account
  err := r.col.FindOne(ctx, bson.M{"_id": merchantID}).Decode(&acc)
  return &acc, err
}

// ✅ CORRECT: compound key for store-scoped data
func (r *mongoRepo) GetCatalog(ctx context.Context, storeID, merchantID string) (*Catalog, error) {
  var cat Catalog
  err := r.col.FindOne(ctx, bson.M{"_id": storeID, "merchant_id": merchantID}).Decode(&cat)
  return &cat, err
}

For Aggregate/List Queries

When query logic requires $or-style operations, still enforce tenant scoping:

// ✅ CORRECT: merchant_id is AND'ed across all branches
func (r *mongoRepo) FindByStatus(ctx context.Context, merchantID string, statuses []string) ([]*Account, error) {
  filter := bson.M{
    "merchant_id": merchantID,  // Non-negotiable
    "status": bson.M{
      "$in": statuses,
    },
  }
  // ... cursor, decode, return
}

// ✅ CORRECT: if using $or, tenant scope still applies
func (r *mongoRepo) FindByNameOrEmail(ctx context.Context, merchantID string, nameOrEmail string) ([]*Account, error) {
  filter := bson.M{
    "merchant_id": merchantID,  // AND'ed with the $or
    "$or": []bson.M{
      {"name": nameOrEmail},
      {"email": nameOrEmail},
    },
  }
  // ... cursor, decode, return
}

For Redis Access

Redis operations should follow the same principle: tenant scope must be part of the key structure:

// ✅ CORRECT: merchantID is part of the key
func (r *redisCache) Get(ctx context.Context, merchantID, key string) ([]byte, error) {
  return r.client.Get(ctx, fmt.Sprintf("merchant:%s:%s", merchantID, key)).Bytes()
}

Handler-Level Defense-in-Depth (Optional)

Handlers may perform additional authorization checks as a second line of defense, but query-level fencing is the primary safeguard:

// ✅ OKAY: handler-level check provides defense-in-depth, but query filter is mandatory
func (h *handler) GetAccount(c echo.Context) error {
  principal, _ := c.Get("principal").(domain.Principal)  // Verify merchant
  accountID := c.Param("account_id")
  
  // Query already includes merchant_id filter — handler check is optional defense-in-depth
  account, err := h.repo.GetAccount(c.Request().Context(), string(principal.MerchantID), accountID)
  if err != nil {
    return c.JSON(http.StatusNotFound, nil)
  }
  
  // Additional check is optional but encouraged
  if account.MerchantID != principal.MerchantID {
    return c.JSON(http.StatusForbidden, nil)
  }
  
  return c.JSON(http.StatusOK, account)
}

Code Review Checklist

When reviewing repository code, flag these patterns:

  • Repository method signature includes merchantID or storeID parameter → must appear in Mongo filter
  • List/aggregate queries → tenant scope must be AND’ed in filter, never omitted
  • Handler-level-only authorization → flag as missing query-level fencing
  • Compound keys → both merchant_id AND store_id required for store-scoped data

Reference

See docs/adr/0005-tenancy-enforcement-via-query-level-fencing.md (ADR-2) for the decision rationale and consequences.

Was this page helpful?