- Phoenix API Railing: proxy to PHOENIX_RAILING_URL, tenant me routes - Tenant-auth: X-API-Key support for /api/v1/* (api_keys table) - Migration 026: api_keys table; 025 sovereign stack marketplace - GET /graphql/schema, GET /graphql-playground, api/docs OpenAPI - Integration tests: phoenix-railing.test.ts - docs/api/API_VERSIONING: /api/v1/ railing alignment - docs/phoenix/PORTAL_RAILING_WIRING Made-with: Cursor
269 lines
6.7 KiB
TypeScript
269 lines
6.7 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<TenantContext | null> {
|
|
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<TenantContext | null> {
|
|
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<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],
|
|
}
|
|
}
|
|
|