Multi-Client Isolation Strategy
Overview
Multi-tenancy ensures that each client (tenant) operates in complete isolation, with no cross-tenant data leakage or unauthorized access. This is critical for security, compliance, and data privacy.
Tenancy Model
Tenant Hierarchy
Organization (Platform)
├── Tenant A (Client A)
│ ├── Business Unit: Finance
│ ├── Business Unit: HR
│ └── Business Unit: Sales
├── Tenant B (Client B)
│ ├── Business Unit: Engineering
│ └── Business Unit: Marketing
└── Tenant C (Client C)
└── Business Unit: Operations
Tenant Identification
// src/tenancy/tenant-resolver.ts
export class TenantResolver {
/**
* Resolve tenant from GitHub organization
*/
async fromGitHubOrg(orgName: string): Promise<string> {
const mapping = await db('tenant_github_mappings')
.where('github_org', orgName)
.first();
if (!mapping) {
throw new Error(`No tenant mapping found for GitHub org: ${orgName}`);
}
return mapping.tenant_id;
}
/**
* Resolve tenant from Terraform Cloud organization
*/
async fromTfcOrg(tfcOrg: string): Promise<string> {
const mapping = await db('tenant_tfc_mappings')
.where('tfc_org', tfcOrg)
.first();
if (!mapping) {
throw new Error(`No tenant mapping found for TFC org: ${tfcOrg}`);
}
return mapping.tenant_id;
}
/**
* Resolve tenant from GCP organization ID
*/
async fromGcpOrg(gcpOrgId: string): Promise<string> {
const mapping = await db('tenant_gcp_mappings')
.where('gcp_org_id', gcpOrgId)
.first();
if (!mapping) {
throw new Error(`No tenant mapping found for GCP org: ${gcpOrgId}`);
}
return mapping.tenant_id;
}
/**
* Resolve tenant from authenticated user
*/
async fromUser(userId: string): Promise<string> {
const user = await db('users')
.where('id', userId)
.first();
if (!user) {
throw new Error(`User not found: ${userId}`);
}
return user.tenant_id;
}
}
Tenant Configuration
// Database schema
interface Tenant {
id: string; // UUID
name: string; // Display name (e.g., "Acme Corp")
slug: string; // URL-safe identifier (e.g., "acme-corp")
status: 'active' | 'suspended' | 'deleted';
created_at: Date;
settings: {
backstage_namespace: string; // Backstage namespace for entities
github_orgs: string[]; // Allowed GitHub organizations
tfc_orgs: string[]; // Allowed Terraform Cloud organizations
gcp_org_ids: string[]; // Allowed GCP organization IDs
};
permissions: {
onboarding_enabled: boolean;
max_business_units: number;
max_entities: number;
};
rate_limits: {
onboarding_per_hour: number;
api_requests_per_minute: number;
};
}
Isolation Layers
1. Database Isolation
Row-Level Security (PostgreSQL)
-- Enable RLS on entities table
ALTER TABLE entities ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see entities from their tenant
CREATE POLICY tenant_isolation_policy ON entities
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Policy: System admin can see all entities
CREATE POLICY admin_policy ON entities
FOR ALL
USING (current_setting('app.user_role') = 'admin');
Application-Level Filtering
// src/database/tenant-aware-query-builder.ts
export class TenantAwareQueryBuilder {
constructor(private tenantId: string) {}
/**
* Wrap Knex queries with tenant filter
*/
query<T>(tableName: string): Knex.QueryBuilder<T> {
return db(tableName).where('tenant_id', this.tenantId);
}
/**
* Insert with automatic tenant_id
*/
async insert<T>(tableName: string, data: Partial<T>): Promise<string> {
const [id] = await db(tableName).insert({
...data,
tenant_id: this.tenantId,
}).returning('id');
return id;
}
/**
* Update with tenant validation
*/
async update<T>(tableName: string, id: string, data: Partial<T>): Promise<void> {
const updated = await db(tableName)
.where('id', id)
.where('tenant_id', this.tenantId)
.update(data);
if (updated === 0) {
throw new Error('Record not found or access denied');
}
}
/**
* Delete with tenant validation
*/
async delete(tableName: string, id: string): Promise<void> {
const deleted = await db(tableName)
.where('id', id)
.where('tenant_id', this.tenantId)
.delete();
if (deleted === 0) {
throw new Error('Record not found or access denied');
}
}
}
Backstage Catalog Isolation
// src/catalog/tenant-aware-catalog.ts
export class TenantAwareCatalog {
constructor(private tenantId: string) {}
/**
* Get entities with tenant filter
*/
async getEntities(filter?: EntityFilter): Promise<Entity[]> {
return await db('entities')
.where('tenant_id', this.tenantId)
.where(builder => {
if (filter?.kind) {
builder.where('kind', filter.kind);
}
if (filter?.namespace) {
builder.where('metadata->namespace', filter.namespace);
}
// Additional filters...
});
}
/**
* Insert entity with tenant namespace
*/
async insertEntity(entity: Entity): Promise<string> {
// Force tenant namespace
entity.metadata.namespace = await this.getTenantNamespace();
// Add tenant label
entity.metadata.labels = {
...entity.metadata.labels,
tenant: this.tenantId,
};
const [id] = await db('entities').insert({
...this.serializeEntity(entity),
tenant_id: this.tenantId,
}).returning('id');
return id;
}
private async getTenantNamespace(): Promise<string> {
const tenant = await db('tenants').where('id', this.tenantId).first();
return tenant.settings.backstage_namespace;
}
}
2. Backstage Namespace Isolation
Namespace Strategy
// Tenant namespace format: tenant-{slug}
// Example: tenant-acme-corp, tenant-globex
export class NamespaceManager {
/**
* Generate tenant namespace from tenant slug
*/
generateNamespace(tenantSlug: string): string {
return `tenant-${tenantSlug}`;
}
/**
* Validate entity belongs to tenant namespace
*/
validateEntityNamespace(entity: Entity, tenantId: string): boolean {
const expectedNamespace = this.getExpectedNamespace(tenantId);
return entity.metadata.namespace === expectedNamespace;
}
/**
* Rewrite entity to force tenant namespace
*/
enforceNamespace(entity: Entity, tenantId: string): Entity {
const namespace = this.getExpectedNamespace(tenantId);
return {
...entity,
metadata: {
...entity.metadata,
namespace,
},
};
}
private getExpectedNamespace(tenantId: string): string {
// Lookup tenant slug
const tenant = db('tenants').where('id', tenantId).first();
return this.generateNamespace(tenant.slug);
}
}
Entity Metadata Enforcement
// All entities get tenant-specific metadata
export class EntityMetadataEnforcer {
enforceMetadata(entity: Entity, tenantId: string): Entity {
return {
...entity,
metadata: {
...entity.metadata,
// Force tenant namespace
namespace: this.namespaceManager.getExpectedNamespace(tenantId),
// Add tenant labels
labels: {
...entity.metadata.labels,
tenant: tenantId,
'tenant-managed': 'true',
},
// Add tenant annotations
annotations: {
...entity.metadata.annotations,
'backstage.io/tenant-id': tenantId,
},
},
};
}
}
3. External System Isolation
GitHub Isolation
// src/integrations/github/tenant-aware-client.ts
export class TenantAwareGitHubClient {
constructor(
private tenantId: string,
private githubClient: Octokit
) {}
/**
* Get repository, validate belongs to tenant
*/
async getRepository(owner: string, repo: string): Promise<Repository> {
// Validate owner belongs to tenant
const allowedOrgs = await this.getAllowedOrgs();
if (!allowedOrgs.includes(owner)) {
throw new ForbiddenError(`Organization ${owner} not allowed for this tenant`);
}
return await this.githubClient.repos.get({ owner, repo });
}
/**
* List repositories, filtered to tenant orgs
*/
async listRepositories(): Promise<Repository[]> {
const allowedOrgs = await this.getAllowedOrgs();
const repos = await Promise.all(
allowedOrgs.map(org =>
this.githubClient.repos.listForOrg({ org })
)
);
return repos.flat().map(r => r.data);
}
private async getAllowedOrgs(): Promise<string[]> {
const tenant = await db('tenants').where('id', this.tenantId).first();
return tenant.settings.github_orgs;
}
}
Terraform Cloud Isolation
// src/integrations/tfc/tenant-aware-client.ts
export class TenantAwareTfcClient {
constructor(
private tenantId: string,
private tfcClient: TerraformCloudClient
) {}
/**
* Get workspace, validate belongs to tenant
*/
async getWorkspace(workspaceId: string): Promise<Workspace> {
const workspace = await this.tfcClient.getWorkspace(workspaceId);
// Validate organization belongs to tenant
const allowedOrgs = await this.getAllowedTfcOrgs();
if (!allowedOrgs.includes(workspace.organization.name)) {
throw new ForbiddenError(`TFC org ${workspace.organization.name} not allowed for this tenant`);
}
return workspace;
}
/**
* List workspaces, filtered to tenant orgs
*/
async listWorkspaces(): Promise<Workspace[]> {
const allowedOrgs = await this.getAllowedTfcOrgs();
const workspaces = await Promise.all(
allowedOrgs.map(org =>
this.tfcClient.listWorkspaces(org)
)
);
return workspaces.flat();
}
private async getAllowedTfcOrgs(): Promise<string[]> {
const tenant = await db('tenants').where('id', this.tenantId).first();
return tenant.settings.tfc_orgs;
}
}
GCP Isolation
// src/integrations/gcp/tenant-aware-client.ts
export class TenantAwareGcpClient {
constructor(
private tenantId: string,
private gcpClient: CloudResourceManagerClient
) {}
/**
* Get folder, validate belongs to tenant
*/
async getFolder(folderId: string): Promise<Folder> {
const folder = await this.gcpClient.folders.get({ name: folderId });
// Validate folder is under tenant's organization
const allowedOrgIds = await this.getAllowedGcpOrgIds();
const folderOrgId = this.extractOrgIdFromFolderPath(folder.parent);
if (!allowedOrgIds.includes(folderOrgId)) {
throw new ForbiddenError(`GCP folder not under tenant's organization`);
}
return folder;
}
/**
* List projects, filtered to tenant organization
*/
async listProjects(): Promise<Project[]> {
const allowedOrgIds = await this.getAllowedGcpOrgIds();
const projects = await Promise.all(
allowedOrgIds.map(orgId =>
this.gcpClient.projects.list({ parent: `organizations/${orgId}` })
)
);
return projects.flat();
}
private async getAllowedGcpOrgIds(): Promise<string[]> {
const tenant = await db('tenants').where('id', this.tenantId).first();
return tenant.settings.gcp_org_ids;
}
private extractOrgIdFromFolderPath(parent: string): string {
// Extract org ID from folder parent path (e.g., "organizations/123456")
const match = parent.match(/organizations\/(\d+)/);
return match ? match[1] : null;
}
}
4. API Isolation
Tenant-Aware Middleware
// src/api/middleware/tenant-middleware.ts
export function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
// Extract tenant from authentication token
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Missing authorization token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const tenantId = decoded.tenantId;
if (!tenantId) {
return res.status(401).json({ error: 'Invalid token: missing tenant' });
}
// Attach tenant to request
req.tenant = {
id: tenantId,
slug: decoded.tenantSlug,
};
// Set PostgreSQL session variable for RLS
await db.raw('SET app.current_tenant_id = ?', [tenantId]);
await db.raw('SET app.user_role = ?', [decoded.role]);
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Tenant-Scoped Routes
// src/api/routes/onboarding-routes.ts
import { tenantMiddleware } from '../middleware/tenant-middleware';
const router = express.Router();
// All routes automatically scoped to tenant
router.use(tenantMiddleware);
router.post('/onboarding/trigger', async (req, res) => {
const { tenantId } = req.tenant;
const { repoUrl, workspaceName } = req.body;
// Onboarding automatically scoped to tenant
const onboardingId = await onboardingService.trigger({
tenantId,
repoUrl,
workspaceName,
});
res.json({ onboardingId });
});
router.get('/onboarding/:id', async (req, res) => {
const { tenantId } = req.tenant;
const { id } = req.params;
// Automatically filters by tenant
const status = await onboardingService.getStatus(id, tenantId);
if (!status) {
return res.status(404).json({ error: 'Onboarding not found' });
}
res.json(status);
});
5. UI Isolation
Backstage Catalog Filters
// plugins/catalog/src/components/TenantCatalogFilter.tsx
export const TenantCatalogFilter: CatalogFilterFunc = entities => {
const { tenant } = useAuth();
return entities.filter(entity =>
entity.metadata.namespace === `tenant-${tenant.slug}`
);
};
Tenant-Aware Entity Provider
// plugins/catalog-backend/src/providers/TenantAwareEntityProvider.ts
export class TenantAwareEntityProvider implements EntityProvider {
constructor(private tenantId: string) {}
async connect(connection: EntityProviderConnection): Promise<void> {
// Only provide entities for this tenant
const entities = await db('entities')
.where('tenant_id', this.tenantId)
.select('*');
await connection.applyMutation({
type: 'full',
entities: entities.map(this.deserializeEntity),
});
}
}
Cross-Tenant Prevention
Validation Checks
// src/tenancy/cross-tenant-validator.ts
export class CrossTenantValidator {
/**
* Validate onboarding request doesn't cross tenant boundaries
*/
async validateOnboarding(context: OnboardingContext): Promise<void> {
// Validate GitHub org belongs to tenant
const repoOrg = this.extractOrgFromRepoUrl(context.repoUrl);
const githubTenant = await this.tenantResolver.fromGitHubOrg(repoOrg);
if (githubTenant !== context.tenantId) {
throw new CrossTenantViolation(
`Repository ${context.repoUrl} belongs to tenant ${githubTenant}, not ${context.tenantId}`
);
}
// Validate TFC workspace belongs to tenant
if (context.workspaceId) {
const workspace = await this.tfcClient.getWorkspace(context.workspaceId);
const tfcTenant = await this.tenantResolver.fromTfcOrg(workspace.organization.name);
if (tfcTenant !== context.tenantId) {
throw new CrossTenantViolation(
`Workspace ${context.workspaceId} belongs to tenant ${tfcTenant}, not ${context.tenantId}`
);
}
}
// Validate GCP resources belong to tenant
if (context.gcpFolderId) {
const folder = await this.gcpClient.getFolder(context.gcpFolderId);
const orgId = this.extractOrgIdFromFolderPath(folder.parent);
const gcpTenant = await this.tenantResolver.fromGcpOrg(orgId);
if (gcpTenant !== context.tenantId) {
throw new CrossTenantViolation(
`GCP folder ${context.gcpFolderId} belongs to tenant ${gcpTenant}, not ${context.tenantId}`
);
}
}
}
}
Audit Logging
// src/tenancy/audit-logger.ts
export class TenantAuditLogger {
async logAccess(event: AuditEvent): Promise<void> {
await db('audit_log').insert({
tenant_id: event.tenantId,
user_id: event.userId,
action: event.action,
resource_type: event.resourceType,
resource_id: event.resourceId,
ip_address: event.ipAddress,
user_agent: event.userAgent,
timestamp: new Date(),
details: JSON.stringify(event.details),
});
// Alert on suspicious cross-tenant access attempts
if (event.action === 'cross_tenant_attempt') {
await this.alertSecurityTeam(event);
}
}
private async alertSecurityTeam(event: AuditEvent): Promise<void> {
await slack.postMessage({
channel: '#security-alerts',
text: `🚨 Cross-tenant access attempt detected`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*User:* ${event.userId}\n` +
`*Tenant:* ${event.tenantId}\n` +
`*Action:* ${event.action}\n` +
`*Resource:* ${event.resourceType}/${event.resourceId}\n` +
`*IP:* ${event.ipAddress}`,
},
},
],
});
}
}
Testing Tenant Isolation
Integration Tests
// tests/integration/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
let tenantA: Tenant;
let tenantB: Tenant;
beforeEach(async () => {
tenantA = await createTestTenant('tenant-a');
tenantB = await createTestTenant('tenant-b');
});
it('should prevent cross-tenant entity access', async () => {
// Create entity in tenant A
const entityA = await createEntity({
tenantId: tenantA.id,
kind: 'Component',
metadata: { name: 'service-a' },
});
// Try to access from tenant B (should fail)
const catalogB = new TenantAwareCatalog(tenantB.id);
const entities = await catalogB.getEntities();
expect(entities).not.toContainEqual(expect.objectContaining({
metadata: { name: 'service-a' },
}));
});
it('should prevent cross-tenant onboarding', async () => {
// Tenant A tries to onboard tenant B's repository
await expect(
onboardingService.trigger({
tenantId: tenantA.id,
repoUrl: 'https://github.com/tenant-b-org/bu-finance-infrastructure',
})
).rejects.toThrow(CrossTenantViolation);
});
it('should isolate entity queries by namespace', async () => {
await createEntity({
tenantId: tenantA.id,
metadata: { name: 'entity-a', namespace: 'tenant-a' },
});
await createEntity({
tenantId: tenantB.id,
metadata: { name: 'entity-b', namespace: 'tenant-b' },
});
const entitiesA = await new TenantAwareCatalog(tenantA.id).getEntities();
const entitiesB = await new TenantAwareCatalog(tenantB.id).getEntities();
expect(entitiesA).toHaveLength(1);
expect(entitiesB).toHaveLength(1);
expect(entitiesA[0].metadata.name).toBe('entity-a');
expect(entitiesB[0].metadata.name).toBe('entity-b');
});
});
Summary
Isolation Mechanisms
| Layer | Mechanism | Enforcement |
|---|---|---|
| Database | Row-Level Security | PostgreSQL RLS policies |
| Application | Query Filtering | Automatic tenant_id filtering |
| Backstage | Namespace | tenant-{slug} namespaces |
| GitHub | Organization Mapping | Allowed orgs per tenant |
| Terraform Cloud | Organization Mapping | Allowed orgs per tenant |
| GCP | Organization Mapping | Allowed org IDs per tenant |
| API | Middleware | JWT token validation |
| UI | Catalog Filters | Client-side filtering |
Security Guarantees
✅ Zero cross-tenant data leakage ✅ API requests automatically scoped ✅ Database queries automatically filtered ✅ External integrations validated ✅ Audit trail for all access ✅ Security alerts for violations
Next Steps
See 07-idempotency-retry.md for retry and rollback mechanisms.