go multi-tenant saas architecture

Multi-Tenant Memory with MCP (Go)

Learn how to build secure multi-tenant memory systems for SaaS AI applications with proper access control, isolation, and Go implementation patterns.

CodeMem Team

The Multi-Tenancy Challenge

When you're building AI agents for a single user, memory is straightforward—one namespace, one owner. But the moment you ship a SaaS product with multiple customers, everything changes. Customer A's memories must never leak to Customer B. API keys need scoped permissions. And you need audit trails for compliance.

Multi-tenant memory isn't just about database partitioning—it's about building security into every layer of your MCP server. Let's explore the architecture patterns that make this work in Go.

Tenant Isolation Models

There are three main approaches to isolating tenant memory, each with different tradeoffs:

  • Database per tenant: Complete isolation, highest cost, complex migrations
  • Schema per tenant: Good isolation, moderate complexity, PostgreSQL-friendly
  • Shared schema with tenant ID: Lowest cost, requires careful access control, scales easily

For most SaaS applications, the shared schema approach with tenant IDs provides the best balance. Here's how to implement it safely:

// Tenant represents an isolated customer organization
type Tenant struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Plan      string    `json:"plan"` // free, pro, enterprise
    CreatedAt time.Time `json:"created_at"`
}

// Memory is always scoped to a tenant
type Memory struct {
    ID        string                 `json:"id"`
    TenantID  string                 `json:"tenant_id"` // Required, never nil
    Namespace string                 `json:"namespace"`
    Key       string                 `json:"key"`
    Content   map[string]interface{} `json:"content"`
    CreatedBy string                 `json:"created_by"` // User or agent ID
    CreatedAt time.Time              `json:"created_at"`
}

// SQL schema enforces tenant isolation
const schema = `
CREATE TABLE memories (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    namespace   TEXT NOT NULL,
    key         TEXT NOT NULL,
    content     JSONB NOT NULL,
    created_by  TEXT NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    
    -- Composite unique constraint includes tenant
    UNIQUE (tenant_id, namespace, key)
);

-- Index for fast tenant-scoped queries
CREATE INDEX idx_memories_tenant ON memories(tenant_id);

-- Row Level Security for defense in depth
ALTER TABLE memories ENABLE ROW LEVEL SECURITY;

Context-Based Access Control

The key to secure multi-tenancy is propagating tenant context through every layer. In Go, the context.Context package is your best friend:

type contextKey string

const (
    tenantKey contextKey = "tenant"
    userKey   contextKey = "user"
)

// TenantFromContext extracts tenant, panics if missing (fail secure)
func TenantFromContext(ctx context.Context) *Tenant {
    tenant, ok := ctx.Value(tenantKey).(*Tenant)
    if !ok || tenant == nil {
        panic("tenant context required but missing")
    }
    return tenant
}

// WithTenant injects tenant into context
func WithTenant(ctx context.Context, tenant *Tenant) context.Context {
    return context.WithValue(ctx, tenantKey, tenant)
}

// TenantMiddleware extracts tenant from JWT and injects into context
func TenantMiddleware(auth AuthService) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, err := auth.ValidateToken(r)
            if err != nil {
                writeError(w, http.StatusUnauthorized, "invalid token")
                return
            }
            
            tenant, err := auth.GetTenant(r.Context(), claims.TenantID)
            if err != nil {
                writeError(w, http.StatusForbidden, "tenant not found")
                return
            }
            
            ctx := WithTenant(r.Context(), tenant)
            ctx = context.WithValue(ctx, userKey, claims.UserID)
            
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

The Tenant-Aware Repository

Every database operation must be scoped to a tenant. The repository pattern makes this explicit and impossible to bypass:

type MemoryRepository struct {
    db *sql.DB
}

// Store always requires tenant from context
func (r *MemoryRepository) Store(ctx context.Context, namespace, key string, content map[string]interface{}) error {
    tenant := TenantFromContext(ctx) // Panics if no tenant
    userID := ctx.Value(userKey).(string)
    
    contentJSON, err := json.Marshal(content)
    if err != nil {
        return fmt.Errorf("marshal content: %w", err)
    }
    
    _, err = r.db.ExecContext(ctx, `
        INSERT INTO memories (tenant_id, namespace, key, content, created_by)
        VALUES ($1, $2, $3, $4, $5)
        ON CONFLICT (tenant_id, namespace, key) 
        DO UPDATE SET content = $4, created_by = $5
    `, tenant.ID, namespace, key, contentJSON, userID)
    
    return err
}

// Retrieve automatically filters by tenant
func (r *MemoryRepository) Retrieve(ctx context.Context, namespace, key string) (*Memory, error) {
    tenant := TenantFromContext(ctx)
    
    var m Memory
    err := r.db.QueryRowContext(ctx, `
        SELECT id, tenant_id, namespace, key, content, created_by, created_at
        FROM memories
        WHERE tenant_id = $1 AND namespace = $2 AND key = $3
    `, tenant.ID, namespace, key).Scan(
        &m.ID, &m.TenantID, &m.Namespace, &m.Key, &m.Content, &m.CreatedBy, &m.CreatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, ErrMemoryNotFound
    }
    return &m, err
}

// List returns all memories for a tenant in a namespace
func (r *MemoryRepository) List(ctx context.Context, namespace string, limit int) ([]Memory, error) {
    tenant := TenantFromContext(ctx)
    
    rows, err := r.db.QueryContext(ctx, `
        SELECT id, tenant_id, namespace, key, content, created_by, created_at
        FROM memories
        WHERE tenant_id = $1 AND namespace = $2
        ORDER BY created_at DESC
        LIMIT $3
    `, tenant.ID, namespace, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var memories []Memory
    for rows.Next() {
        var m Memory
        if err := rows.Scan(&m.ID, &m.TenantID, &m.Namespace, &m.Key, &m.Content, &m.CreatedBy, &m.CreatedAt); err != nil {
            return nil, err
        }
        memories = append(memories, m)
    }
    return memories, rows.Err()
}

Namespace-Level Permissions

Within a tenant, you often need finer-grained control. Some namespaces might be read-only for certain roles, or restricted to specific users:

type Permission string

const (
    PermRead   Permission = "read"
    PermWrite  Permission = "write"
    PermDelete Permission = "delete"
    PermAdmin  Permission = "admin"
)

type NamespacePolicy struct {
    Namespace   string
    AllowedBy   []string     // User or role IDs
    Permissions []Permission
}

type PolicyEngine struct {
    policies map[string][]NamespacePolicy // tenant_id -> policies
}

func (p *PolicyEngine) CanAccess(ctx context.Context, namespace string, perm Permission) bool {
    tenant := TenantFromContext(ctx)
    userID := ctx.Value(userKey).(string)
    
    policies := p.policies[tenant.ID]
    for _, policy := range policies {
        if policy.Namespace != namespace && policy.Namespace != "*" {
            continue
        }
        
        // Check if user is allowed
        allowed := false
        for _, id := range policy.AllowedBy {
            if id == userID || id == "*" {
                allowed = true
                break
            }
        }
        if !allowed {
            continue
        }
        
        // Check if permission is granted
        for _, p := range policy.Permissions {
            if p == perm || p == PermAdmin {
                return true
            }
        }
    }
    
    return false
}

Audit Logging for Compliance

Enterprise customers require audit trails. Log every memory operation with tenant, user, and action:

type AuditEntry struct {
    Timestamp time.Time `json:"timestamp"`
    TenantID  string    `json:"tenant_id"`
    UserID    string    `json:"user_id"`
    Action    string    `json:"action"`
    Namespace string    `json:"namespace"`
    Key       string    `json:"key"`
    Success   bool      `json:"success"`
    Error     string    `json:"error,omitempty"`
}

func (r *MemoryRepository) StoreWithAudit(ctx context.Context, namespace, key string, content map[string]interface{}) error {
    tenant := TenantFromContext(ctx)
    userID := ctx.Value(userKey).(string)
    
    err := r.Store(ctx, namespace, key, content)
    
    audit := AuditEntry{
        Timestamp: time.Now().UTC(),
        TenantID:  tenant.ID,
        UserID:    userID,
        Action:    "memory.store",
        Namespace: namespace,
        Key:       key,
        Success:   err == nil,
    }
    if err != nil {
        audit.Error = err.Error()
    }
    
    r.auditLog.Log(audit) // Async, non-blocking
    return err
}

Defense in Depth

Multi-tenancy security requires multiple layers working together:

  • Network layer: Use separate API keys per tenant with scoped permissions
  • Application layer: Tenant context in every request, enforced by middleware
  • Repository layer: All queries include tenant ID, panic if missing
  • Database layer: Row Level Security as final defense
  • Audit layer: Log all operations for forensics and compliance

If any layer fails, the next one catches the breach. This is how you build SaaS systems that enterprise customers can trust.

Multi-Tenant Memory, Zero Effort

CodeMem handles tenant isolation, access control, and audit logging out of the box. Stop building infrastructure and start shipping AI features your customers actually want.

Start Building for Free →