Skip to main content

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

LayerMechanismEnforcement
DatabaseRow-Level SecurityPostgreSQL RLS policies
ApplicationQuery FilteringAutomatic tenant_id filtering
BackstageNamespacetenant-{slug} namespaces
GitHubOrganization MappingAllowed orgs per tenant
Terraform CloudOrganization MappingAllowed orgs per tenant
GCPOrganization MappingAllowed org IDs per tenant
APIMiddlewareJWT token validation
UICatalog FiltersClient-side filtering

Security Guarantees

Zero cross-tenant data leakageAPI requests automatically scopedDatabase queries automatically filteredExternal integrations validatedAudit trail for all accessSecurity alerts for violations

Next Steps

See 07-idempotency-retry.md for retry and rollback mechanisms.