Files
+1 98652c6476 Include sub projects (#10201)
* Add bump-changes

* Add utility tests

* Add utility tests

* Bump to new version of esbuild and typescript

* v0.7.3

* use platform rig 0.7.10

* upgrade: memory engine optimized; change name  to  (was recommended by Copilot and Onnikov, TODO: CHANGE CLIENT TOO!!!)

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* Fix rate limits bug

* Bump versions

* Fix lock file

* Fix bug in queue cleanup

* Add more tests for queue

* Add api-test tests

* Initial commit

* Improve hierarchy + tests

Add tests for hierarchy and few performance/memory  optimizations.

* Add more hierarchy tests

* Move from Huly platform repository

* Add docker tests setup

* Fix test to be executed only once

* Add connection tests

* Fix package include source files

* More tests

* Create README.md

* Fix pnpm lock

* Fix packages publish

* Remove broken tests

* feat: adjust hulylake client for storage adapter

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Fix export

* Fix publish

* Fix message update (#114)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Bump version

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Bump versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Fix lang store (#115)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* update hulylake client

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump version

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Add hulylake storage adapter

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump version

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix validation issues

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix: do not fail on deseralization error and add logs

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* bump version -> 0.1.14

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix collaboration test

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Update prettier and new update-deps script

Prettier + svelte support

* fix unstable ydoc tests

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Add tx ordering middleware

* Fix ordering tests

* Fix Kafka close of admin

* Add tests for measurement and understand overhead

* Fix not updated lock file

* Fix update-deps

* Fix update-deps

* Use latest platform-rig

* Fix deps

* Add rush check to CI

* Use latest versions

* Bump versions

* Fix lock file

* validate json patch

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* bump version -> 0.1.15

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix merge unit tests

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Script to sync eslint deps

* Fix deps

* Fix tests

* Fix platform-rig detection

* Update to latest platform-rig

* Update to latest platform rig and core

* Bump typescript

* Bump typescript

* Rollback eslint plugins

* Fix lock file

* Bump platform-rig

* Update to latest platform-rig

* update to latest platform-rig

* Allow to compile svelte files

* Add ui-test component for checking compile

* Fix log levels rename compile ui -> compile ui-esbuild

* Fix build

* Bump esbuild svelte version

* Chore: use fixed versions in update-deps

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Chore: commit changes

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Update deps

* Add tests for session manager

* Fix txOrdering implementation

* Bump ordering

* Prevent metrics zero values in measure

+ Fix format svelte files

* Revert update-deps script logic

* v0.7.19

* update to latest platform-rig

* Update deps

* Fix pnpm

* Session counters

* Fix pnpm lock

* Add storage client

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump core

* Fix pnpm

* Get rid of communication dependency

* Add copilot memory file

* Use proper name for instructions file

* Fix instructions

* Use domain instead of test name in gauges

* Update instructions file

* fix front service upload

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* remove incorrect test

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Move packages to huly.core

* Move packages to core, since they are not utils

* Add global user profile

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Fix lock file

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Add support for memory limit check

* Bump version

* Fix pnpm

* report more accurate upload progress

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fic validation issues

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Fix deps

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Move LowLevelStorage to server

* Fix linting

* Revert "Fix linting"

This reverts commit 54631d353e.

* Revert "Move LowLevelStorage to server"

This reverts commit aafb8f6f12.

* feature: add regorus engine with permit file

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* Fix one second counters for memory usage

* Fix kafka test

* use fresh core

* Version bump

* fix: key parameter added

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* Fix readme and few author mistakes

* Export domain schemas

* Bump version

* Tests (#117)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Bump version

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* feat: compact compact worker (#4)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* bump version -> 0.1.16

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Add TypeIdentifier

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Add change logs

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* rename send -> try_send

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix pnpm lock

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Add identifier middleware, bump core

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Add subsciption methods to account client

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Fix lock file

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Fix reaction notification (#118)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Bump version

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Improve find methods schemas to convert to valid types

Signed-off-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>

* Add change description

Signed-off-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>

* Do not transcode while recording

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Open telemetry support

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* use proper content type in multipart upload

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix build (#26)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Fix peers (#120)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Bump version

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>

* Add ActivityCollaborativeChange

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Update pnpm

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Allow to suspend errors on with

* Fix pnpm cache

* update versions

* v0.7.17 for all

* v0.7.11

* v0.7.14

* remove arc from worker

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* bump version -> 0.1.17

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Rank for attributes

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Update pnpm

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix one second counters

* Fix withContext and allow pass options

* Fix formatting

* Use updated deps

* Bump versions

* Update deps

* Update deps to platform.core

* add support for textColor mark

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* add support for textStyle mark

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump versions again

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Fix

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Rework on second timers

* fix merge of large blobs feched from s3

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* bump version -> 0.1.18

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* New subscription methods in account-client

* Update lock file

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Send error on find for wrong domain

* Suspend connect custom errors events in traces

* Bump client

* Bump core

* update deps

* Fix lock file

* Sorting for TypeIdentifier

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Bump version

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* add workspace usage info

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Bump versions

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Improve pg security perfomance

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix identifier middleware

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Update TxAccessLevel interface

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Allow guest to update its identities

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Add password login locked platform status

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>

* Fix Uptrace normalizeMarkdown errors

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add change log

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add txMatch to permission

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* update pnpm lock

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Bump

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix permission middleware

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix enum sorting

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Enable formatting check

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>

* Enable formatting check

* Add change

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>

* Fix Uptrace NaN error

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* feature: removed actors, improved performance

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* feature: ping from server to clients added

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* feature: ping from server to clients added

Signed-off-by: Leonid Kaganov <lleo@lleo.me>

* Compress kafka messages and fix exception in findAll

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Bump versions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Bump versions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Rush change

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix compression param

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Trigger change

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Trigger change

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Bump markdown version

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Enable sub projects

* Fix wrong double symbol scripts

* Include foundation packages

* Add support for custom exclude filters

Add support for custom exclude filters - by Andrey Sobolev - haiodo@gmail.com

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>

* Bump

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix Uptrace filter is not a function error

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Sync versions

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>

---------

Signed-off-by: Leonid Kaganov <lleo@lleo.me>
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
Signed-off-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>
Signed-off-by: Artem Savchenko <armisav@gmail.com>
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
Co-authored-by: Leonid Kaganov <lleo@lleo.me>
Co-authored-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
Co-authored-by: Alexander Onnikov <Alexander.Onnikov@gmail.com>
Co-authored-by: Kristina <kristin.fefelova@gmail.com>
Co-authored-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
Co-authored-by: Denis Bykhov <bykhov.denis@gmail.com>
Co-authored-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>
Co-authored-by: Alexander Onnikov <aonnikov@hardcoreeng.com>
Co-authored-by: Artem Savchenko <armisav@gmail.com>
2025-11-26 19:15:30 +05:00

16 KiB

Multi-Tenant Architectures with Huly Network

Building secure, scalable multi-tenant applications using Huly Virtual Network.

Table of Contents

Introduction

Multi-tenancy allows multiple customers (tenants) to share the same infrastructure while maintaining complete data and security isolation. Huly Network provides natural multi-tenancy through container kinds, labels, and reference management.

Benefits of Multi-Tenant Architecture

  • Cost Efficiency: Share infrastructure costs across tenants
  • Scalability: Scale resources per tenant independently
  • Isolation: Complete data and security separation
  • Flexibility: Different tiers and features per tenant
  • Maintainability: Single codebase for all tenants

Multi-Tenancy Patterns

Pattern 1: Tenant-Per-Container

Each tenant gets dedicated container instances:

// Request workspace for specific tenant
const workspace = await client.get('workspace' as ContainerKind, {
  labels: ['tenant-acme-corp']
})

Pros:

  • Complete isolation
  • Easy to track resource usage
  • Can scale per tenant

Cons:

  • More containers to manage
  • Higher resource overhead

Pattern 2: Shared Container with Tenant Filtering

Multiple tenants share containers, data filtered by tenant ID:

class SharedWorkspaceContainer implements Container {
  async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
    const tenantId = data.tenantId

    // All operations scoped to tenant
    switch (operation) {
      case 'getData':
        return this.getData(tenantId, data.filter)
    }
  }

  private async getData(tenantId: string, filter: any): Promise<any> {
    // Query with tenant filter
    return this.db.query({ ...filter, tenantId })
  }
}

Pros:

  • Fewer containers
  • Better resource utilization
  • Easier to manage

Cons:

  • Must ensure data isolation in code
  • Risk of data leakage if not careful

Pattern 3: Hybrid Approach

Shared containers for common operations, dedicated for sensitive data:

// Shared query engine
const queryEngine = await client.get('query-engine' as ContainerKind, {
  labels: ['shared']
})

// Dedicated workspace for sensitive operations
const workspace = await client.get('workspace' as ContainerKind, {
  labels: ['tenant-acme-corp', 'dedicated']
})

Container Isolation

Using Labels for Tenant Routing

// Agent factory with tenant-aware containers
// Note: For production code, use serveAgent() on the client
import { createNetworkClient } from '@hcengineering/network-client'

const client = createNetworkClient('localhost:3737')
await client.waitConnection(5000)

await client.serveAgent('localhost:3738', {
  'tenant-workspace': async (options: GetOptions) => {
    const tenantId = options.labels?.[0]
    if (!tenantId) {
      throw new Error('Tenant ID required')
    }

    const uuid = `workspace-${tenantId}-${Date.now()}` as ContainerUuid
    const container = new TenantWorkspaceContainer(uuid, tenantId)

    return {
      uuid,
      container,
      endpoint: `workspace://${tenantId}/${uuid}` as any
    }
  }
})

Tenant Workspace Container

export class TenantWorkspaceContainer implements Container {
  private data = new Map<string, any>()
  private users = new Set<string>()

  constructor(readonly uuid: ContainerUuid, readonly tenantId: string) {
    console.log(`Workspace created for tenant: ${tenantId}`)
  }

  async request(operation: string, data?: any): Promise<any> {
    // All operations automatically scoped to this.tenantId

    switch (operation) {
      case 'createDocument':
        return this.createDocument(data)

      case 'listDocuments':
        return this.listDocuments()

      case 'addUser':
        return this.addUser(data.userId)
    }
  }

  private async createDocument(data: any): Promise<any> {
    const docId = generateId()
    const doc = {
      id: docId,
      tenantId: this.tenantId, // Automatically tagged
      ...data,
      createdAt: Date.now()
    }

    this.data.set(docId, doc)

    await this.broadcast({
      type: 'documentCreated',
      document: doc
    })

    return { success: true, document: doc }
  }

  private async listDocuments(): Promise<any> {
    // Only returns documents for this tenant
    return {
      success: true,
      documents: Array.from(this.data.values()),
      tenantId: this.tenantId
    }
  }
}

Tenant Identification

Method 1: Labels

Use labels to identify tenants:

// Client requests workspace for tenant
const workspace = await client.get('workspace' as ContainerKind, {
  labels: ['tenant-id:acme-corp', 'tier:enterprise']
})

Method 2: Extra Parameters

Pass tenant context in extra parameters:

const workspace = await client.get('workspace' as ContainerKind, {
  extra: {
    tenantId: 'acme-corp',
    tier: 'enterprise',
    region: 'us-west'
  }
})

Method 3: Request-Time Identification

Include tenant in every request:

await workspace.request('createDocument', {
  tenantId: 'acme-corp', // Required in every request
  title: 'Q1 Report',
  content: '...'
})

Best Practice: Combined Approach

// Container-level isolation (preferred)
const workspace = await client.get('workspace' as ContainerKind, {
  labels: ['tenant:acme-corp']
})

// Request-level validation (defense in depth)
await workspace.request('createDocument', {
  tenantId: 'acme-corp', // Validated against container's tenant
  data: { ... }
})

Data Isolation

Database-Level Isolation

export class DatabaseContainer implements Container {
  private db: TenantDatabase

  constructor(readonly uuid: ContainerUuid, readonly tenantId: string) {
    // Connect to tenant-specific database
    this.db = new TenantDatabase({
      database: `tenant_${tenantId}`,
      schema: tenantId
    })
  }

  async request(operation: string, data?: any): Promise<any> {
    // All queries automatically scoped to tenant database
    switch (operation) {
      case 'query':
        return await this.db.query(data.sql, data.params)
    }
  }
}

Row-Level Security

export class SharedDatabaseContainer implements Container {
  private db: Database

  async request(operation: string, data?: any): Promise<any> {
    const tenantId = data.tenantId

    switch (operation) {
      case 'query':
        // Always add tenant filter
        return await this.db.query(data.sql + ' WHERE tenant_id = ?', [...data.params, tenantId])
    }
  }
}

In-Memory Isolation

export class CacheContainer implements Container {
  // Separate cache per tenant
  private caches = new Map<string, Map<string, any>>()

  private getTenantCache(tenantId: string): Map<string, any> {
    if (!this.caches.has(tenantId)) {
      this.caches.set(tenantId, new Map())
    }
    return this.caches.get(tenantId)!
  }

  async request(operation: string, data?: any): Promise<any> {
    const cache = this.getTenantCache(data.tenantId)

    switch (operation) {
      case 'get':
        return { value: cache.get(data.key) }

      case 'set':
        cache.set(data.key, data.value)
        return { success: true }
    }
  }
}

Resource Management

Resource Quotas

interface TenantQuota {
  maxDocuments: number
  maxUsers: number
  maxStorageBytes: number
  maxRequestsPerSecond: number
}

export class QuotaEnforcedContainer implements Container {
  private quotas = new Map<string, TenantQuota>()
  private usage = new Map<string, TenantUsage>()

  constructor(readonly uuid: ContainerUuid, readonly tenantId: string) {
    // Load quota for tenant
    this.quotas.set(tenantId, this.loadQuota(tenantId))
  }

  async request(operation: string, data?: any): Promise<any> {
    // Check quota before operation
    if (!(await this.checkQuota(operation, data))) {
      return {
        success: false,
        error: 'quota_exceeded',
        message: 'Your plan limit has been reached'
      }
    }

    // Process request
    const result = await this.processRequest(operation, data)

    // Update usage
    await this.updateUsage(operation, data)

    return result
  }

  private async checkQuota(operation: string, data: any): Promise<boolean> {
    const quota = this.quotas.get(this.tenantId)!
    const usage = this.usage.get(this.tenantId) || { documents: 0, users: 0 }

    switch (operation) {
      case 'createDocument':
        return usage.documents < quota.maxDocuments

      case 'addUser':
        return usage.users < quota.maxUsers

      default:
        return true
    }
  }
}

Rate Limiting

import { RateLimiter } from 'limiter'

export class RateLimitedContainer implements Container {
  private limiters = new Map<string, RateLimiter>()

  constructor(readonly uuid: ContainerUuid, readonly tenantId: string) {
    // Different limits per tier
    const tier = this.getTenantTier(tenantId)
    const limit = tier === 'enterprise' ? 1000 : 100 // requests per second

    this.limiters.set(
      tenantId,
      new RateLimiter({
        tokensPerInterval: limit,
        interval: 'second'
      })
    )
  }

  async request(operation: string, data?: any): Promise<any> {
    const limiter = this.limiters.get(this.tenantId)!

    // Try to consume token
    if (!(await limiter.tryRemoveTokens(1))) {
      return {
        success: false,
        error: 'rate_limit_exceeded',
        message: 'Too many requests, please try again later'
      }
    }

    return await this.processRequest(operation, data)
  }
}

Security Considerations

Preventing Data Leakage

export class SecureTenantContainer implements Container {
  async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
    // 1. Validate tenant access
    if (!(await this.validateTenantAccess(data.tenantId, clientId))) {
      throw new Error('Unauthorized tenant access')
    }

    // 2. Ensure tenant ID matches container
    if (data.tenantId !== this.tenantId) {
      throw new Error('Tenant ID mismatch')
    }

    // 3. Process request
    const result = await this.processRequest(operation, data)

    // 4. Sanitize response (remove cross-tenant data)
    return this.sanitizeResponse(result, this.tenantId)
  }

  private sanitizeResponse(data: any, tenantId: string): any {
    // Remove any data not belonging to tenant
    if (Array.isArray(data)) {
      return data.filter((item) => item.tenantId === tenantId)
    }
    return data
  }
}

Audit Logging

export class AuditedContainer implements Container {
  async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
    const startTime = Date.now()

    // Log request
    await this.auditLog({
      timestamp: new Date().toISOString(),
      tenantId: this.tenantId,
      clientId,
      operation,
      data: this.sanitizeForAudit(data)
    })

    try {
      const result = await this.processRequest(operation, data)

      // Log success
      await this.auditLog({
        timestamp: new Date().toISOString(),
        tenantId: this.tenantId,
        operation,
        status: 'success',
        duration: Date.now() - startTime
      })

      return result
    } catch (error: any) {
      // Log failure
      await this.auditLog({
        timestamp: new Date().toISOString(),
        tenantId: this.tenantId,
        operation,
        status: 'error',
        error: error.message,
        duration: Date.now() - startTime
      })

      throw error
    }
  }
}

Billing and Metering

Usage Tracking

export class MeteredContainer implements Container {
  private usage = {
    requests: 0,
    computeTime: 0,
    storageBytes: 0
  }

  async request(operation: string, data?: any): Promise<any> {
    const startTime = Date.now()

    this.usage.requests++

    try {
      const result = await this.processRequest(operation, data)

      // Track compute time
      const duration = Date.now() - startTime
      this.usage.computeTime += duration

      // Track storage if applicable
      if (operation === 'store') {
        this.usage.storageBytes += this.calculateSize(data)
      }

      // Report to billing system
      await this.reportUsage()

      return result
    } catch (error) {
      this.usage.computeTime += Date.now() - startTime
      throw error
    }
  }

  private async reportUsage(): Promise<void> {
    // Send to billing system every 1000 requests
    if (this.usage.requests % 1000 === 0) {
      await fetch('https://billing.api/usage', {
        method: 'POST',
        body: JSON.stringify({
          tenantId: this.tenantId,
          period: new Date().toISOString(),
          usage: this.usage
        })
      })
    }
  }
}

Complete Example

SaaS Application with Multiple Tenants

// See examples/03-multi-tenant.ts for complete working example

import { AgentImpl } from '@hcengineering/network-core'
import type { GetOptions, ContainerUuid } from '@hcengineering/network-core'

// Define tenant workspace container
class SaaSTenantContainer implements Container {
  private data = new Map<string, any>()
  private users = new Set<string>()
  private quota: TenantQuota
  private usage: TenantUsage

  constructor(readonly uuid: ContainerUuid, readonly tenantId: string, readonly tier: 'free' | 'pro' | 'enterprise') {
    this.quota = this.getQuotaForTier(tier)
    this.usage = { documents: 0, users: 0, requests: 0 }
  }

  async request(operation: string, data?: any): Promise<any> {
    // Rate limiting
    if (!(await this.checkRateLimit())) {
      return { success: false, error: 'rate_limit_exceeded' }
    }

    // Quota checking
    if (!(await this.checkQuota(operation))) {
      return { success: false, error: 'quota_exceeded' }
    }

    // Process request
    this.usage.requests++

    switch (operation) {
      case 'createDocument':
        return await this.createDocument(data)
      case 'listDocuments':
        return await this.listDocuments()
      case 'addUser':
        return await this.addUser(data)
      case 'getUsage':
        return { success: true, usage: this.usage, quota: this.quota }
    }
  }

  // Implementation details...
}

// Create agent
// Note: For production code, use serveAgent() on the client
const client = createNetworkClient('localhost:3737')
await client.waitConnection(5000)

await client.serveAgent('localhost:3738', {
  'tenant-workspace': async (options: GetOptions) => {
    const tenantId = options.labels?.[0]
    const tier = options.extra?.tier || 'free'

    const container = new SaaSTenantContainer(`workspace-${tenantId}` as ContainerUuid, tenantId, tier)

    return {
      uuid: container.uuid,
      container,
      endpoint: `saas://${tenantId}/${container.uuid}` as any
    }
  }
})

Best Practices

  1. Always validate tenant ID in every request
  2. Use container-level isolation when possible
  3. Implement quotas and rate limiting per tenant
  4. Audit all operations for security and compliance
  5. Monitor resource usage per tenant
  6. Test cross-tenant access prevention
  7. Plan for tenant data migration and export
  8. Document tenant isolation boundaries
  9. Implement graceful degradation when quotas exceeded
  10. Provide tenant analytics dashboard

Next Steps


For help building multi-tenant applications, see the Troubleshooting Guide.