Multi-Tenancy¶
Dashforge supports multi-tenant deployments using PostgreSQL Row Level Security (RLS) for data isolation.
Overview¶
Multi-tenancy allows multiple organizations to share a single Dashforge instance while keeping their data completely isolated.
┌─────────────────────────────────────────────┐
│ Dashforge Server │
├─────────────────────────────────────────────┤
│ Tenant A │ Tenant B │ ... │
│ ┌────────────┐ │ ┌────────────┐ │ │
│ │ Dashboards │ │ │ Dashboards │ │ │
│ │ Users │ │ │ Users │ │ │
│ │ Queries │ │ │ Queries │ │ │
│ └────────────┘ │ └────────────┘ │ │
├─────────────────────────────────────────────┤
│ PostgreSQL with RLS │
└─────────────────────────────────────────────┘
Enabling Multi-Tenancy¶
This:
- Creates the tenant table
- Adds tenant relationships to all entities
- Applies Row Level Security policies
How RLS Works¶
Row Level Security enforces tenant isolation at the database level:
-- Every query automatically includes tenant filtering
SELECT * FROM dashboards;
-- Actually executes as:
SELECT * FROM dashboards
WHERE tenant_id = current_setting('app.current_tenant')::int;
Benefits:
- Defense in depth: Even if application code has bugs, database enforces isolation
- Automatic: No need to add
WHERE tenant_id = ?to every query - Audit-friendly: Impossible to accidentally access another tenant's data
Tenant Context¶
Setting Tenant Context¶
The server sets tenant context from:
- JWT token (primary) -
tidclaim - X-Tenant-ID header - For service-to-service calls
- Subdomain -
tenant-a.dashforge.example.com - Query parameter -
?tenant=tenant-a(development only)
Priority: JWT > Header > Subdomain > Query param
Middleware¶
The tenant middleware extracts and sets tenant context:
// internal/server/middleware/tenant.go
func (m *TenantMiddleware) Handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, err := m.extractTenant(r)
if err != nil {
http.Error(w, "Tenant required", http.StatusBadRequest)
return
}
// Set PostgreSQL session variable
db.SetTenantContext(r.Context(), m.db, tenantID)
// Add to request context
ctx := context.WithValue(r.Context(), TenantKey, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Tenant Schema¶
// ent/schema/tenant.go
type Tenant struct {
ent.Schema
}
func (Tenant) Fields() []ent.Field {
return []ent.Field{
field.String("slug").Unique().NotEmpty(),
field.String("name").NotEmpty(),
field.String("domain").Optional(),
field.Enum("plan").
Values("free", "pro", "enterprise").
Default("free"),
field.Bool("active").Default(true),
field.JSON("settings", map[string]any{}).Optional(),
field.Time("created_at").Default(time.Now).Immutable(),
field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now),
}
}
func (Tenant) Edges() []ent.Edge {
return []ent.Edge{
edge.To("users", User.Type),
edge.To("dashboards", Dashboard.Type),
edge.To("data_sources", DataSource.Type),
edge.To("saved_queries", SavedQuery.Type),
}
}
RLS Policies¶
The server applies these policies:
-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE dashboards ENABLE ROW LEVEL SECURITY;
ALTER TABLE data_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE saved_queries ENABLE ROW LEVEL SECURITY;
-- Create isolation policies
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant', true)::int);
CREATE POLICY tenant_isolation_dashboards ON dashboards
USING (tenant_id = current_setting('app.current_tenant', true)::int);
-- Admin bypass (for system operations)
CREATE POLICY admin_bypass ON users
USING (current_setting('app.is_admin', true)::boolean = true);
Creating Tenants¶
Via API¶
curl -X POST https://dashforge.example.com/api/v1/admin/tenants \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "acme-corp",
"name": "Acme Corporation",
"plan": "pro"
}'
Via Database¶
INSERT INTO tenants (slug, name, plan, active, created_at, updated_at)
VALUES ('acme-corp', 'Acme Corporation', 'pro', true, NOW(), NOW());
User-Tenant Relationship¶
Users belong to a single tenant:
JWT tokens include the tenant ID:
Cross-Tenant Operations¶
Some operations need to work across tenants:
System Admin¶
// Bypass RLS for system operations
func (s *Server) listAllTenants(ctx context.Context) ([]*ent.Tenant, error) {
// Set admin mode
_, _ = s.db.DB().ExecContext(ctx, "SET LOCAL app.is_admin = true")
return s.db.Client().Tenant.Query().All(ctx)
}
Tenant Switching¶
Admins can switch tenant context:
curl https://dashforge.example.com/api/v1/dashboards \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-ID: 2"
Subdomain Routing¶
Configure DNS and routing for tenant subdomains:
Nginx Configuration¶
server {
listen 443 ssl;
server_name ~^(?<tenant>.+)\.dashforge\.example\.com$;
location / {
proxy_pass http://dashforge;
proxy_set_header Host $host;
proxy_set_header X-Tenant-Slug $tenant;
}
}
Tenant Plans¶
Configure features by plan:
plans:
free:
max_users: 5
max_dashboards: 10
features:
- basic_charts
pro:
max_users: 50
max_dashboards: 100
features:
- basic_charts
- advanced_charts
- api_access
enterprise:
max_users: unlimited
max_dashboards: unlimited
features:
- all
Migration Considerations¶
Migrating to Multi-Tenancy¶
If you're adding multi-tenancy to an existing single-tenant installation:
- Create a default tenant
- Assign all existing data to the default tenant
- Enable RLS
-- Create default tenant
INSERT INTO tenants (slug, name, plan)
VALUES ('default', 'Default Tenant', 'enterprise');
-- Assign existing users
UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL;
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
Disabling Multi-Tenancy¶
To run single-tenant:
Without RLS, all data is accessible to all authenticated users.