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
merchantIDorstoreIDparameter → 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.