How to Set Up Multi-Tenant Isolation¶
Isolate data access by tenant or organization.
Goal¶
Ensure users can only access data belonging to their tenant.
Prerequisites¶
- A working custom MCP server with authentication
- Tenant information available in user context
Architecture¶
flowchart TB
subgraph Tenant A
userA[User A]
dataA[Tenant A Data]
end
subgraph Tenant B
userB[User B]
dataB[Tenant B Data]
end
subgraph MCP Server
filter[Tenant Filter]
end
userA --> filter
userB --> filter
filter --> dataA
filter --> dataB
style dataA fill:#e1f5e1
style dataB fill:#e1f5e1
Step 1: Define Tenant Context¶
Add tenant information to the request context:
// In your auth middleware
func (m *AuthMiddleware) Before(ctx context.Context, tc *tools.ToolContext) (context.Context, error) {
// ... validate token ...
// Extract tenant from token claims
tenantID := claims["tenant_id"].(string)
ctx = context.WithValue(ctx, "tenant_id", tenantID)
return ctx, nil
}
Step 2: Implement Tenant Access Filter¶
Create an AccessFilter that enforces tenant boundaries:
package tenant
import (
"context"
"errors"
"strings"
"github.com/txn2/mcp-datahub/pkg/integration"
)
type TenantAccessFilter struct {
// Map tenant ID to allowed domain prefixes
tenantDomains map[string][]string
}
func NewTenantAccessFilter(config map[string][]string) *TenantAccessFilter {
return &TenantAccessFilter{tenantDomains: config}
}
func (f *TenantAccessFilter) CanAccess(ctx context.Context, urn string) (bool, error) {
tenantID, ok := ctx.Value("tenant_id").(string)
if !ok || tenantID == "" {
return false, errors.New("tenant context required")
}
// Get allowed domains for this tenant
allowedDomains := f.tenantDomains[tenantID]
if len(allowedDomains) == 0 {
return false, nil // No access if no domains configured
}
// Check if URN belongs to an allowed domain
for _, domain := range allowedDomains {
if strings.Contains(urn, domain) {
return true, nil
}
}
return false, nil
}
func (f *TenantAccessFilter) FilterURNs(ctx context.Context, urns []string) ([]string, error) {
var allowed []string
for _, urn := range urns {
ok, err := f.CanAccess(ctx, urn)
if err != nil {
return nil, err
}
if ok {
allowed = append(allowed, urn)
}
}
return allowed, nil
}
Step 3: Configure Tenant Mappings¶
Define which domains each tenant can access:
// Configuration
tenantConfig := map[string][]string{
"acme-corp": {
"acme",
"shared",
},
"globex": {
"globex",
"shared",
},
"initech": {
"initech",
"shared",
},
}
tenantFilter := tenant.NewTenantAccessFilter(tenantConfig)
Step 4: Wire the Filter¶
Add the tenant filter to your toolkit:
toolkit := tools.NewToolkit(datahubClient,
tools.WithMiddleware(authMiddleware),
tools.WithAccessFilter(tenantFilter),
)
Advanced: Database-Backed Tenant Configuration¶
For dynamic tenant configuration:
type DBTenantAccessFilter struct {
db *sql.DB
}
func (f *DBTenantAccessFilter) CanAccess(ctx context.Context, urn string) (bool, error) {
tenantID := ctx.Value("tenant_id").(string)
// Query allowed patterns for this tenant
rows, err := f.db.QueryContext(ctx, `
SELECT urn_pattern FROM tenant_access
WHERE tenant_id = ?
`, tenantID)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var pattern string
if err := rows.Scan(&pattern); err != nil {
return false, err
}
if matchesPattern(urn, pattern) {
return true, nil
}
}
return false, nil
}
func matchesPattern(urn, pattern string) bool {
// Support wildcards: "urn:li:dataset:*acme*"
if strings.Contains(pattern, "*") {
pattern = strings.ReplaceAll(pattern, "*", ".*")
matched, _ := regexp.MatchString(pattern, urn)
return matched
}
return strings.Contains(urn, pattern)
}
Advanced: Per-Tenant DataHub Connections¶
For complete isolation, connect each tenant to a separate DataHub:
type TenantClientManager struct {
clients map[string]*client.Client
}
func (m *TenantClientManager) GetClient(tenantID string) (*client.Client, error) {
c, ok := m.clients[tenantID]
if !ok {
return nil, errors.New("unknown tenant")
}
return c, nil
}
// Create middleware that switches clients per tenant
type TenantClientMiddleware struct {
manager *TenantClientManager
}
func (m *TenantClientMiddleware) Before(ctx context.Context, tc *tools.ToolContext) (context.Context, error) {
tenantID := ctx.Value("tenant_id").(string)
client, err := m.manager.GetClient(tenantID)
if err != nil {
return ctx, err
}
ctx = context.WithValue(ctx, "datahub_client", client)
return ctx, nil
}
Verification¶
Test tenant isolation:
func TestTenantIsolation(t *testing.T) {
filter := tenant.NewTenantAccessFilter(map[string][]string{
"tenant-a": {"domain-a"},
"tenant-b": {"domain-b"},
})
// Tenant A accessing their data
ctxA := context.WithValue(context.Background(), "tenant_id", "tenant-a")
ok, _ := filter.CanAccess(ctxA, "urn:li:dataset:domain-a.table")
if !ok {
t.Error("Tenant A should access domain-a")
}
// Tenant A accessing Tenant B data
ok, _ = filter.CanAccess(ctxA, "urn:li:dataset:domain-b.table")
if ok {
t.Error("Tenant A should NOT access domain-b")
}
// Tenant B accessing their data
ctxB := context.WithValue(context.Background(), "tenant_id", "tenant-b")
ok, _ = filter.CanAccess(ctxB, "urn:li:dataset:domain-b.table")
if !ok {
t.Error("Tenant B should access domain-b")
}
}
Troubleshooting¶
Users see no results
- Verify tenant ID is being set in context
- Check tenant configuration includes correct domains
- Verify URNs contain expected domain patterns
Cross-tenant data leakage
- Audit FilterURNs implementation
- Ensure filter runs on all tool results
- Check for bypasses in custom tools
Performance issues
- Cache tenant configurations
- Use batch permission checks
- Index tenant access patterns in database
Next Steps¶
- Audit Logging: Log tenant-scoped access
- Authentication: Extract tenant from tokens