Skip to main content

Multi-Tenancy Architecture

Jet Admin is built from the ground up as a multi-tenant platform, enabling organizations to manage multiple isolated workspaces (tenants) with complete data separation, independent configurations, and granular access control.


πŸ“‹ Table of Contents​


What is Multi-Tenancy?​

Multi-tenancy is an architecture where a single instance of Jet Admin serves multiple independent organizations (tenants). Each tenant has:

  • βœ… Complete data isolation - Cannot access other tenants' data
  • βœ… Independent configuration - Custom datasources, workflows, dashboards
  • βœ… Separate user management - Own users, roles, and permissions
  • βœ… Dedicated resources - Isolated execution environments

Use Cases​

ScenarioDescription
SaaS PlatformServe multiple customer organizations from one installation
EnterpriseSeparate departments (HR, Finance, Engineering) with isolated data
AgencyManage multiple clients with strict data separation
DevelopmentIsolate dev/staging/production environments

Tenant Architecture​

Database Schema​

// Core tenant model
model tblTenants {
tenantID String @id @default(gen_random_uuid())
tenantTitle String
tenantDBURL String? // Optional: dedicated DB per tenant
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?

// Tenant-scoped resources
datasources tblDatasources[]
workflows tblWorkflows[]
dashboards tblDashboards[]
widgets tblWidgets[]
queries tblDataQueries[]
users tblUsersTenantsRelationship[]
apiKeys tblAPIKeys[]
roles tblRoles[]
}

Tenant Hierarchy​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Jet Admin Platform β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”β”‚
β”‚ β”‚ Tenant A β”‚ β”‚ Tenant B β”‚ β”‚Tenant Cβ”‚β”‚
β”‚ β”‚ ─────────│ β”‚ ─────────│ │────────││
β”‚ β”‚ β€’ Users β”‚ β”‚ β€’ Users β”‚ β”‚ β€’ Usersβ”‚β”‚
β”‚ β”‚ β€’ Data β”‚ β”‚ β€’ Data β”‚ β”‚ β€’ Data β”‚β”‚
β”‚ β”‚ β€’ Config β”‚ β”‚ β€’ Config β”‚ β”‚ β€’ Configβ”‚β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data Isolation​

Isolation Strategies​

Jet Admin implements logical isolation through tenant scoping:

1. Query-Level Isolation​

All database queries automatically include tenant filtering:

// Every query includes tenantID filter
const datasources = await prisma.tblDatasources.findMany({
where: {
tenantID: req.tenantID, // ← Automatic tenant scoping
deletedAt: null
}
});

2. Middleware Enforcement​

Tenant context is established via middleware:

// middleware/tenant.middleware.js
async function tenantMiddleware(req, res, next) {
const tenantId = req.headers['x-tenant-id'] || req.params.tenantID;

// Verify tenant exists
const tenant = await prisma.tblTenants.findUnique({
where: { tenantID: tenantId, deletedAt: null }
});

if (!tenant) {
throw new AppError('Invalid tenant', 'INVALID_TENANT', 400);
}

req.tenant = tenant;
req.tenantID = tenantId;
next();
}

3. Foreign Key Constraints​

Database-level enforcement through foreign keys:

model tblDatasources {
id String @id @default(gen_random_uuid())
tenantID String // ← Foreign key to tenants
tenant tblTenants @relation(fields: [tenantID], references: [tenantID])
// ... other fields
}

Isolation Guarantees​

LevelGuarantee
APIAll endpoints require tenant context
DatabaseAll queries filtered by tenantID
WorkflowWorkflow execution tenant-scoped
Real-timeSocket rooms tenant-isolated
CacheCache keys include tenant prefix

User Management​

User-Tenant Relationship​

Users can belong to multiple tenants with different roles:

model tblUsers {
userID String @id @default(gen_random_uuid())
email String @unique
firebaseID String @unique

// Many-to-many with tenants
tenants tblUsersTenantsRelationship[]
}

model tblUsersTenantsRelationship {
id String @id @default(gen_random_uuid())
userID String
tenantID String
roleID String

user tblUsers @relation(fields: [userID], references: [userID])
tenant tblTenants @relation(fields: [tenantID], references: [tenantID])
role tblRoles @relation(fields: [roleID], references: [roleID])

@@unique([userID, tenantID]) // One role per tenant per user
}

User Journey​

Switching Tenants​

Users can switch between tenants:

// Frontend: Tenant switcher component
function TenantSwitcher() {
const { tenants, currentTenant, selectTenant } = useTenant();

return (
<select
value={currentTenant?.tenantID}
onChange={(e) => {
const tenant = tenants.find(t => t.tenantID === e.target.value);
selectTenant(tenant);
}}
>
{tenants.map(tenant => (
<option key={tenant.tenantID} value={tenant.tenantID}>
{tenant.tenantTitle}
</option>
))}
</select>
);
}

Role-Based Access Control (RBAC)​

RBAC Model​

model tblRoles {
roleID String @id @default(gen_random_uuid())
tenantID String
roleName String // e.g., "Admin", "Editor", "Viewer"

permissions tblPermissions[]
users tblUsersTenantsRelationship[]
}

model tblPermissions {
permissionID String @id @default(gen_random_uuid())
roleID String
permissionName String // e.g., "datasource:create"
resource String // e.g., "datasource"
action String // e.g., "create"

role tblRoles @relation(fields: [roleID], references: [roleID])
}

Default Roles​

RolePermissionsUse Case
AdminAll permissionsPlatform administrators
EditorCreate, read, updateContent creators
ViewerRead-onlyStakeholders, auditors

Permission System​

Permissions follow a resource:action pattern:

const PERMISSIONS = {
// Datasources
'datasource:list': 'View datasource list',
'datasource:read': 'View datasource details',
'datasource:create': 'Create new datasources',
'datasource:update': 'Edit existing datasources',
'datasource:delete': 'Remove datasources',

// Workflows
'workflow:list': 'View workflow list',
'workflow:read': 'View workflow details',
'workflow:create': 'Create new workflows',
'workflow:update': 'Edit existing workflows',
'workflow:execute': 'Execute workflows',
'workflow:delete': 'Delete workflows',

// Dashboards
'dashboard:list': 'View dashboards',
'dashboard:read': 'View dashboard',
'dashboard:create': 'Create dashboards',
'dashboard:update': 'Edit dashboards',
'dashboard:delete': 'Delete dashboards',

// Users & Roles
'user:list': 'View users',
'user:create': 'Invite users',
'user:update': 'Update user roles',
'user:delete': 'Remove users',
'role:manage': 'Manage roles and permissions',
};

Permission Middleware​

// middleware/permission.middleware.js
function requirePermission(permission) {
return async (req, res, next) => {
const { user, tenantID } = req;

// Get user's roles in tenant
const relationships = await prisma.tblUsersTenantsRelationship.findMany({
where: { userID: user.uid, tenantID },
include: { role: { include: { permissions: true } } }
});

// Check if any role has required permission
const hasPermission = relationships.some(rel =>
rel.role.permissions.some(p => p.permissionName === permission)
);

if (!hasPermission) {
return res.status(403).json({
error: 'Permission denied',
code: 'PERMISSION_DENIED',
required: permission
});
}

next();
};
}

// Usage in routes
router.post(
'/',
authMiddleware,
tenantMiddleware,
requirePermission('datasource:create'),
datasourceController.createDatasource
);

API Key Authentication​

For machine-to-machine communication, Jet Admin supports API key authentication:

API Key Model​

model tblAPIKeys {
keyID String @id @default(gen_random_uuid())
tenantID String
keyName String
keyHash String // Hashed key value
createdAt DateTime @default(now())
expiresAt DateTime?

tenant tblTenants @relation(fields: [tenantID], references: [tenantID])
roleMappings tblAPIKeyRoleMappings[]
}

Creating API Keys​

// API: Generate API key
async function createAPIKey(tenantID, name, permissions) {
// Generate random key
const rawKey = `jet_${crypto.randomBytes(32).toString('hex')}`;

// Hash for storage
const keyHash = await bcrypt.hash(rawKey, 10);

// Store in database
const apiKey = await prisma.tblAPIKeys.create({
data: {
tenantID,
keyName: name,
keyHash,
roleMappings: {
create: permissions.map(permission => ({
permissionName: permission
}))
}
}
});

// Return raw key once (never stored)
return {
keyID: apiKey.keyID,
key: rawKey, // Only shown once
expiresAt: apiKey.expiresAt
};
}

Using API Keys​

# API request with API key
curl -X GET https://api.jetadmin.io/api/v1/tenants/:tenantID/datasources \
-H "Authorization: Bearer jet_abc123..." \
-H "x-tenant-id: tenant-uuid"

API Key Middleware​

async function apiKeyMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(); // Try Firebase auth instead
}

const key = authHeader.split(' ')[1];

// Find API key
const apiKey = await prisma.tblAPIKeys.findFirst({
where: { tenantID: req.tenantID },
include: { roleMappings: true }
});

if (!apiKey || !await bcrypt.compare(key, apiKey.keyHash)) {
return res.status(401).json({ error: 'Invalid API key' });
}

// Attach permissions to request
req.apiKey = apiKey;
req.permissions = apiKey.roleMappings.map(m => m.permissionName);

next();
}

Security Model​

Defense in Depth​

Security Layers​

LayerProtection
TransportHTTPS/TLS encryption
AuthenticationFirebase Auth or API Keys
AuthorizationRBAC permissions
Tenant IsolationQuery-level filtering
Input ValidationRequest sanitization
Audit LoggingAll actions logged
EncryptionCredentials encrypted at rest

Audit Logging​

All tenant-scoped actions are logged:

model tblAuditLogs {
logID String @id @default(gen_random_uuid())
tenantID String
userID String
action String
resource String
timestamp DateTime @default(now())
ipAddress String
userAgent String
metadata Json
}

Logged Events:

  • User login/logout
  • Datasource CRUD operations
  • Workflow execution
  • Dashboard modifications
  • Role changes
  • API key usage

Best Practices​

Tenant Organization​

βœ… Use Clear Naming

Good: "Acme Corp - Production"
Bad: "Tenant 1"

βœ… Separate Environments

Tenant: "MyApp - Development"
Tenant: "MyApp - Staging"
Tenant: "MyApp - Production"

βœ… Document Purpose

{
tenantTitle: 'Engineering Team',
description: 'Engineering department tools and dashboards',
metadata: {
department: 'engineering',
costCenter: 'ENG-001'
}
}

Access Control​

βœ… Principle of Least Privilege

  • Start with minimal permissions
  • Grant additional access as needed
  • Review permissions regularly

βœ… Use Roles, Not Individual Permissions

// Good: Assign predefined role
await assignRoleToUser(userId, 'editor');

// Avoid: Assign individual permissions
await assignPermissions(userId, [
'datasource:read',
'workflow:read',
// ... 20 more
]);

βœ… Regular Audits

  • Review user access quarterly
  • Remove inactive users
  • Rotate API keys every 90 days

Security​

βœ… Enable MFA

  • Require multi-factor authentication
  • Especially for admin accounts

βœ… Monitor Suspicious Activity

// Alert on unusual patterns
if (loginAttempts > 5 ||
ipChange ||
unusualHour) {
alertSecurityTeam();
}

βœ… Encrypt Sensitive Data

  • Use field-level encryption for PII
  • Encrypt datasource credentials
  • Secure key management

Performance​

βœ… Index Tenant Queries

model tblDatasources {
@@index([tenantID, deletedAt])
@@index([tenantID, createdAt])
}

βœ… Partition Large Tables

-- Partition audit logs by tenant
CREATE TABLE audit_logs (
logID uuid,
tenantID uuid,
...
) PARTITION BY LIST (tenantID);

βœ… Cache Tenant Context

// Cache tenant data
const tenant = await cache.get(`tenant:${tenantID}`);
if (!tenant) {
const tenant = await db.tblTenants.findUnique(...);
await cache.set(`tenant:${tenantID}`, tenant, 300);
}

Next Steps​