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