diff --git a/apps/backend/docs/multi-tenancy.md b/apps/backend/docs/multi-tenancy.md new file mode 100644 index 0000000..a3b6509 --- /dev/null +++ b/apps/backend/docs/multi-tenancy.md @@ -0,0 +1,310 @@ +# Multi-Tenancy Architecture + +This document describes the multi-tenancy implementation in the boilerplate and the available strategies for tenant isolation. + +## Overview + +The application supports multiple tenants (accounts) with data isolation. Each user can belong to multiple accounts with different roles in each. + +## Current Implementation: Header-Based Tenancy + +The default strategy uses an `account-id` HTTP header to identify the current tenant. + +### How It Works + +1. Client sends `account-id` header with each request +2. `AccountMiddleware` extracts and validates the header +3. Account ID is stored in CLS (Continuation Local Storage) context +4. Services access the account ID via `ClsService.get('accountId')` + +### Request Flow + +``` +Client Request + │ + ├── Header: Authorization: Bearer + ├── Header: account-id: + │ + ▼ +AccountMiddleware + │ + ├── Validate account-id header exists + ├── Extract and decode JWT token + ├── Look up user by provider ID + ├── Store context in CLS: + │ ├── accountId + │ ├── user + │ ├── transactionId + │ ├── ip + │ └── userAgent + │ + ▼ +Route Handler / Service + │ + └── Access context via ClsService +``` + +### Configuration + +The middleware is configured in `app.module.ts`: + +```typescript +export class AppModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(ClsMiddleware, AccountMiddleware) + .exclude('users/login') + .forRoutes('*'); + } +} +``` + +### Usage in Services + +```typescript +@Injectable() +export class MyService { + constructor(private readonly cls: ClsService) {} + + async doSomething() { + const accountId = this.cls.get('accountId'); + const user = this.cls.get('user'); + // Use accountId to filter data + } +} +``` + +--- + +## Alternative Strategy: Subdomain-Based Tenancy + +For applications that want to use subdomains (e.g., `tenant1.app.com`, `tenant2.app.com`). + +### Implementation + +Use `SubdomainTenantMiddleware` instead of or alongside `AccountMiddleware`: + +```typescript +// src/middlewares/subdomain-tenant.middleware.ts +@Injectable() +export class SubdomainTenantMiddleware implements NestMiddleware { + constructor( + private readonly cls: ClsService, + private readonly accountsService: AccountsService, + ) {} + + async use(req: Request, res: Response, next: NextFunction) { + const host = req.hostname; + const subdomain = host.split('.')[0]; + + // Skip for www, api, or localhost + if (['www', 'api', 'localhost'].includes(subdomain)) { + return next(); + } + + const account = await this.accountsService.findBySubdomain(subdomain); + if (!account) { + throw new HttpException('Tenant not found', HttpStatus.NOT_FOUND); + } + + this.cls.set('accountId', account.id); + next(); + } +} +``` + +### Database Changes Required + +Add a `subdomain` column to the accounts table: + +```typescript +@Entity('accounts') +export class Account { + @Column({ unique: true, nullable: true }) + subdomain: string; +} +``` + +### DNS Configuration + +Configure wildcard DNS: `*.app.com → your-server-ip` + +--- + +## Alternative Strategy: Path-Based Tenancy + +For APIs where the tenant is part of the URL path (e.g., `/api/v1/tenants/:tenantId/resources`). + +### Implementation + +```typescript +// Use route parameters instead of middleware +@Controller('tenants/:tenantId/users') +export class TenantUsersController { + @Get() + async getUsers(@Param('tenantId') tenantId: string) { + // Validate user has access to this tenant + // Query users for this tenant + } +} +``` + +### Pros and Cons + +| Aspect | Path-Based | +|--------|------------| +| ✅ Pros | Explicit, RESTful, easy to cache | +| ❌ Cons | Longer URLs, requires parameter in every route | + +--- + +## Data Isolation Patterns + +### 1. Application-Level Filtering (Current) + +Data is filtered in service/repository layer: + +```typescript +async findAll(): Promise { + const accountId = this.cls.get('accountId'); + return this.userRepository.find({ + where: { accountId } + }); +} +``` + +### 2. Row-Level Security (PostgreSQL Only) + +PostgreSQL supports automatic row filtering: + +```sql +-- Enable RLS on table +ALTER TABLE users ENABLE ROW LEVEL SECURITY; + +-- Create policy +CREATE POLICY tenant_isolation ON users + USING (account_id = current_setting('app.current_tenant')::uuid); + +-- Set tenant in session +SET app.current_tenant = 'account-uuid'; +``` + +#### Implementation with TypeORM + +```typescript +// Before each query +await queryRunner.query( + `SET app.current_tenant = '${accountId}'` +); +``` + +### 3. Separate Schemas (PostgreSQL) + +Each tenant gets a separate schema: + +```typescript +async getConnection(tenantId: string): Promise { + return createConnection({ + ...baseConfig, + schema: `tenant_${tenantId}`, + }); +} +``` + +### 4. Separate Databases + +Most isolated but complex to manage: + +```typescript +const connections = new Map(); + +async getConnection(tenantId: string): Promise { + if (!connections.has(tenantId)) { + const connection = await createConnection({ + ...baseConfig, + database: `app_${tenantId}`, + }); + connections.set(tenantId, connection); + } + return connections.get(tenantId); +} +``` + +--- + +## Strategy Comparison + +| Strategy | Isolation Level | Complexity | Use Case | +|----------|----------------|------------|----------| +| Header-based | Application | Low | APIs, SPAs | +| Subdomain | Application | Medium | SaaS products | +| Path-based | Application | Low | RESTful APIs | +| Row-Level Security | Database | Medium | PostgreSQL apps | +| Separate Schemas | Database | High | Enterprise | +| Separate Databases | Infrastructure | Very High | Regulated industries | + +--- + +## Configuration Options + +### Environment Variables + +```bash +# Tenant strategy (header, subdomain, path) +TENANT_STRATEGY=header + +# For subdomain strategy +APP_DOMAIN=app.com +SKIP_SUBDOMAINS=www,api,localhost + +# For RLS strategy +USE_ROW_LEVEL_SECURITY=true +``` + +### Tenant Configuration Type + +```typescript +// src/config/tenant.config.ts +export enum TenantStrategy { + HEADER = 'header', + SUBDOMAIN = 'subdomain', + PATH = 'path', +} + +export interface TenantConfig { + strategy: TenantStrategy; + headerName?: string; // For header strategy + appDomain?: string; // For subdomain strategy + skipSubdomains?: string[]; +} +``` + +--- + +## Security Considerations + +1. **Always validate tenant access**: Check that the user has permission to access the requested tenant +2. **Audit cross-tenant access attempts**: Log when users try to access unauthorized tenants +3. **Use database-level isolation for sensitive data**: Consider RLS or separate schemas for regulated industries +4. **Validate tenant ID format**: Ensure tenant IDs are valid UUIDs to prevent injection +5. **Rate limit per tenant**: Prevent one tenant from consuming all resources + +--- + +## Migration Between Strategies + +### From Header to Subdomain + +1. Add `subdomain` column to accounts table +2. Populate subdomains for existing accounts +3. Configure DNS wildcard +4. Add SubdomainTenantMiddleware +5. Update frontend to use subdomains +6. (Optional) Keep header support for backwards compatibility + +### To Row-Level Security + +1. Ensure PostgreSQL is used +2. Add RLS policies to all tenant-scoped tables +3. Modify connection handler to set tenant context +4. Test thoroughly - RLS errors fail silently diff --git a/apps/backend/package.json b/apps/backend/package.json index 2285e2c..90a6069 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -86,6 +86,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/backend/src/config/tenant.config.spec.ts b/apps/backend/src/config/tenant.config.spec.ts new file mode 100644 index 0000000..8d38ff4 --- /dev/null +++ b/apps/backend/src/config/tenant.config.spec.ts @@ -0,0 +1,211 @@ +import { + TenantStrategy, + TenantConfig, + defaultTenantConfig, + getTenantConfig, +} from './tenant.config'; + +describe('Tenant Config', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment for each test + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('TenantStrategy', () => { + it('should have HEADER strategy', () => { + expect(TenantStrategy.HEADER).toBe('header'); + }); + + it('should have SUBDOMAIN strategy', () => { + expect(TenantStrategy.SUBDOMAIN).toBe('subdomain'); + }); + + it('should have PATH strategy', () => { + expect(TenantStrategy.PATH).toBe('path'); + }); + + it('should have exactly 3 strategies', () => { + const strategies = Object.values(TenantStrategy); + expect(strategies).toHaveLength(3); + }); + }); + + describe('defaultTenantConfig', () => { + it('should use HEADER strategy by default', () => { + expect(defaultTenantConfig.strategy).toBe(TenantStrategy.HEADER); + }); + + it('should use account-id as default header name', () => { + expect(defaultTenantConfig.headerName).toBe('account-id'); + }); + + it('should not allow requests without tenant by default', () => { + expect(defaultTenantConfig.allowNoTenant).toBe(false); + }); + }); + + describe('getTenantConfig', () => { + it('should return default HEADER strategy when not configured', () => { + delete process.env.TENANT_STRATEGY; + + const config = getTenantConfig(); + + expect(config.strategy).toBe(TenantStrategy.HEADER); + }); + + it('should use TENANT_STRATEGY from environment', () => { + process.env.TENANT_STRATEGY = 'subdomain'; + + const config = getTenantConfig(); + + expect(config.strategy).toBe('subdomain'); + }); + + it('should use PATH strategy from environment', () => { + process.env.TENANT_STRATEGY = 'path'; + + const config = getTenantConfig(); + + expect(config.strategy).toBe('path'); + }); + + it('should use default header name when not configured', () => { + delete process.env.TENANT_HEADER_NAME; + + const config = getTenantConfig(); + + expect(config.headerName).toBe('account-id'); + }); + + it('should use custom header name from environment', () => { + process.env.TENANT_HEADER_NAME = 'x-tenant-id'; + + const config = getTenantConfig(); + + expect(config.headerName).toBe('x-tenant-id'); + }); + + it('should use APP_DOMAIN from environment', () => { + process.env.APP_DOMAIN = 'myapp.example.com'; + + const config = getTenantConfig(); + + expect(config.appDomain).toBe('myapp.example.com'); + }); + + it('should have undefined appDomain when not configured', () => { + delete process.env.APP_DOMAIN; + + const config = getTenantConfig(); + + expect(config.appDomain).toBeUndefined(); + }); + + it('should use default skip subdomains when not configured', () => { + delete process.env.SKIP_SUBDOMAINS; + + const config = getTenantConfig(); + + expect(config.skipSubdomains).toEqual(['www', 'api', 'localhost']); + }); + + it('should parse SKIP_SUBDOMAINS from environment', () => { + process.env.SKIP_SUBDOMAINS = 'www,api,static,cdn'; + + const config = getTenantConfig(); + + expect(config.skipSubdomains).toEqual(['www', 'api', 'static', 'cdn']); + }); + + it('should trim whitespace from skip subdomains', () => { + process.env.SKIP_SUBDOMAINS = ' www , api , static '; + + const config = getTenantConfig(); + + expect(config.skipSubdomains).toEqual(['www', 'api', 'static']); + }); + + it('should not allow requests without tenant by default', () => { + delete process.env.ALLOW_NO_TENANT; + + const config = getTenantConfig(); + + expect(config.allowNoTenant).toBe(false); + }); + + it('should allow no tenant when ALLOW_NO_TENANT is true', () => { + process.env.ALLOW_NO_TENANT = 'true'; + + const config = getTenantConfig(); + + expect(config.allowNoTenant).toBe(true); + }); + + it('should not allow no tenant when ALLOW_NO_TENANT is false', () => { + process.env.ALLOW_NO_TENANT = 'false'; + + const config = getTenantConfig(); + + expect(config.allowNoTenant).toBe(false); + }); + + it('should not allow no tenant for invalid ALLOW_NO_TENANT value', () => { + process.env.ALLOW_NO_TENANT = 'yes'; + + const config = getTenantConfig(); + + expect(config.allowNoTenant).toBe(false); + }); + + it('should return complete TenantConfig object', () => { + process.env.TENANT_STRATEGY = 'subdomain'; + process.env.TENANT_HEADER_NAME = 'x-tenant'; + process.env.APP_DOMAIN = 'myapp.com'; + process.env.SKIP_SUBDOMAINS = 'www,api'; + process.env.ALLOW_NO_TENANT = 'true'; + + const config = getTenantConfig(); + + expect(config).toEqual({ + strategy: 'subdomain', + headerName: 'x-tenant', + appDomain: 'myapp.com', + skipSubdomains: ['www', 'api'], + allowNoTenant: true, + }); + }); + }); + + describe('TenantConfig interface', () => { + it('should allow creating minimal config', () => { + const config: TenantConfig = { + strategy: TenantStrategy.HEADER, + }; + + expect(config.strategy).toBe(TenantStrategy.HEADER); + }); + + it('should allow creating full config', () => { + const config: TenantConfig = { + strategy: TenantStrategy.SUBDOMAIN, + headerName: 'x-tenant-id', + appDomain: 'example.com', + skipSubdomains: ['www', 'api'], + allowNoTenant: true, + }; + + expect(config.strategy).toBe(TenantStrategy.SUBDOMAIN); + expect(config.headerName).toBe('x-tenant-id'); + expect(config.appDomain).toBe('example.com'); + expect(config.skipSubdomains).toEqual(['www', 'api']); + expect(config.allowNoTenant).toBe(true); + }); + }); +}); diff --git a/apps/backend/src/config/tenant.config.ts b/apps/backend/src/config/tenant.config.ts new file mode 100644 index 0000000..b6d1af0 --- /dev/null +++ b/apps/backend/src/config/tenant.config.ts @@ -0,0 +1,70 @@ +/** + * Tenant isolation configuration types. + * + * This file defines the available tenant strategies and their configuration options. + * See docs/multi-tenancy.md for detailed documentation. + */ + +export enum TenantStrategy { + /** + * Tenant ID passed via HTTP header (default). + * Header name: account-id + */ + HEADER = 'header', + + /** + * Tenant determined from subdomain. + * Example: tenant1.app.com → tenant1 + */ + SUBDOMAIN = 'subdomain', + + /** + * Tenant ID in URL path. + * Example: /api/v1/tenants/:tenantId/resources + */ + PATH = 'path', +} + +export interface TenantConfig { + /** The strategy used for tenant resolution */ + strategy: TenantStrategy; + + /** Header name for HEADER strategy (default: 'account-id') */ + headerName?: string; + + /** Application domain for SUBDOMAIN strategy */ + appDomain?: string; + + /** Subdomains to skip (e.g., 'www', 'api') */ + skipSubdomains?: string[]; + + /** Whether to allow requests without tenant context */ + allowNoTenant?: boolean; +} + +/** + * Default tenant configuration + */ +export const defaultTenantConfig: TenantConfig = { + strategy: TenantStrategy.HEADER, + headerName: 'account-id', + allowNoTenant: false, +}; + +/** + * Get tenant configuration from environment + */ +export function getTenantConfig(): TenantConfig { + const strategy = + (process.env.TENANT_STRATEGY as TenantStrategy) || TenantStrategy.HEADER; + + return { + strategy, + headerName: process.env.TENANT_HEADER_NAME || 'account-id', + appDomain: process.env.APP_DOMAIN, + skipSubdomains: process.env.SKIP_SUBDOMAINS?.split(',').map((s) => + s.trim(), + ) || ['www', 'api', 'localhost'], + allowNoTenant: process.env.ALLOW_NO_TENANT === 'true', + }; +} diff --git a/apps/backend/src/middlewares/subdomain-tenant.middleware.spec.ts b/apps/backend/src/middlewares/subdomain-tenant.middleware.spec.ts new file mode 100644 index 0000000..2a94839 --- /dev/null +++ b/apps/backend/src/middlewares/subdomain-tenant.middleware.spec.ts @@ -0,0 +1,328 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsService } from 'nestjs-cls'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response, NextFunction } from 'express'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { SubdomainTenantMiddleware } from './subdomain-tenant.middleware'; + +// Helper to create mock request with hostname +const createMockRequest = (hostname: string): Partial => ({ + hostname, +}); + +describe('SubdomainTenantMiddleware', () => { + let middleware: SubdomainTenantMiddleware; + let mockClsService: jest.Mocked; + let mockConfigService: jest.Mocked; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(async () => { + mockClsService = { + set: jest.fn(), + get: jest.fn(), + } as any; + + mockConfigService = { + get: jest.fn().mockImplementation((key: string, defaultValue?: string) => { + const config: Record = { + APP_DOMAIN: 'app.example.com', + SKIP_SUBDOMAINS: 'www,api,localhost', + }; + return config[key] ?? defaultValue; + }), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubdomainTenantMiddleware, + { provide: ClsService, useValue: mockClsService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + middleware = module.get(SubdomainTenantMiddleware); + + mockResponse = {}; + mockNext = jest.fn(); + }); + + it('should be defined', () => { + expect(middleware).toBeDefined(); + }); + + describe('extractSubdomain', () => { + it('should extract subdomain from full domain and throw when not found', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const mockRequest = createMockRequest('tenant1.app.example.com'); + + // Default implementation returns null, which throws 404 + await expect( + middleware.use(mockRequest as Request, mockResponse as Response, mockNext), + ).rejects.toThrow(HttpException); + + // Verify the subdomain was extracted and resolveAccountBySubdomain was called + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Subdomain 'tenant1'"), + ); + + consoleSpy.mockRestore(); + }); + + it('should return null for localhost', async () => { + const mockRequest = createMockRequest('localhost'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should return null for localhost with port', async () => { + const mockRequest = createMockRequest('localhost:3000'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should return null for IP addresses', async () => { + const mockRequest = createMockRequest('192.168.1.1'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should return null for domain without subdomain', async () => { + const mockRequest = createMockRequest('example.com'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + }); + + describe('skip subdomains', () => { + it('should skip www subdomain', async () => { + const mockRequest = createMockRequest('www.app.example.com'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should skip api subdomain', async () => { + const mockRequest = createMockRequest('api.app.example.com'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should skip localhost subdomain', async () => { + const mockRequest = createMockRequest('localhost.app.example.com'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + + it('should handle case-insensitive subdomain matching', async () => { + const mockRequest = createMockRequest('WWW.app.example.com'); + + await middleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockClsService.set).not.toHaveBeenCalled(); + }); + }); + + describe('tenant resolution', () => { + it('should call next with warning when resolveAccountBySubdomain not implemented', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const mockRequest = createMockRequest('tenant1.app.example.com'); + + await expect( + middleware.use(mockRequest as Request, mockResponse as Response, mockNext), + ).rejects.toThrow(HttpException); + + consoleSpy.mockRestore(); + }); + }); + + describe('with custom resolveAccountBySubdomain', () => { + let customMiddleware: SubdomainTenantMiddleware; + + beforeEach(async () => { + // Create a custom subclass that resolves accounts + class CustomSubdomainMiddleware extends SubdomainTenantMiddleware { + protected async resolveAccountBySubdomain( + subdomain: string, + ): Promise<{ id: string } | null> { + if (subdomain === 'valid-tenant') { + return { id: 'account-123' }; + } + return null; + } + } + + const module = await Test.createTestingModule({ + providers: [ + CustomSubdomainMiddleware, + { provide: ClsService, useValue: mockClsService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + customMiddleware = module.get(CustomSubdomainMiddleware); + }); + + it('should set accountId in CLS when tenant is found', async () => { + const mockRequest = createMockRequest('valid-tenant.app.example.com'); + + await customMiddleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockClsService.set).toHaveBeenCalledWith('accountId', 'account-123'); + expect(mockClsService.set).toHaveBeenCalledWith('tenantSubdomain', 'valid-tenant'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should throw 404 when tenant not found', async () => { + const mockRequest = createMockRequest('nonexistent.app.example.com'); + + await expect( + customMiddleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ), + ).rejects.toThrow(HttpException); + + try { + await customMiddleware.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND); + expect((error as HttpException).message).toContain('nonexistent'); + } + }); + }); + + describe('configuration', () => { + it('should use default APP_DOMAIN when not configured', async () => { + const configWithoutDomain = { + get: jest.fn().mockImplementation((key: string, defaultValue?: string) => { + if (key === 'SKIP_SUBDOMAINS') return 'www,api,localhost'; + return defaultValue; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + SubdomainTenantMiddleware, + { provide: ClsService, useValue: mockClsService }, + { provide: ConfigService, useValue: configWithoutDomain }, + ], + }).compile(); + + const middlewareWithDefaults = module.get(SubdomainTenantMiddleware); + expect(middlewareWithDefaults).toBeDefined(); + }); + + it('should use default SKIP_SUBDOMAINS when not configured', async () => { + const configWithoutSkip = { + get: jest.fn().mockImplementation((key: string, defaultValue?: string) => { + if (key === 'APP_DOMAIN') return 'app.com'; + return defaultValue; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + SubdomainTenantMiddleware, + { provide: ClsService, useValue: mockClsService }, + { provide: ConfigService, useValue: configWithoutSkip }, + ], + }).compile(); + + const middlewareWithDefaults = module.get(SubdomainTenantMiddleware); + expect(middlewareWithDefaults).toBeDefined(); + }); + + it('should parse skip subdomains with whitespace', async () => { + const configWithSpaces = { + get: jest.fn().mockImplementation((key: string, defaultValue?: string) => { + if (key === 'SKIP_SUBDOMAINS') return ' www , api , test '; + return defaultValue ?? 'app.com'; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + SubdomainTenantMiddleware, + { provide: ClsService, useValue: mockClsService }, + { provide: ConfigService, useValue: configWithSpaces }, + ], + }).compile(); + + const middlewareWithSpaces = module.get(SubdomainTenantMiddleware); + + // Test that 'www' with whitespace is properly trimmed and skipped + const mockRequest = createMockRequest('www.app.com'); + await middlewareWithSpaces.use( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/middlewares/subdomain-tenant.middleware.ts b/apps/backend/src/middlewares/subdomain-tenant.middleware.ts new file mode 100644 index 0000000..6ae4529 --- /dev/null +++ b/apps/backend/src/middlewares/subdomain-tenant.middleware.ts @@ -0,0 +1,141 @@ +import { + Injectable, + NestMiddleware, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { ClsService } from 'nestjs-cls'; +import { ConfigService } from '@nestjs/config'; + +/** + * Subdomain-based tenant middleware. + * + * Extracts tenant from subdomain (e.g., tenant1.app.com → tenant1). + * Use this as an alternative to header-based tenant resolution. + * + * Configuration: + * - APP_DOMAIN: Your application domain (e.g., 'app.com') + * - SKIP_SUBDOMAINS: Comma-separated subdomains to skip (e.g., 'www,api,localhost') + * + * Usage in app.module.ts: + * ```typescript + * consumer + * .apply(ClsMiddleware, SubdomainTenantMiddleware) + * .forRoutes('*'); + * ``` + * + * To enable subdomain resolution, extend this class and override resolveAccountBySubdomain: + * ```typescript + * @Injectable() + * export class MySubdomainMiddleware extends SubdomainTenantMiddleware { + * constructor( + * cls: ClsService, + * configService: ConfigService, + * private readonly accountsService: AccountsService, + * ) { + * super(cls, configService); + * } + * + * protected async resolveAccountBySubdomain(subdomain: string) { + * return this.accountsService.findBySubdomain(subdomain); + * } + * } + * ``` + */ +@Injectable() +export class SubdomainTenantMiddleware implements NestMiddleware { + private readonly skipSubdomains: string[]; + private readonly appDomain: string; + + constructor( + private readonly cls: ClsService, + private readonly configService: ConfigService, + ) { + this.appDomain = this.configService.get('APP_DOMAIN', 'localhost'); + this.skipSubdomains = this.configService + .get('SKIP_SUBDOMAINS', 'www,api,localhost') + .split(',') + .map((s) => s.trim().toLowerCase()); + } + + async use(req: Request, res: Response, next: NextFunction) { + const host = req.hostname.toLowerCase(); + + // Extract subdomain + const subdomain = this.extractSubdomain(host); + + // Skip if no subdomain or it's in the skip list + if (!subdomain || this.skipSubdomains.includes(subdomain)) { + return next(); + } + + // Look up account by subdomain + const account = await this.resolveAccountBySubdomain(subdomain); + if (!account) { + throw new HttpException( + `Tenant '${subdomain}' not found`, + HttpStatus.NOT_FOUND, + ); + } + + // Store account ID in CLS context + this.cls.set('accountId', account.id); + this.cls.set('tenantSubdomain', subdomain); + + next(); + } + + private extractSubdomain(host: string): string | null { + // Handle localhost with port + if (host.startsWith('localhost')) { + return null; + } + + // Handle IP addresses + if (/^\d+\.\d+\.\d+\.\d+/.test(host)) { + return null; + } + + const parts = host.split('.'); + + // Need at least 3 parts for subdomain.domain.tld + if (parts.length < 3) { + return null; + } + + return parts[0]; + } + + /** + * Resolve account by subdomain. + * + * Override this method or implement findBySubdomain in AccountsService + * to enable subdomain-based tenant resolution. + * + * Required changes: + * 1. Add 'subdomain' column to Account entity + * 2. Add findBySubdomain method to AccountsService + * 3. Or override this method in a subclass + */ + protected async resolveAccountBySubdomain( + subdomain: string, + ): Promise<{ id: string } | null> { + // TODO: Implement one of these approaches: + // + // Option 1: Add to AccountsService + // return await this.accountsService.findBySubdomain(subdomain); + // + // Option 2: Query directly + // return await this.accountRepository.findOne({ where: { subdomain } }); + // + // Option 3: Use subdomain as account ID (if subdomains are UUIDs) + // return { id: subdomain }; + + console.warn( + `SubdomainTenantMiddleware: findBySubdomain not implemented. ` + + `Subdomain '${subdomain}' cannot be resolved.`, + ); + return null; + } +}