Multi-Tenancy
Multi-Tenancy Model
flow8 implements company-based isolation where every entity (flow, user, play, audit log, etc.) is tagged with a company_id. This ensures complete data segregation between organizations while allowing the same infrastructure to serve many customers.
Architecture
Company-Based Scoping
Every significant MongoDB collection includes a company_id field:
type DBFlow struct { CompanyID primitive.ObjectID // ... other fields}
type DBUser struct { CompanyID primitive.ObjectID // ... other fields}
type DBAuditLog struct { CompanyID primitive.ObjectID // ... other fields}
type DBPlay struct { CompanyID primitive.ObjectID // ... other fields}Query Filtering at Data Layer
All database queries are automatically filtered by company_id. This is enforced at the middleware and service layers:
Middleware (request context):
func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Header.Get("Cookie") // or JWT from Authorization header user := m.getUser(session)
// Extract current company from session or JWT companyID := user.CurrentCompanyID
// Store in context ctx := context.WithValue(r.Context(), "company_id", companyID) ctx = context.WithValue(ctx, "user_id", user.ID)
next.ServeHTTP(w, r.WithContext(ctx)) })}Service layer (query builder):
func (s *FlowService) GetFlows(ctx context.Context) ([]*model.DBFlow, error) { companyID := ctx.Value("company_id").(primitive.ObjectID)
flows, _ := s.db.Collection("flows").Find(ctx, bson.M{ "company_id": companyID, // ALWAYS include company filter })
return flows, nil}REST controller:
func (c *FlowsController) List(w http.ResponseWriter, r *http.Request) { // Context already has company_id from middleware flows, _ := c.flowService.GetFlows(r.Context()) json.NewEncoder(w).Encode(flows)}Multi-Company User Support
A single user can be associated with multiple companies, each with different roles:
type DBUser struct { ID primitive.ObjectID Email string PasswordHash string CompanyAssociations []CompanyRole}
type CompanyRole struct { CompanyID primitive.ObjectID Role string // "admin", "flow_executor", etc. JoinedAt time.Time}
// Example:// alice@company.com:// - CompanyRole{CompanyID: company_a, Role: "admin"}// - CompanyRole{CompanyID: company_b, Role: "flow_executor"}User document in MongoDB:
{ "_id": ObjectId("user_123"), "email": "alice@company.com", "password_hash": "[bcrypt hash]", "company_associations": [ { "company_id": ObjectId("company_a"), "role": "admin", "joined_at": ISODate("2026-01-15T10:00:00Z") }, { "company_id": ObjectId("company_b"), "role": "flow_executor", "joined_at": ISODate("2026-03-01T14:30:00Z") } ], "current_company_id": ObjectId("company_a"), // Active company "created_at": ISODate("2026-01-15T10:00:00Z")}Company Switching
Users can switch between companies via API:
# Switch to company BPOST /api/v1/auth/switch-company{ "company_id": "company_b"}
Response:{ "message": "Switched to company B", "current_company": { "id": "company_b", "name": "Company B", "role": "flow_executor" }}Implementation:
func (c *AuthController) SwitchCompany(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(primitive.ObjectID) targetCompanyID := parseRequest(r).CompanyID
// Verify user is associated with target company user, _ := c.userService.GetUser(userID) if !user.HasCompany(targetCompanyID) { w.WriteHeader(http.StatusForbidden) return }
// Update session session := c.sessionService.GetSession(r.Context()) session.CompanyID = targetCompanyID c.sessionService.Save(session)
w.Header().Set("Set-Cookie", "sid=...;Path=/;HttpOnly") json.NewEncoder(w).Encode(map[string]string{"message": "switched"})}Isolation Mechanisms
Session-Based Isolation
Session cookies are company-scoped:
Session context:{ session_id: "sid_abc123...", user_id: "user_456", company_id: "company_a", // Determines accessible data roles: ["admin"], expires_at: "2026-04-04T11:23:45Z"}All requests using this session can only access data belonging to company_a.
JWT/API Key Isolation
API keys include the company in their JWT claims:
{ "sub": "user_456", "company_id": "company_a", // Baked into token "key_id": "key_789", "scope": ["api"], "iat": 1712244000, "exp": 1743780000}The key cannot be used to access data from other companies, even if presented to a different instance.
Query-Level Enforcement
Every database query includes an implicit company filter. There is no way to read or write data from a different company:
// Even if a developer forgets to add company_id filter:flows, _ := s.db.Collection("flows").Find(ctx, bson.M{})
// The middleware/service layer intercepts and injects:flows, _ := s.db.Collection("flows").Find(ctx, bson.M{ "company_id": ctx.Value("company_id"), // Injected by framework})Index Optimization
Indexes are designed to make company filtering efficient:
// Primary index for most queriesdb.flows.createIndex({ company_id: 1, created_at: -1 })
// Fast queries by user and companydb.audit_logs.createIndex({ company_id: 1, user_id: 1, timestamp: -1 })
// Fast company-wide searchesdb.flows.createIndex({ company_id: 1, name: 1 })Query performance is not degraded by company filtering because itβs the leading index field.
Data Cross-Tenancy Prevention
Scenario 1: User Attempts to Access Another Companyβs Data
# Alice (company_a) tries to read company_b flowcurl -H "Cookie: sid=alice_session..." \ http://localhost:4454/api/v1/flows/flow_from_company_b
# Request intercepted by middleware:# 1. Extract session, get company_a# 2. Query: db.flows.findOne({ _id: flow_from_company_b, company_id: company_a })# 3. Result: NOT FOUND# 4. Response: 404 Not Found
Response: 404 Not FoundScenario 2: User with API Key from Company A Uses It for Company B
# API key JWT contains: { company_id: "company_a", ... }# Alice tries to access company_b with company_a keycurl -H "Authorization: Bearer eyJjb..." \ http://localhost:4454/api/v1/flows/flow_from_company_b
# Middleware validates JWT:# 1. Extract company_id from JWT: company_a# 2. Query: db.flows.findOne({ _id: flow_from_company_b, company_id: company_a })# 3. Result: NOT FOUND# 4. Response: 404
Response: 404 Not FoundScenario 3: SQL Injection / NoSQL Injection
Even if an attacker injects a query, the company filter prevents cross-tenant access:
// Attacker tries: { $ne: null } to bypass filter// Request: ?filter={"company_id": {"$ne": null}}
// Server processes as:db.flows.find({ company_id: "company_a", // Enforced by middleware filter: { "$ne": null } // Attacker's input})
// Result: Only returns flows from company_a, because company_id filter is implicitScenario 4: OAuth2 Token Sharing Between Companies
User at company_a obtains access token to Slack integration.Tries to use it in company_b flow.
Flow execution:1. Play created in company_b context2. Module tries to access Slack token: db.links.findOne({ _id: token_id, company_id: company_b })3. Token exists but belongs to company_a (company_id: company_a)4. Query returns: NOT FOUND5. Module fails: "integration not configured"
Result: Cross-tenant credential theft prevented.Audit Logging & Isolation
Audit logs are scoped to company and automatically filtered:
# Get all audit logs for company_aGET /api/v1/audit?page=1
# Middleware ensures:# - Only logs from company_a returned# - Even if attacker manipulates company_id parameter, server uses session company_idAudit log query (backend):
func (s *AuditService) GetLogs(ctx context.Context, filters *AuditFilter) ([]*model.DBAuditLog, error) { companyID := ctx.Value("company_id").(primitive.ObjectID)
query := bson.M{ "company_id": companyID, // Always filtered }
// Append user-provided filters if filters.ActionType != "" { query["action_type"] = filters.ActionType }
logs, _ := s.db.Collection("audit_logs").Find(ctx, query) return logs, nil}Multi-Tenancy at Scale
Horizontal Scaling
flow8 is stateless, so multiple instances can serve many companies:
ββββββββββββββββββββββββββββββββββββββββββββββββ Reverse Proxy ββ (Load Balancer, WAF) βββββββββββββ¬βββββββββββββββββββββββ¬ββββββββββββ β β ββββΌβββ βββββΌβββ β i1 β β i2 β β4454 β β 4454 β ββββ¬βββ βββββ¬βββ β (both connect) β ββββββββββββ¬βββββββββββ β βββββΌβββββββββββββββββ β MongoDB Replica β β Set (enterprise) β β + backup stream β ββββββββββββββββββββββEach instance:
- Serves all companies
- Enforces company_id filtering
- No cross-tenant data contamination
- Scales horizontally (add more instances as load increases)
Dedicated Tenants (Premium)
For sensitive organizations, deploy a dedicated instance (single-tenant):
# Deploy instance for Company A onlyapiVersion: v1kind: Podmetadata: name: flow8-company-aspec: containers: - name: flow8 env: - name: MONGODB_DATABASE value: "flow8_company_a" # Separate database - name: SINGLETON_COMPANY_ID value: "company_a" # Hard-coded companyEnforcement:
func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // ... if os.Getenv("SINGLETON_COMPANY_ID") != "" { // Force single company companyID = os.Getenv("SINGLETON_COMPANY_ID") } // ... })}API Examples
Create Flow (Company-Scoped)
POST /api/v1/flows{ "name": "ProcessInvoice", "flowlets": [ ... ]}
# Behind the scenes:# 1. Extract company_id from session/JWT# 2. Create flow with: { ..., company_id: "company_a" }# 3. Return flow (company_a is implicit, not shown in response)List Flows (Automatic Company Filter)
GET /api/v1/flows?page=1&limit=50
# Behind the scenes:# db.flows.find({ company_id: "company_a" }).limit(50)
Response:{ "flows": [ { "id": "flow_123", "name": "ProcessInvoice", "company_id": "company_a" }, { "id": "flow_456", "name": "SendEmail", "company_id": "company_a" } ], "total": 2, "page": 1, "limit": 50}Access Another Userβs Flows in Same Company
# Alice (admin in company_a) can see Bob's flowsGET /api/v1/flows?user_id=bob&page=1
# Query:# db.flows.find({# company_id: "company_a", // Enforced# created_by: "user_bob" // User-provided filter# })
# Result: Shows flows created by Bob in company_a onlyIsolation is Transparent
Users donβt need to specify company_id in requests (itβs inferred from session):
# β WRONG: Trying to specify company_id in requestPOST /api/v1/flows{ "name": "MyFlow", "company_id": "company_b" # Ignored!}
# β
Server creates flow with session's company_id, not the provided one# Result: Flow created in company_a (session company), company_id="company_b" in request ignoredThis prevents users from accidentally (or maliciously) creating flows in wrong companies.
Compliance & Data Residency
GDPR Right to Deletion
Delete all user data from one company:
# Admin deletes user Alice from company_aDELETE /api/v1/users/user_456?company_id=company_a
# Result:# - User record removed from company_a_users# - API keys revoked# - Flows authored by Alice transferred to admin (ownership change)# - Audit logs preserved (not deleted)# - Alice's data in company_b unaffected (separate company)Data Residency Isolation
For compliance (GDPR, HIPAA), isolate data by region:
EU Deployment:ββ MongoDB EU (ireland-1)ββ S3 EU (eu-west-1)ββ flow8 instances (eu-*)
US Deployment:ββ MongoDB US (us-east-1)ββ S3 US (us-east-1)ββ flow8 instances (us-*)
Routing:- company_a (EU) β routes to EU deployment- company_b (US) β routes to US deploymentConfigured via DNS/routing:
# company_a EU setupapiVersion: v1kind: ConfigMapmetadata: name: flow8-company-a-configdata: MONGODB_URI: "mongodb://mongo-eu:27017" STORAGE_BUCKET: "s3-eu:flow8-artifacts"Each companyβs data is isolated to its region, never crosses borders.
Testing Multi-Tenancy
Unit Test Example
func TestMultiTenancyIsolation(t *testing.T) { // Create two companies companyA, _ := createCompany("Company A") companyB, _ := createCompany("Company B")
// Create users in each company userA, _ := createUser("alice@a.com", companyA.ID) userB, _ := createUser("bob@b.com", companyB.ID)
// Create flows in each company flowA, _ := createFlow("Flow A", companyA.ID, userA.ID) flowB, _ := createFlow("Flow B", companyB.ID, userB.ID)
// Alice tries to read Bob's flow (should fail) ctx := contextWithCompany(companyA.ID) _, err := flowService.GetFlow(ctx, flowB.ID) assert.Error(t, err) // NOT FOUND assert.Equal(t, 404, err.StatusCode)
// Alice can read her own flow flow, _ := flowService.GetFlow(ctx, flowA.ID) assert.Equal(t, "Flow A", flow.Name)}Integration Test Example
# Create two companiesCOMPANY_A=$(curl -X POST http://localhost:4454/api/v1/companies \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -d '{"name":"Company A"}' | jq -r .id)
COMPANY_B=$(curl -X POST http://localhost:4454/api/v1/companies \ -d '{"name":"Company B"}' | jq -r .id)
# Create flowsFLOW_A=$(curl -X POST http://localhost:4454/api/v1/flows \ -H "Authorization: Bearer $COMPANY_A_TOKEN" \ -d '{"name":"Flow A"}' | jq -r .id)
FLOW_B=$(curl -X POST http://localhost:4454/api/v1/flows \ -H "Authorization: Bearer $COMPANY_B_TOKEN" \ -d '{"name":"Flow B"}' | jq -r .id)
# Try to read Flow B with Company A's token (should fail)STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $COMPANY_A_TOKEN" \ http://localhost:4454/api/v1/flows/$FLOW_B)
test "$STATUS" = "404" || echo "FAIL: Expected 404, got $STATUS"Multi-tenancy isolation is core to flow8βs security model and is thoroughly tested.