Component: BFF Tenant Aware¶
Date: December 16, 2025
Status: Architecture Specification
Component Type: Backend-for-Frontend Layer
Table of Contents¶
- Overview
- Architecture
- Tenant Context Extraction
- Tenant Middleware
- Tenant Router
- FastAPI Client Integration
- Security & Isolation
- Error Handling
- Caching Strategy
- Implementation Details
- Testing Strategy
- Deployment
Overview¶
The BFF Tenant Aware is a Node.js/TypeScript Backend-for-Frontend layer that acts as a tenant-aware proxy between the React frontend and FastAPI backend services. It extracts tenant context from incoming requests, validates tenant access, and routes requests to appropriate backend APIs with tenant context.
Key Responsibilities¶
- Tenant Identification: Extract tenant ID from various sources
- Tenant Validation: Verify tenant exists and user has access
- Request Routing: Route requests to tenant-specific backend APIs
- Context Injection: Add tenant context to all backend API calls
- Response Shaping: Transform backend responses for frontend consumption
- Error Handling: Handle tenant-related errors gracefully
Benefits¶
- ✅ Centralized tenant logic
- ✅ Type-safe API layer (tRPC)
- ✅ Request aggregation
- ✅ Response transformation
- ✅ Caching layer
- ✅ Security boundary
Architecture¶
High-Level Architecture¶
┌─────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ - Tenant ID in request (subdomain/header/path) │
└────────────────────┬────────────────────────────────────┘
│ HTTP Request
│ (with tenant context)
▼
┌─────────────────────────────────────────────────────────┐
│ BFF Tenant Aware │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 1. Tenant Context Extraction │ │
│ │ - Subdomain parsing │ │
│ │ - Header extraction │ │
│ │ - Path parsing │ │
│ │ - JWT token parsing │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 2. Tenant Validation │ │
│ │ - Verify tenant exists │ │
│ │ - Check user access │ │
│ │ - Validate tenant status │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 3. Tenant Context Injection │ │
│ │ - Add tenant ID to context │ │
│ │ - Add tenant config to context │ │
│ │ - Add CAT list to context │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 4. Request Routing │ │
│ │ - Route to tenant-specific APIs │ │
│ │ - Add tenant headers │ │
│ │ - Transform requests │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 5. Response Handling │ │
│ │ - Transform responses │ │
│ │ - Handle errors │ │
│ │ - Cache responses │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────┘
│ HTTP Request
│ (with tenant context)
▼
┌─────────────────────────────────────────────────────────┐
│ Backend APIs (FastAPI) │
│ - Tenant-aware endpoints │
│ - Per-tenant data isolation │
└─────────────────────────────────────────────────────────┘
Component Structure¶
apps/bff/src/
├── trpc/
│ ├── router.ts # Main router
│ ├── context.ts # Context with tenant
│ └── middleware.ts # Tenant middleware
├── middleware/
│ ├── tenant.ts # Tenant extraction
│ ├── validation.ts # Tenant validation
│ └── error-handler.ts # Error handling
├── routers/
│ ├── tenant.router.ts # Tenant config router
│ ├── auth.router.ts # Auth router (tenant-aware)
│ ├── users.router.ts # Users router (tenant-aware)
│ └── cats.router.ts # CATs router (tenant-aware)
├── services/
│ ├── fastapi-client.ts # FastAPI client (tenant-aware)
│ ├── tenant-service.ts # Tenant service
│ └── cache.ts # Cache service
└── utils/
├── tenant.ts # Tenant utilities
└── logger.ts # Logging
Tenant Context Extraction¶
Extraction Methods (Priority Order)¶
1. Subdomain Extraction¶
Pattern: {tenantId}.bast.ai
Implementation:
File: apps/bff/src/middleware/tenant.ts
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
export function extractTenantFromSubdomain(req: CreateExpressContextOptions['req']): string | null {
const host = req.headers.host;
if (!host) return null;
const parts = host.split('.');
// Skip 'www' and 'app' subdomains
if (parts.length >= 3 && !['www', 'app'].includes(parts[0])) {
return parts[0];
}
return null;
}
2. Header Extraction¶
Pattern: X-Tenant-ID header
Implementation:
export function extractTenantFromHeader(req: CreateExpressContextOptions['req']): string | null {
return req.headers['x-tenant-id'] as string || null;
}
3. Path Extraction¶
Pattern: /tenant/{tenantId}/...
Implementation:
export function extractTenantFromPath(req: CreateExpressContextOptions['req']): string | null {
const url = req.url || '';
const match = url.match(/^\/tenant\/([^\/]+)/);
return match ? match[1] : null;
}
4. JWT Token Extraction¶
Pattern: Tenant ID in JWT claims
Implementation:
import { verifyToken } from '../utils/auth';
export async function extractTenantFromToken(
req: CreateExpressContextOptions['req']
): Promise<string | null> {
const authHeader = req.headers.authorization;
if (!authHeader) return null;
const token = authHeader.replace('Bearer ', '');
try {
const decoded = await verifyToken(token);
return decoded.tenantId || decoded.organization || null;
} catch {
return null;
}
}
5. Combined Extraction Function¶
export async function extractTenantId(
req: CreateExpressContextOptions['req']
): Promise<string | null> {
// Try methods in priority order
const tenantId =
extractTenantFromSubdomain(req) ||
extractTenantFromHeader(req) ||
extractTenantFromPath(req) ||
await extractTenantFromToken(req);
return tenantId;
}
Tenant Middleware¶
Tenant Context Middleware¶
File: apps/bff/src/trpc/middleware.ts
import { TRPCError } from '@trpc/server';
import { t } from './router';
import { extractTenantId } from '../middleware/tenant';
import { validateTenantAccess } from '../services/tenant-service';
import { logger } from '../utils/logger';
export const tenantContext = t.middleware(async ({ ctx, next }) => {
// Extract tenant ID
const tenantId = await extractTenantId(ctx.req);
if (!tenantId) {
logger.warn('No tenant ID found in request', {
host: ctx.req.headers.host,
path: ctx.req.url,
});
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Tenant ID is required. Provide via subdomain, header, path, or JWT token.',
});
}
// Validate tenant exists and user has access
const validation = await validateTenantAccess(tenantId, ctx.user);
if (!validation.valid) {
logger.warn('Tenant access denied', {
tenantId,
userId: ctx.user?.id,
reason: validation.reason,
});
throw new TRPCError({
code: validation.reason === 'NOT_FOUND' ? 'NOT_FOUND' : 'FORBIDDEN',
message: validation.reason === 'NOT_FOUND'
? `Tenant '${tenantId}' not found`
: `Access denied for tenant '${tenantId}'`,
});
}
// Load tenant configuration
const tenantConfig = await getTenantConfig(tenantId);
// Add tenant context to context
return next({
ctx: {
...ctx,
tenantId,
tenant: tenantConfig,
},
});
});
Tenant Validation Service¶
File: apps/bff/src/services/tenant-service.ts
import { fastAPIClient } from './fastapi-client';
import { User } from '../trpc/context';
import { logger } from '../utils/logger';
export interface TenantValidationResult {
valid: boolean;
reason?: 'NOT_FOUND' | 'FORBIDDEN' | 'INACTIVE' | 'SUSPENDED';
tenant?: any;
}
export async function validateTenantAccess(
tenantId: string,
user?: User
): Promise<TenantValidationResult> {
try {
// Check if tenant exists
const tenant = await fastAPIClient.get(
'userManagement',
`/api/v1/tenants/${tenantId}`,
{},
tenantId // Pass tenant ID for routing
);
if (!tenant) {
return { valid: false, reason: 'NOT_FOUND' };
}
// Check tenant status
if (tenant.status === 'inactive') {
return { valid: false, reason: 'INACTIVE', tenant };
}
if (tenant.status === 'suspended') {
return { valid: false, reason: 'SUSPENDED', tenant };
}
// If user is provided, check access
if (user) {
const hasAccess = await checkUserTenantAccess(user.id, tenantId);
if (!hasAccess) {
return { valid: false, reason: 'FORBIDDEN', tenant };
}
}
return { valid: true, tenant };
} catch (error: any) {
logger.error('Tenant validation error', { tenantId, error: error.message });
if (error.response?.status === 404) {
return { valid: false, reason: 'NOT_FOUND' };
}
throw error;
}
}
async function checkUserTenantAccess(userId: string, tenantId: string): Promise<boolean> {
try {
const user = await fastAPIClient.get(
'userManagement',
`/api/v1/users/${userId}`,
{},
tenantId
);
// Check if user belongs to tenant
return user.organization === tenantId || user.tenants?.includes(tenantId);
} catch {
return false;
}
}
export async function getTenantConfig(tenantId: string): Promise<any> {
try {
const config = await fastAPIClient.get(
'userManagement',
`/api/v1/tenants/${tenantId}/config`,
{},
tenantId
);
return config;
} catch (error) {
logger.error('Failed to load tenant config', { tenantId, error });
throw error;
}
}
Tenant Router¶
Tenant Configuration Router¶
File: apps/bff/src/routers/tenant.router.ts
import { router, protectedProcedure } from '../trpc/router';
import { tenantContext } from '../trpc/middleware';
import { fastAPIClient } from '../services/fastapi-client';
import { cache } from '../services/cache';
import { logger } from '../utils/logger';
export const tenantRouter = router({
getConfig: protectedProcedure
.use(tenantContext)
.query(async ({ ctx }) => {
const cacheKey = `tenant:config:${ctx.tenantId}`;
// Try cache first
const cached = await cache.get(cacheKey);
if (cached) {
logger.debug('Tenant config cache hit', { tenantId: ctx.tenantId });
return cached;
}
// Load from backend
const config = await fastAPIClient.get(
'userManagement',
`/api/v1/tenants/${ctx.tenantId}/config`,
{},
ctx.tenantId
);
// Cache for 5 minutes
await cache.set(cacheKey, config, 300);
return {
tenantId: ctx.tenantId,
name: config.name,
slug: config.slug,
branding: config.branding,
features: config.features,
cats: config.cats || [],
environments: config.environments || ['dev', 'stage', 'prod'],
limits: config.limits,
subscription: config.subscription,
};
}),
listCats: protectedProcedure
.use(tenantContext)
.query(async ({ ctx }) => {
const cacheKey = `tenant:cats:${ctx.tenantId}`;
const cached = await cache.get(cacheKey);
if (cached) {
return cached;
}
const cats = await fastAPIClient.get(
'userManagement',
`/api/v1/tenants/${ctx.tenantId}/cats`,
{},
ctx.tenantId
);
await cache.set(cacheKey, cats, 300);
return cats;
}),
getCat: protectedProcedure
.use(tenantContext)
.input(z.object({ catPrefix: z.string() }))
.query(async ({ ctx, input }) => {
// Verify CAT belongs to tenant
const cats = await fastAPIClient.get(
'userManagement',
`/api/v1/tenants/${ctx.tenantId}/cats`,
{},
ctx.tenantId
);
const cat = cats.find((c: any) => c.prefix === input.catPrefix);
if (!cat) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `CAT '${input.catPrefix}' not found for tenant '${ctx.tenantId}'`,
});
}
return cat;
}),
updateConfig: protectedProcedure
.use(tenantContext)
.input(z.object({
branding: z.object({
logo: z.string().optional(),
colors: z.object({
primary: z.string().optional(),
secondary: z.string().optional(),
}).optional(),
}).optional(),
}))
.mutation(async ({ ctx, input }) => {
const updated = await fastAPIClient.put(
'userManagement',
`/api/v1/tenants/${ctx.tenantId}/config`,
input,
{},
ctx.tenantId
);
// Invalidate cache
await cache.invalidate(`tenant:config:${ctx.tenantId}`);
logger.info('Tenant config updated', {
tenantId: ctx.tenantId,
updatedBy: ctx.user?.id,
});
return updated;
}),
});
FastAPI Client Integration¶
Tenant-Aware FastAPI Client¶
File: apps/bff/src/services/fastapi-client.ts (UPDATE)
import axios, { AxiosInstance, AxiosError } from 'axios';
import { logger } from '../utils/logger';
export class FastAPIClient {
private clients: Map<string, AxiosInstance> = new Map();
constructor() {
// Initialize clients for each FastAPI service
const services: Record<string, { baseURL: string; timeout?: number }> = {
security: {
baseURL: process.env.FASTAPI_SECURITY_URL || 'http://localhost:8000',
timeout: 10000,
},
userManagement: {
baseURL: process.env.FASTAPI_USER_MGMT_URL || 'http://localhost:8001',
timeout: 10000,
},
cat: {
baseURL: process.env.FASTAPI_CAT_URL || 'http://localhost:8002',
timeout: 10000,
},
// ... other services
};
Object.entries(services).forEach(([name, config]) => {
const client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for logging
client.interceptors.request.use(
(config) => {
logger.debug(`FastAPI Request: ${name} ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
logger.error(`FastAPI Request Error: ${name}`, error);
return Promise.reject(error);
}
);
// Response interceptor for logging
client.interceptors.response.use(
(response) => {
logger.debug(`FastAPI Response: ${name} ${response.status}`);
return response;
},
(error) => {
logger.error(`FastAPI Response Error: ${name}`, error);
return Promise.reject(error);
}
);
this.clients.set(name, client);
});
}
async get(
service: string,
endpoint: string,
config?: any,
tenantId?: string,
catPrefix?: string
) {
const client = this.clients.get(service);
if (!client) {
throw new Error(`Unknown service: ${service}`);
}
// Add tenant context to headers
const headers = {
...config?.headers,
...(tenantId && { 'X-Tenant-ID': tenantId }),
...(catPrefix && { 'X-CAT-Prefix': catPrefix }),
};
try {
const response = await client.get(endpoint, {
...config,
headers,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw this.handleError(error, service, endpoint);
}
throw error;
}
}
async post(
service: string,
endpoint: string,
data?: any,
config?: any,
tenantId?: string,
catPrefix?: string
) {
const client = this.clients.get(service);
if (!client) {
throw new Error(`Unknown service: ${service}`);
}
const headers = {
...config?.headers,
...(tenantId && { 'X-Tenant-ID': tenantId }),
...(catPrefix && { 'X-CAT-Prefix': catPrefix }),
};
try {
const response = await client.post(endpoint, data, {
...config,
headers,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw this.handleError(error, service, endpoint);
}
throw error;
}
}
async put(
service: string,
endpoint: string,
data?: any,
config?: any,
tenantId?: string,
catPrefix?: string
) {
// Similar to post
}
async delete(
service: string,
endpoint: string,
config?: any,
tenantId?: string,
catPrefix?: string
) {
// Similar to get
}
private handleError(error: AxiosError, service: string, endpoint: string) {
if (error.response) {
const status = error.response.status;
const data = error.response.data;
logger.error(`FastAPI Error: ${service} ${endpoint} - ${status}`, data);
return new Error(`FastAPI Error (${status}): ${JSON.stringify(data)}`);
} else if (error.request) {
logger.error(`FastAPI Unreachable: ${service} ${endpoint}`, error.message);
return new Error(`FastAPI service unreachable: ${service}`);
} else {
logger.error(`FastAPI Request Error: ${service} ${endpoint}`, error.message);
return new Error(`Request error: ${error.message}`);
}
}
}
export const fastAPIClient = new FastAPIClient();
Security & Isolation¶
Tenant Isolation Enforcement¶
-
Always Validate Tenant
-
Never Trust Client
// Always use tenantId from context, not from input export const myRouter = router({ getData: protectedProcedure .use(tenantContext) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { // Use ctx.tenantId, not input.tenantId return fastAPIClient.get( 'service', `/data/${input.id}`, {}, ctx.tenantId // From context, not input ); }), }); -
Validate Tenant Access
Security Best Practices¶
// 1. Log all tenant operations
logger.info('Tenant operation', {
tenantId: ctx.tenantId,
userId: ctx.user?.id,
operation: 'getData',
});
// 2. Rate limiting per tenant
const rateLimiter = rateLimit({
keyGenerator: (req) => `tenant:${req.tenantId}`,
windowMs: 15 * 60 * 1000,
max: 100,
});
// 3. Audit trail
await auditLog.create({
tenantId: ctx.tenantId,
userId: ctx.user?.id,
action: 'data_access',
resource: 'users',
timestamp: new Date(),
});
Error Handling¶
Tenant-Specific Error Handling¶
File: apps/bff/src/middleware/error-handler.ts
import { TRPCError } from '@trpc/server';
export function handleTenantError(error: any, tenantId?: string): TRPCError {
if (error.response?.status === 404) {
return new TRPCError({
code: 'NOT_FOUND',
message: tenantId
? `Resource not found for tenant '${tenantId}'`
: 'Resource not found',
});
}
if (error.response?.status === 403) {
return new TRPCError({
code: 'FORBIDDEN',
message: 'Access denied',
});
}
if (error.response?.status === 401) {
return new TRPCError({
code: 'UNAUTHORIZED',
message: 'Authentication required',
});
}
return new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Internal server error',
});
}
Caching Strategy¶
Tenant-Aware Caching¶
// Cache key includes tenant ID
const cacheKey = `tenant:${tenantId}:${resource}:${id}`;
// Cache invalidation per tenant
await cache.invalidate(`tenant:${tenantId}:*`);
// Cache TTL per tenant tier
const ttl = tenant.tier === 'enterprise' ? 600 : 300; // 10min vs 5min
Implementation Details¶
Step-by-Step Implementation¶
Phase 1: Tenant Context (Week 1-2)¶
- Create Tenant Middleware
- Create
middleware/tenant.ts - Implement tenant extraction functions
-
Create
trpc/middleware.tswith tenantContext -
Update Context
- Update
trpc/context.tsto include tenant - Add tenant validation
- Add tenant config loading
Phase 2: Tenant Service (Week 3-4)¶
- Create Tenant Service
- Create
services/tenant-service.ts - Implement tenant validation
-
Implement tenant config loading
-
Update FastAPI Client
- Add tenant ID parameter
- Add tenant headers
- Update all methods
Phase 3: Tenant Router (Week 5-6)¶
- Create Tenant Router
- Create
routers/tenant.router.ts - Implement tenant config endpoints
-
Implement CAT endpoints
-
Update Main Router
- Add tenant router
- Update all routers to use tenantContext
Testing Strategy¶
Unit Tests¶
// tenant.middleware.test.ts
describe('Tenant Middleware', () => {
it('extracts tenant from subdomain', () => {
const req = { headers: { host: 'acme.bast.ai' } };
const tenantId = extractTenantFromSubdomain(req);
expect(tenantId).toBe('acme');
});
it('validates tenant access', async () => {
const result = await validateTenantAccess('acme', mockUser);
expect(result.valid).toBe(true);
});
});
Integration Tests¶
// tenant.integration.test.ts
describe('Tenant Integration', () => {
it('loads tenant config', async () => {
const result = await trpc.tenant.getConfig.query({ tenantId: 'acme' });
expect(result.tenantId).toBe('acme');
});
});
Deployment¶
Environment Variables¶
# FastAPI Services
FASTAPI_SECURITY_URL=http://localhost:8000
FASTAPI_USER_MGMT_URL=http://localhost:8001
# Cache
REDIS_URL=redis://localhost:6379
# Logging
LOG_LEVEL=info
Health Check¶
app.get('/health', async (req, res) => {
const health = {
status: 'ok',
tenantAware: true,
services: {
fastapi: await checkFastAPIConnection(),
cache: await checkCacheConnection(),
},
};
res.json(health);
});
Document created: December 16, 2025
Component: BFF Tenant Aware