Apply Composer changes: comprehensive API updates, migrations, middleware, and infrastructure improvements
- Add comprehensive database migrations (001-024) for schema evolution - Enhance API schema with expanded type definitions and resolvers - Add new middleware: audit logging, rate limiting, MFA enforcement, security, tenant auth - Implement new services: AI optimization, billing, blockchain, compliance, marketplace - Add adapter layer for cloud integrations (Cloudflare, Kubernetes, Proxmox, storage) - Update Crossplane provider with enhanced VM management capabilities - Add comprehensive test suite for API endpoints and services - Update frontend components with improved GraphQL subscriptions and real-time updates - Enhance security configurations and headers (CSP, CORS, etc.) - Update documentation and configuration files - Add new CI/CD workflows and validation scripts - Implement design system improvements and UI enhancements
This commit is contained in:
70
api/src/middleware/__tests__/rate-limit.test.ts
Normal file
70
api/src/middleware/__tests__/rate-limit.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Rate Limiting Middleware Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { rateLimitMiddleware } from '../rate-limit'
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
|
||||
describe('Rate Limiting Middleware', () => {
|
||||
let mockRequest: Partial<FastifyRequest>
|
||||
let mockReply: Partial<FastifyReply>
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
ip: '127.0.0.1',
|
||||
socket: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
}
|
||||
|
||||
mockReply = {
|
||||
code: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
header: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
// Clear module cache to reset store
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should allow requests within rate limit', async () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await rateLimitMiddleware(
|
||||
mockRequest as FastifyRequest,
|
||||
mockReply as FastifyReply
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockReply.code).not.toHaveBeenCalledWith(429)
|
||||
})
|
||||
|
||||
it('should reject requests exceeding rate limit', async () => {
|
||||
// Make 101 requests (exceeding limit of 100)
|
||||
for (let i = 0; i < 101; i++) {
|
||||
await rateLimitMiddleware(
|
||||
mockRequest as FastifyRequest,
|
||||
mockReply as FastifyReply
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockReply.code).toHaveBeenCalledWith(429)
|
||||
expect(mockReply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Too Many Requests',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should include rate limit headers', async () => {
|
||||
await rateLimitMiddleware(
|
||||
mockRequest as FastifyRequest,
|
||||
mockReply as FastifyReply
|
||||
)
|
||||
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-RateLimit-Limit', expect.any(String))
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-RateLimit-Remaining', expect.any(String))
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(String))
|
||||
})
|
||||
})
|
||||
|
||||
82
api/src/middleware/__tests__/security.test.ts
Normal file
82
api/src/middleware/__tests__/security.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Security Middleware Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
securityHeadersMiddleware,
|
||||
sanitizeInput,
|
||||
sanitizeBodyMiddleware,
|
||||
} from '../security'
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
describe('securityHeadersMiddleware', () => {
|
||||
it('should add security headers', async () => {
|
||||
const mockReply = {
|
||||
header: vi.fn().mockReturnThis(),
|
||||
} as any
|
||||
|
||||
await securityHeadersMiddleware({} as FastifyRequest, mockReply)
|
||||
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff')
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-Frame-Options', 'DENY')
|
||||
expect(mockReply.header).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block')
|
||||
expect(mockReply.header).toHaveBeenCalledWith(
|
||||
'Strict-Transport-Security',
|
||||
expect.stringContaining('max-age')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInput', () => {
|
||||
it('should remove script tags', () => {
|
||||
const input = '<script>alert("xss")</script>Hello'
|
||||
const sanitized = sanitizeInput(input)
|
||||
expect(sanitized).not.toContain('<script>')
|
||||
expect(sanitized).toContain('Hello')
|
||||
})
|
||||
|
||||
it('should remove javascript: protocol', () => {
|
||||
const input = 'javascript:alert("xss")'
|
||||
const sanitized = sanitizeInput(input)
|
||||
expect(sanitized).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('should sanitize nested objects', () => {
|
||||
const input = {
|
||||
name: '<script>alert("xss")</script>',
|
||||
nested: {
|
||||
value: 'javascript:test',
|
||||
},
|
||||
}
|
||||
const sanitized = sanitizeInput(input)
|
||||
expect(sanitized.name).not.toContain('<script>')
|
||||
expect(sanitized.nested.value).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('should sanitize arrays', () => {
|
||||
const input = ['<script>test</script>', 'normal', 'javascript:test']
|
||||
const sanitized = sanitizeInput(input)
|
||||
expect(sanitized[0]).not.toContain('<script>')
|
||||
expect(sanitized[2]).not.toContain('javascript:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeBodyMiddleware', () => {
|
||||
it('should sanitize request body', async () => {
|
||||
const mockRequest = {
|
||||
body: {
|
||||
name: '<script>alert("xss")</script>',
|
||||
},
|
||||
} as any
|
||||
|
||||
const mockReply = {} as FastifyReply
|
||||
|
||||
await sanitizeBodyMiddleware(mockRequest, mockReply)
|
||||
|
||||
expect(mockRequest.body.name).not.toContain('<script>')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
71
api/src/middleware/audit-middleware.ts
Normal file
71
api/src/middleware/audit-middleware.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Audit Middleware
|
||||
*
|
||||
* Automatically logs audit events for all requests
|
||||
* Per DoD/MilSpec requirements (NIST SP 800-53: AU-2, AU-3)
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { logAuditEvent, logAuthentication, logDataAccess } from '../services/audit-logger'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
/**
|
||||
* Audit middleware - logs all requests for audit trail
|
||||
*/
|
||||
export async function auditMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
// Skip audit logging for health checks and WebSocket upgrades
|
||||
if (request.url === '/health' || request.url === '/graphql-ws') {
|
||||
return
|
||||
}
|
||||
|
||||
const user = (request as any).user
|
||||
const startTime = Date.now()
|
||||
|
||||
// Log request
|
||||
try {
|
||||
// Determine event type based on request
|
||||
if (request.url === '/graphql') {
|
||||
// GraphQL request - log based on operation
|
||||
const body = request.body as any
|
||||
const operation = body?.operationName || 'UNKNOWN'
|
||||
|
||||
await logAuditEvent({
|
||||
eventType: 'DATA_ACCESS',
|
||||
result: reply.statusCode < 400 ? 'SUCCESS' : 'FAILURE',
|
||||
userId: user?.id,
|
||||
userName: user?.name,
|
||||
userRole: user?.role,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
action: `GRAPHQL_${operation}`,
|
||||
details: {
|
||||
query: body?.query?.substring(0, 200), // Log first 200 chars of query
|
||||
variables: body?.variables ? 'PRESENT' : 'NONE', // Don't log full variables
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Regular HTTP request
|
||||
await logAuditEvent({
|
||||
eventType: 'DATA_ACCESS',
|
||||
result: reply.statusCode < 400 ? 'SUCCESS' : 'FAILURE',
|
||||
userId: user?.id,
|
||||
userName: user?.name,
|
||||
userRole: user?.role,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
action: `${request.method} ${request.url}`,
|
||||
details: {
|
||||
statusCode: reply.statusCode,
|
||||
responseTime: Date.now() - startTime,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the request if audit logging fails, but log the error
|
||||
logger.error('Failed to log audit event', { error, request: request.url })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { User } from '../types/context'
|
||||
import { JWTPayload } from '../types/jwt'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
const JWT_SECRET = process.env.JWT_SECRET
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required')
|
||||
}
|
||||
|
||||
export async function authMiddleware(
|
||||
request: FastifyRequest,
|
||||
@@ -23,14 +27,14 @@ export async function authMiddleware(
|
||||
const token = authHeader.substring(7)
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
|
||||
// Attach user to request
|
||||
;(request as any).user = {
|
||||
;(request as FastifyRequest & { user: User }).user = {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
name: decoded.name,
|
||||
role: decoded.role,
|
||||
} as User
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid token - let GraphQL resolvers handle it
|
||||
return
|
||||
|
||||
161
api/src/middleware/mfa-enforcement.ts
Normal file
161
api/src/middleware/mfa-enforcement.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* MFA Enforcement Middleware
|
||||
*
|
||||
* Enforces MFA requirements per DoD/MilSpec standards:
|
||||
* - NIST SP 800-53: IA-2 (Identification and Authentication)
|
||||
* - NIST SP 800-63B: Digital Identity Guidelines
|
||||
*
|
||||
* Requires MFA for:
|
||||
* - All privileged operations
|
||||
* - Access to classified data
|
||||
* - Administrative actions
|
||||
* - Security-sensitive operations
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { hasMFAEnabled, verifyMFAChallenge } from '../services/mfa'
|
||||
import { getDb } from '../db'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
/**
|
||||
* Operations that require MFA
|
||||
*/
|
||||
const MFA_REQUIRED_OPERATIONS = [
|
||||
// Administrative operations
|
||||
'createTenant',
|
||||
'updateTenant',
|
||||
'deleteTenant',
|
||||
'suspendTenant',
|
||||
'activateTenant',
|
||||
|
||||
// Security operations
|
||||
'createUser',
|
||||
'updateUser',
|
||||
'deleteUser',
|
||||
'changePassword',
|
||||
'updateRole',
|
||||
'grantPermission',
|
||||
'revokePermission',
|
||||
|
||||
// Resource management
|
||||
'createResource',
|
||||
'updateResource',
|
||||
'deleteResource',
|
||||
'provisionVM',
|
||||
'destroyVM',
|
||||
|
||||
// Billing operations
|
||||
'createInvoice',
|
||||
'updateBillingAccount',
|
||||
'processPayment',
|
||||
|
||||
// Compliance operations
|
||||
'exportAuditLog',
|
||||
'generateComplianceReport',
|
||||
'updateSecurityPolicy',
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if an operation requires MFA
|
||||
*/
|
||||
function requiresMFA(operation: string): boolean {
|
||||
return MFA_REQUIRED_OPERATIONS.includes(operation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation name from GraphQL request
|
||||
*/
|
||||
function getOperationName(request: FastifyRequest): string | null {
|
||||
if (request.body && typeof request.body === 'object') {
|
||||
const body = request.body as any
|
||||
if (body.operationName) {
|
||||
return body.operationName
|
||||
}
|
||||
if (body.query) {
|
||||
// Parse GraphQL query to extract operation name
|
||||
const match = body.query.match(/(?:mutation|query)\s+(\w+)/)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA Enforcement Middleware
|
||||
* Checks if MFA is required and verified for the current request
|
||||
*/
|
||||
export async function mfaEnforcementMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
// Skip MFA check for health endpoints and WebSocket upgrades
|
||||
if (request.url === '/health' || request.url === '/graphql-ws') {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from request context (set by auth middleware)
|
||||
const user = (request as any).user
|
||||
if (!user) {
|
||||
// Not authenticated - auth middleware will handle
|
||||
return
|
||||
}
|
||||
|
||||
// Get operation name
|
||||
const operation = getOperationName(request)
|
||||
if (!operation || !requiresMFA(operation)) {
|
||||
// Operation doesn't require MFA
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has MFA enabled
|
||||
const mfaEnabled = await hasMFAEnabled(user.id)
|
||||
if (!mfaEnabled) {
|
||||
logger.warn('MFA required but not enabled', { userId: user.id, operation })
|
||||
reply.code(403).send({
|
||||
error: 'MFA_REQUIRED',
|
||||
message: 'Multi-factor authentication is required for this operation. Please enable MFA in your account settings.',
|
||||
operation,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for MFA challenge verification
|
||||
const mfaChallengeId = request.headers['x-mfa-challenge-id'] as string
|
||||
const mfaToken = request.headers['x-mfa-token'] as string
|
||||
|
||||
if (!mfaChallengeId || !mfaToken) {
|
||||
logger.warn('MFA challenge missing', { userId: user.id, operation })
|
||||
reply.code(403).send({
|
||||
error: 'MFA_CHALLENGE_REQUIRED',
|
||||
message: 'MFA challenge verification required for this operation',
|
||||
operation,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify MFA challenge
|
||||
const verified = await verifyMFAChallenge(mfaChallengeId, user.id, mfaToken)
|
||||
if (!verified) {
|
||||
logger.warn('MFA challenge verification failed', { userId: user.id, operation, challengeId: mfaChallengeId })
|
||||
reply.code(403).send({
|
||||
error: 'MFA_VERIFICATION_FAILED',
|
||||
message: 'MFA verification failed. Please try again.',
|
||||
operation,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// MFA verified - allow request to proceed
|
||||
logger.info('MFA verified for operation', { userId: user.id, operation })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA is required for a specific operation
|
||||
* Can be used in GraphQL resolvers
|
||||
*/
|
||||
export function checkMFARequired(operation: string, userId: string): Promise<boolean> {
|
||||
return requiresMFA(operation) ? hasMFAEnabled(userId) : Promise.resolve(false)
|
||||
}
|
||||
|
||||
77
api/src/middleware/rate-limit.ts
Normal file
77
api/src/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
* Implements rate limiting for API endpoints
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
|
||||
interface RateLimitStore {
|
||||
[key: string]: {
|
||||
count: number
|
||||
resetTime: number
|
||||
}
|
||||
}
|
||||
|
||||
const store: RateLimitStore = {}
|
||||
|
||||
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
|
||||
const RATE_LIMIT_MAX_REQUESTS = 100 // 100 requests per minute
|
||||
|
||||
/**
|
||||
* Get client identifier from request
|
||||
*/
|
||||
function getClientId(request: FastifyRequest): string {
|
||||
// Use IP address or user ID
|
||||
const ip = request.ip || request.socket.remoteAddress || 'unknown'
|
||||
const userId = (request as any).user?.id
|
||||
|
||||
return userId ? `user:${userId}` : `ip:${ip}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
*/
|
||||
export async function rateLimitMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const clientId = getClientId(request)
|
||||
const now = Date.now()
|
||||
|
||||
// Clean up expired entries
|
||||
Object.keys(store).forEach((key) => {
|
||||
if (store[key].resetTime < now) {
|
||||
delete store[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Get or create rate limit entry
|
||||
let entry = store[clientId]
|
||||
|
||||
if (!entry || entry.resetTime < now) {
|
||||
entry = {
|
||||
count: 0,
|
||||
resetTime: now + RATE_LIMIT_WINDOW,
|
||||
}
|
||||
store[clientId] = entry
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
|
||||
reply.code(429).send({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
reply.header('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString())
|
||||
reply.header('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX_REQUESTS - entry.count).toString())
|
||||
reply.header('X-RateLimit-Reset', entry.resetTime.toString())
|
||||
}
|
||||
|
||||
141
api/src/middleware/security.ts
Normal file
141
api/src/middleware/security.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Security Middleware
|
||||
* Implements security headers and protections per DoD/MilSpec standards
|
||||
*
|
||||
* Complies with:
|
||||
* - DISA STIG: Web Server Security
|
||||
* - NIST SP 800-53: SI-4 (Information System Monitoring)
|
||||
* - NIST SP 800-171: 3.13.1 (Cryptographic Protection in Transit)
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
/**
|
||||
* Add security headers to responses per DoD/MilSpec requirements
|
||||
*
|
||||
* Implements comprehensive security headers as required by:
|
||||
* - DISA STIG for Web Servers
|
||||
* - OWASP Secure Headers Project
|
||||
* - DoD Security Technical Implementation Guides
|
||||
*/
|
||||
export async function securityHeadersMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
// Prevent MIME type sniffing (DISA STIG requirement)
|
||||
reply.header('X-Content-Type-Options', 'nosniff')
|
||||
|
||||
// Prevent clickjacking attacks (DISA STIG requirement)
|
||||
reply.header('X-Frame-Options', 'DENY')
|
||||
|
||||
// Legacy XSS protection (deprecated but still recommended for older browsers)
|
||||
reply.header('X-XSS-Protection', '1; mode=block')
|
||||
|
||||
// HTTP Strict Transport Security (HSTS) with preload
|
||||
// max-age: 1 year (31536000 seconds)
|
||||
// includeSubDomains: Apply to all subdomains
|
||||
// preload: Allow inclusion in HSTS preload lists
|
||||
const hstsMaxAge = 31536000 // 1 year
|
||||
reply.header('Strict-Transport-Security', `max-age=${hstsMaxAge}; includeSubDomains; preload`)
|
||||
|
||||
// Content Security Policy (CSP) per STIG requirements
|
||||
// Strict CSP to prevent XSS and injection attacks
|
||||
// Generate nonce for inline scripts/styles to avoid unsafe-inline
|
||||
const nonce = randomBytes(16).toString('base64')
|
||||
// Store nonce in request for use in templates
|
||||
;(request as any).cspNonce = nonce
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}'`, // Use nonce instead of unsafe-inline
|
||||
`style-src 'self' 'nonce-${nonce}'`, // Use nonce instead of unsafe-inline
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"upgrade-insecure-requests",
|
||||
].join('; ')
|
||||
reply.header('Content-Security-Policy', csp)
|
||||
|
||||
// Referrer Policy - control referrer information leakage
|
||||
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
|
||||
// Permissions Policy (formerly Feature Policy) - disable unnecessary features
|
||||
reply.header('Permissions-Policy', [
|
||||
'geolocation=()',
|
||||
'microphone=()',
|
||||
'camera=()',
|
||||
'payment=()',
|
||||
'usb=()',
|
||||
'magnetometer=()',
|
||||
'gyroscope=()',
|
||||
'accelerometer=()',
|
||||
].join(', '))
|
||||
|
||||
// X-Permitted-Cross-Domain-Policies - restrict cross-domain policies
|
||||
reply.header('X-Permitted-Cross-Domain-Policies', 'none')
|
||||
|
||||
// Expect-CT - Certificate Transparency (deprecated but still used)
|
||||
// Note: This header is deprecated but may still be required for some compliance
|
||||
// reply.header('Expect-CT', 'max-age=86400, enforce')
|
||||
|
||||
// Cross-Origin-Embedder-Policy - prevent cross-origin data leakage
|
||||
reply.header('Cross-Origin-Embedder-Policy', 'require-corp')
|
||||
|
||||
// Cross-Origin-Opener-Policy - isolate browsing context
|
||||
reply.header('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
|
||||
// Cross-Origin-Resource-Policy - control resource loading
|
||||
reply.header('Cross-Origin-Resource-Policy', 'same-origin')
|
||||
|
||||
// Remove server information disclosure
|
||||
// Note: Fastify doesn't expose server header by default, but we ensure it's not set
|
||||
reply.removeHeader('Server')
|
||||
reply.removeHeader('X-Powered-By')
|
||||
}
|
||||
|
||||
/**
|
||||
* Input sanitization helper
|
||||
*/
|
||||
export function sanitizeInput(input: unknown): unknown {
|
||||
if (typeof input === 'string') {
|
||||
// Remove potentially dangerous characters
|
||||
return input
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(sanitizeInput)
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const key in input) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
sanitized[key] = sanitizeInput((input as Record<string, unknown>)[key])
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize request body
|
||||
*/
|
||||
export async function sanitizeBodyMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
if (request.body) {
|
||||
request.body = sanitizeInput(request.body)
|
||||
}
|
||||
}
|
||||
|
||||
237
api/src/middleware/tenant-auth.ts
Normal file
237
api/src/middleware/tenant-auth.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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<string, any>
|
||||
isSystemAdmin: boolean
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
tenantContext?: TenantContext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tenant context from request
|
||||
*/
|
||||
export async function extractTenantContext(
|
||||
request: FastifyRequest
|
||||
): Promise<TenantContext | null> {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.authorization
|
||||
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<string, any> = {}
|
||||
|
||||
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<void> {
|
||||
// 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],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user