Skip to content

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):

pkg/http/middleware/auth_middleware.go
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):

pkg/service/flow_service.go
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:

pkg/http/controller/flows_controller.go
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:

Terminal window
# Switch to company B
POST /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 queries
db.flows.createIndex({ company_id: 1, created_at: -1 })
// Fast queries by user and company
db.audit_logs.createIndex({ company_id: 1, user_id: 1, timestamp: -1 })
// Fast company-wide searches
db.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

Terminal window
# Alice (company_a) tries to read company_b flow
curl -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 Found

Scenario 2: User with API Key from Company A Uses It for Company B

Terminal window
# API key JWT contains: { company_id: "company_a", ... }
# Alice tries to access company_b with company_a key
curl -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 Found

Scenario 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 implicit

Scenario 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 context
2. 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 FOUND
5. Module fails: "integration not configured"
Result: Cross-tenant credential theft prevented.

Audit Logging & Isolation

Audit logs are scoped to company and automatically filtered:

Terminal window
# Get all audit logs for company_a
GET /api/v1/audit?page=1
# Middleware ensures:
# - Only logs from company_a returned
# - Even if attacker manipulates company_id parameter, server uses session company_id

Audit 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 only
apiVersion: v1
kind: Pod
metadata:
name: flow8-company-a
spec:
containers:
- name: flow8
env:
- name: MONGODB_DATABASE
value: "flow8_company_a" # Separate database
- name: SINGLETON_COMPANY_ID
value: "company_a" # Hard-coded company

Enforcement:

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)

Terminal window
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)

Terminal window
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

Terminal window
# Alice (admin in company_a) can see Bob's flows
GET /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 only

Isolation is Transparent

Users don’t need to specify company_id in requests (it’s inferred from session):

Terminal window
# ❌ WRONG: Trying to specify company_id in request
POST /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 ignored

This 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:

Terminal window
# Admin deletes user Alice from company_a
DELETE /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 deployment

Configured via DNS/routing:

# company_a EU setup
apiVersion: v1
kind: ConfigMap
metadata:
name: flow8-company-a-config
data:
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

test-multi-tenancy.sh
# Create two companies
COMPANY_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 flows
FLOW_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.