Files
Sankofa/api/src/middleware/tenant-auth.ts
defiQUG 8436e22f4c API: Phoenix railing proxy, API key auth for /api/v1/*, schema export, docs, migrations, tests
- 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
2026-03-11 12:57:41 -07:00

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],
}
}