/** * Tenant-Aware Authentication Middleware * Enforces tenant isolation in all queries * Superior to Azure with more flexible permission model */ import { FastifyRequest, FastifyReply } from 'fastify' import { identityService, TokenValidationResult } from '../services/identity.js' import { getDb } from '../db/index.js' import { logger } from '../lib/logger.js' export interface TenantContext { tenantId?: string userId: string email: string role: string tenantRole?: string permissions: Record isSystemAdmin: boolean } declare module 'fastify' { interface FastifyRequest { tenantContext?: TenantContext } } /** * Resolve tenant context from X-API-Key (for /api/v1/* client and partner API access). * Uses api_keys table: key hash, tenant_id, permissions. */ async function extractTenantContextFromApiKey( request: FastifyRequest ): Promise { const apiKey = (request.headers['x-api-key'] as string) || (request.headers['X-API-Key'] as string) if (!apiKey?.trim()) return null const { verifyApiKey } = await import('../services/api-key.js') const result = await verifyApiKey(apiKey.trim()) if (!result) return null return { tenantId: result.tenantId ?? undefined, userId: result.userId, email: '', role: 'API_KEY', permissions: { scopes: result.permissions }, isSystemAdmin: false, } } /** * Extract tenant context from request (JWT or X-API-Key for /api/v1/*) */ export async function extractTenantContext( request: FastifyRequest ): Promise { const authHeader = request.headers.authorization const isRailingPath = typeof request.url === 'string' && request.url.startsWith('/api/v1') // For /api/v1/*, allow X-API-Key when no Bearer token if (isRailingPath && (!authHeader || !authHeader.startsWith('Bearer '))) { const apiKeyContext = await extractTenantContextFromApiKey(request) if (apiKeyContext) return apiKeyContext return null } // JWT path if (!authHeader || !authHeader.startsWith('Bearer ')) { return null } const token = authHeader.substring(7) // Validate token const validation = await identityService.validateToken(token) if (!validation.valid || !validation.userId) { return null } // Get user from database const db = getDb() const userResult = await db.query('SELECT id, email, role FROM users WHERE id = $1', [ validation.userId, ]) if (userResult.rows.length === 0) { return null } const user = userResult.rows[0] const isSystemAdmin = user.role === 'ADMIN' // Get tenant information if tenant ID is present let tenantRole: string | undefined let tenantPermissions: Record = {} if (validation.tenantId) { const tenantUserResult = await db.query( `SELECT role, permissions FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`, [validation.tenantId, validation.userId] ) if (tenantUserResult.rows.length > 0) { tenantRole = tenantUserResult.rows[0].role tenantPermissions = tenantUserResult.rows[0].permissions || {} } } return { tenantId: validation.tenantId, userId: validation.userId, email: validation.email || user.email, role: user.role, tenantRole, permissions: { ...tenantPermissions, ...(validation.permissions || {}) }, isSystemAdmin, } } /** * Tenant-aware authentication middleware */ export async function tenantAuthMiddleware( request: FastifyRequest, reply: FastifyReply ): Promise { // Skip auth for health check and GraphQL introspection if (request.url === '/health' || request.method === 'OPTIONS') { return } const context = await extractTenantContext(request) if (!context) { // Allow unauthenticated requests - GraphQL will handle auth per query/mutation return } // Attach tenant context to request request.tenantContext = context // Set tenant context in database session for RLS policies const db = getDb() if (context.userId) { await db.query(`SET LOCAL app.current_user_id = $1`, [context.userId]) } } /** * Require authentication middleware */ export function requireAuth( request: FastifyRequest, reply: FastifyReply ): TenantContext { const context = request.tenantContext if (!context) { reply.code(401).send({ error: 'Authentication required', code: 'UNAUTHENTICATED', }) throw new Error('Authentication required') } return context } /** * Require tenant membership middleware */ export function requireTenant( request: FastifyRequest, reply: FastifyReply ): TenantContext { const context = requireAuth(request, reply) if (!context.tenantId && !context.isSystemAdmin) { reply.code(403).send({ error: 'Tenant membership required', code: 'TENANT_REQUIRED', }) throw new Error('Tenant membership required') } return context } /** * Require specific tenant role */ export function requireTenantRole( allowedRoles: string[] ) { return (request: FastifyRequest, reply: FastifyReply): TenantContext => { const context = requireTenant(request, reply) if (context.isSystemAdmin) { return context } if (!context.tenantRole || !allowedRoles.includes(context.tenantRole)) { reply.code(403).send({ error: 'Insufficient permissions', code: 'FORBIDDEN', required: allowedRoles, current: context.tenantRole, }) throw new Error('Insufficient tenant permissions') } return context } } /** * Require system admin middleware */ export function requireSystemAdmin( request: FastifyRequest, reply: FastifyReply ): TenantContext { const context = requireAuth(request, reply) if (!context.isSystemAdmin) { reply.code(403).send({ error: 'System administrator access required', code: 'FORBIDDEN', }) throw new Error('System administrator access required') } return context } /** * Filter resources by tenant automatically */ export function filterByTenant( query: string, params: any[], context: TenantContext ): { query: string; params: any[] } { // If system admin, don't filter if (context.isSystemAdmin) { return { query, params } } // If no tenant context, filter to show only system resources (tenant_id IS NULL) if (!context.tenantId) { const whereClause = query.includes('WHERE') ? 'AND tenant_id IS NULL' : 'WHERE tenant_id IS NULL' return { query: `${query} ${whereClause}`, params, } } // Filter by tenant_id const whereClause = query.includes('WHERE') ? `AND tenant_id = $${params.length + 1}` : `WHERE tenant_id = $${params.length + 1}` return { query: `${query} ${whereClause}`, params: [...params, context.tenantId], } }