mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat(tenant): implement tenant context resolution and add interceptor
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -35,6 +35,8 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
|
||||
globalPrefix: options.globalPrefix ?? '/api',
|
||||
})
|
||||
|
||||
const container = app.getContainer()
|
||||
|
||||
app.useGlobalFilters(new AllExceptionsFilter())
|
||||
app.useGlobalInterceptors(new LoggingInterceptor())
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor())
|
||||
@@ -42,7 +44,6 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
|
||||
app.useGlobalPipes(new GlobalValidationPipe())
|
||||
|
||||
// Warm up DB connection during bootstrap
|
||||
const container = app.getContainer()
|
||||
const poolProvider = container.resolve(PgPoolProvider)
|
||||
await poolProvider.warmup()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Session } from 'better-auth'
|
||||
import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { getTenantContext } from 'core/modules/tenant/tenant.context'
|
||||
import { TenantContextResolver } from 'core/modules/tenant/tenant-context-resolver.service'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@@ -30,13 +31,22 @@ export class AuthGuard implements CanActivate {
|
||||
private readonly authProvider: AuthProvider,
|
||||
private readonly tenantAuthProvider: TenantAuthProvider,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly tenantContextResolver: TenantContextResolver,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const store = context.getContext()
|
||||
const { hono } = store
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
let tenantContext = getTenantContext()
|
||||
|
||||
if (!tenantContext) {
|
||||
const resolvedTenant = await this.tenantContextResolver.resolve(hono, {
|
||||
setResponseHeaders: false,
|
||||
})
|
||||
HttpContext.setValue('tenant', tenantContext)
|
||||
tenantContext = resolvedTenant ?? undefined
|
||||
}
|
||||
|
||||
const { headers } = hono.req.raw
|
||||
|
||||
|
||||
24
be/apps/core/src/interceptors/tenant-resolver.decorator.ts
Normal file
24
be/apps/core/src/interceptors/tenant-resolver.decorator.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
|
||||
|
||||
export const TENANT_RESOLUTION_OPTIONS = Symbol('core:tenantResolutionOptions')
|
||||
|
||||
type DecoratorTarget = object | Function
|
||||
|
||||
function setMetadata(target: DecoratorTarget, options: TenantResolutionOptions): void {
|
||||
Reflect.defineMetadata(TENANT_RESOLUTION_OPTIONS, options, target)
|
||||
}
|
||||
|
||||
export function TenantResolution(options: TenantResolutionOptions): ClassDecorator & MethodDecorator {
|
||||
return ((
|
||||
target: DecoratorTarget,
|
||||
_propertyKey?: string | symbol,
|
||||
descriptor?: PropertyDescriptor,
|
||||
): void | PropertyDescriptor => {
|
||||
if (descriptor && descriptor.value) {
|
||||
setMetadata(descriptor.value as DecoratorTarget, options)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
setMetadata(target, options)
|
||||
}) as unknown as ClassDecorator & MethodDecorator
|
||||
}
|
||||
54
be/apps/core/src/interceptors/tenant-resolver.interceptor.ts
Normal file
54
be/apps/core/src/interceptors/tenant-resolver.interceptor.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
|
||||
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
|
||||
import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
|
||||
|
||||
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
|
||||
throwOnMissing: true,
|
||||
setResponseHeaders: true,
|
||||
skipInitializationCheck: false,
|
||||
}
|
||||
|
||||
function getResolutionOptions(target: object | Function | undefined): TenantResolutionOptions | undefined {
|
||||
if (!target) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return Reflect.getMetadata(TENANT_RESOLUTION_OPTIONS, target) as TenantResolutionOptions | undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOptions(
|
||||
classOptions: TenantResolutionOptions | undefined,
|
||||
handlerOptions: TenantResolutionOptions | undefined,
|
||||
): TenantResolutionOptions {
|
||||
return {
|
||||
...DEFAULT_OPTIONS,
|
||||
...classOptions,
|
||||
...handlerOptions,
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TenantResolverInterceptor implements Interceptor {
|
||||
constructor(private readonly tenantContextResolver: TenantContextResolver) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
|
||||
const { hono } = context.getContext()
|
||||
const handler = context.getHandler()
|
||||
const clazz = context.getClass()
|
||||
|
||||
const classOptions = getResolutionOptions(clazz)
|
||||
const handlerOptions = getResolutionOptions(handler)
|
||||
const resolutionOptions = mergeOptions(classOptions, handlerOptions)
|
||||
|
||||
await this.tenantContextResolver.resolve(hono, resolutionOptions)
|
||||
|
||||
return await next.handle()
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { injectable } from 'tsyringe'
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import { SettingService } from '../modules/setting/setting.service'
|
||||
import { getTenantContext } from '../modules/tenant/tenant.context'
|
||||
import { TenantService } from '../modules/tenant/tenant.service'
|
||||
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
|
||||
|
||||
type AllowedOrigins = '*' | string[]
|
||||
|
||||
@@ -51,7 +51,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitterService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly tenantContextResolver: TenantContextResolver,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
|
||||
@@ -132,7 +132,11 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
||||
return await next()
|
||||
}
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||
setResponseHeaders: false,
|
||||
skipInitializationCheck: true,
|
||||
})
|
||||
|
||||
const tenantId = tenantContext?.tenant.id
|
||||
|
||||
if (tenantId) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { APP_GUARD, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
|
||||
import { AuthGuard } from 'core/guards/auth.guard'
|
||||
import { TenantResolverInterceptor } from 'core/interceptors/tenant-resolver.interceptor'
|
||||
import { CorsMiddleware } from 'core/middlewares/cors.middleware'
|
||||
import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware'
|
||||
import { TenantResolverMiddleware } from 'core/middlewares/tenant-resolver.middleware'
|
||||
import { RedisAccessor } from 'core/redis/redis.provider'
|
||||
|
||||
import { DatabaseModule } from '../database/database.module'
|
||||
@@ -52,10 +52,6 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
provide: APP_MIDDLEWARE,
|
||||
useClass: CorsMiddleware,
|
||||
},
|
||||
{
|
||||
provide: APP_MIDDLEWARE,
|
||||
useClass: TenantResolverMiddleware,
|
||||
},
|
||||
{
|
||||
provide: APP_MIDDLEWARE,
|
||||
useClass: DatabaseContextMiddleware,
|
||||
@@ -65,6 +61,10 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TenantResolverInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModules {}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import type { HttpMiddleware } from '@afilmory/framework'
|
||||
import { HttpContext, Middleware } from '@afilmory/framework'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import type { Context, Next } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import { OnboardingService } from '../modules/onboarding/onboarding.service'
|
||||
import { SuperAdminSettingService } from '../modules/system-setting/super-admin-setting.service'
|
||||
import { TenantService } from '../modules/tenant/tenant.service'
|
||||
import { logger } from '../../helpers/logger.helper'
|
||||
import { OnboardingService } from '../onboarding/onboarding.service'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { TenantService } from './tenant.service'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
|
||||
const HEADER_TENANT_ID = 'x-tenant-id'
|
||||
const HEADER_TENANT_SLUG = 'x-tenant-slug'
|
||||
|
||||
@Middleware()
|
||||
export interface TenantResolutionOptions {
|
||||
throwOnMissing?: boolean
|
||||
setResponseHeaders?: boolean
|
||||
skipInitializationCheck?: boolean
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TenantResolverMiddleware implements HttpMiddleware {
|
||||
export class TenantContextResolver {
|
||||
private readonly log = logger.extend('TenantResolver')
|
||||
|
||||
constructor(
|
||||
@@ -24,34 +29,23 @@ export class TenantResolverMiddleware implements HttpMiddleware {
|
||||
private readonly superAdminSettingService: SuperAdminSettingService,
|
||||
) {}
|
||||
|
||||
|
||||
async use(context: Context, next: Next): Promise<Response | void> {
|
||||
const { path } = context.req
|
||||
|
||||
// During onboarding (before any user/tenant exists), skip tenant resolution entirely
|
||||
const initialized = await this.onboardingService.isInitialized()
|
||||
if (!initialized) {
|
||||
this.log.info(`Application not initialized yet, skip tenant resolution for ${path}`)
|
||||
return await next()
|
||||
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
|
||||
const existing = this.getExistingContext()
|
||||
if (existing) {
|
||||
if (options.setResponseHeaders !== false) {
|
||||
this.applyTenantHeaders(context, existing)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
const tenantContext = await this.resolveTenantContext(context)
|
||||
|
||||
if (tenantContext) {
|
||||
HttpContext.assign({ tenant: tenantContext })
|
||||
if (!options.skipInitializationCheck) {
|
||||
const initialized = await this.onboardingService.isInitialized()
|
||||
if (!initialized) {
|
||||
this.log.info(`Application not initialized yet, skip tenant resolution for ${context.req.path}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const response = await next()
|
||||
|
||||
if (tenantContext) {
|
||||
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
|
||||
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private async resolveTenantContext(context: Context) {
|
||||
const forwardedHost = context.req.header('x-forwarded-host')
|
||||
const origin = context.req.header('origin')
|
||||
const hostHeader = context.req.header('host')
|
||||
@@ -86,15 +80,34 @@ export class TenantResolverMiddleware implements HttpMiddleware {
|
||||
)
|
||||
|
||||
if (!tenantContext) {
|
||||
if (tenantId || derivedSlug || host) {
|
||||
if (options.throwOnMissing && (tenantId || derivedSlug)) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
HttpContext.setValue('tenant', tenantContext)
|
||||
|
||||
if (options.setResponseHeaders !== false) {
|
||||
this.applyTenantHeaders(context, tenantContext)
|
||||
}
|
||||
|
||||
return tenantContext
|
||||
}
|
||||
|
||||
private getExistingContext(): TenantContext | null {
|
||||
try {
|
||||
return (HttpContext.getValue('tenant') as TenantContext | undefined) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private applyTenantHeaders(context: Context, tenantContext: TenantContext): void {
|
||||
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
|
||||
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
|
||||
}
|
||||
|
||||
private async getBaseDomain(): Promise<string> {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return 'localhost'
|
||||
@@ -5,9 +5,10 @@ import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { TenantRepository } from './tenant.repository'
|
||||
import { TenantService } from './tenant.service'
|
||||
import { TenantContextResolver } from './tenant-context-resolver.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [TenantRepository, TenantService],
|
||||
providers: [TenantRepository, TenantService, TenantContextResolver],
|
||||
})
|
||||
export class TenantModule {}
|
||||
|
||||
Reference in New Issue
Block a user