mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: implement multi-tenancy support in authentication module (#177)
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import type { AuthSession } from 'core/modules/platform/auth/auth.provider'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
|
||||
import type { TenantContext } from 'core/modules/platform/tenant/tenant.types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -18,10 +17,7 @@ import { logger } from '../helpers/logger.helper'
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly log = logger.extend('AuthGuard')
|
||||
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const store = context.getContext()
|
||||
@@ -51,11 +47,7 @@ export class AuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
private async requireTenantContext(method: string, path: string): Promise<TenantContext> {
|
||||
let tenantContext = getTenantContext()
|
||||
if (!tenantContext && this.isPlaceholderAllowedPath(path)) {
|
||||
tenantContext = (await this.createPlaceholderContext(method, path)) as TenantContext
|
||||
}
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
if (!tenantContext) {
|
||||
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
@@ -151,20 +143,4 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private async createPlaceholderContext(method: string, path: string): Promise<TenantContext | null> {
|
||||
try {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
const context: TenantContext = {
|
||||
tenant: placeholder.tenant,
|
||||
isPlaceholder: true,
|
||||
}
|
||||
HttpContext.setValue('tenant', context)
|
||||
this.log.verbose(`Placeholder tenant context injected for ${method} ${path}`)
|
||||
return context
|
||||
} catch (error) {
|
||||
this.log.error('Failed to inject placeholder tenant context', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BypassResponseTransform } from 'core/interceptors/response-transform.de
|
||||
import { SettingKeys } from './setting.constant'
|
||||
import type { GetSettingsBodyDto } from './setting.dto'
|
||||
import { DeleteSettingDto, GetSettingDto, SetSettingDto } from './setting.dto'
|
||||
import type { SettingEntryInput } from './setting.service'
|
||||
import { SettingService } from './setting.service'
|
||||
|
||||
@Controller('settings')
|
||||
@@ -48,7 +49,7 @@ export class SettingController {
|
||||
|
||||
@Post('/')
|
||||
async set(@Body() { entries }: SetSettingDto) {
|
||||
await this.settingService.setMany(entries)
|
||||
await this.settingService.setMany(entries as SettingEntryInput[])
|
||||
return { updated: entries }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createZodDto } from '@afilmory/framework'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SETTING_SCHEMAS, SettingKeys } from './setting.constant'
|
||||
import type { SettingEntryInput } from './setting.service'
|
||||
|
||||
const keySchema = z.enum(SettingKeys)
|
||||
|
||||
@@ -17,7 +18,7 @@ const normalizeEntries = z
|
||||
return entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
||||
}))
|
||||
})) as SettingEntryInput[]
|
||||
})
|
||||
|
||||
const keysInputSchema = z
|
||||
|
||||
@@ -4,9 +4,10 @@ import { DbAccessor } from 'core/database/database.provider'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import type { SystemSettingKey as SystemSettingLiteralKey } from './system-setting.constants'
|
||||
import type {
|
||||
SystemSettingEntryInput,
|
||||
SystemSettingKey,
|
||||
SystemSettingKey as SystemSettingStoreKey,
|
||||
SystemSettingRecord,
|
||||
SystemSettingSetOptions,
|
||||
} from './system-setting.store.types'
|
||||
@@ -18,27 +19,29 @@ export class SystemSettingStore {
|
||||
private readonly eventService: EventEmitterService,
|
||||
) {}
|
||||
|
||||
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
|
||||
async get(key: SystemSettingStoreKey): Promise<SystemSettingRecord['value']> {
|
||||
const record = await this.find(key)
|
||||
return record?.value ?? null
|
||||
}
|
||||
|
||||
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
|
||||
async getMany(
|
||||
keys: readonly SystemSettingStoreKey[],
|
||||
): Promise<Record<SystemSettingStoreKey, SystemSettingRecord['value']>> {
|
||||
if (keys.length === 0) {
|
||||
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
|
||||
return {} as Record<SystemSettingStoreKey, SystemSettingRecord['value']>
|
||||
}
|
||||
|
||||
const uniqueKeys = Array.from(new Set(keys))
|
||||
const db = this.dbAccessor.get()
|
||||
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
|
||||
|
||||
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
|
||||
const map = new Map<SystemSettingStoreKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
|
||||
return uniqueKeys.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = map.get(key)?.value ?? null
|
||||
return acc
|
||||
},
|
||||
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
|
||||
Object.create(null) as Record<SystemSettingStoreKey, SystemSettingRecord['value']>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ export class SystemSettingStore {
|
||||
}
|
||||
|
||||
async set(
|
||||
key: SystemSettingKey,
|
||||
key: SystemSettingStoreKey,
|
||||
value: SystemSettingRecord['value'],
|
||||
options: SystemSettingSetOptions = {},
|
||||
): Promise<void> {
|
||||
@@ -78,7 +81,10 @@ export class SystemSettingStore {
|
||||
},
|
||||
})
|
||||
|
||||
this.eventService.emit('system.setting.updated', { key, value: String(value) })
|
||||
this.eventService.emit('system.setting.updated', {
|
||||
key: key as SystemSettingLiteralKey,
|
||||
value: String(value),
|
||||
})
|
||||
}
|
||||
|
||||
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
|
||||
@@ -87,12 +93,12 @@ export class SystemSettingStore {
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: SystemSettingKey): Promise<void> {
|
||||
async delete(key: SystemSettingStoreKey): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.delete(systemSettings).where(eq(systemSettings.key, key))
|
||||
}
|
||||
|
||||
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
|
||||
private async find(key: SystemSettingStoreKey): Promise<SystemSettingRecord | null> {
|
||||
return await this.findRaw(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { authUsers, tenants } from '@afilmory/db'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
@@ -7,7 +7,7 @@ import type { SettingEntryInput } from 'core/modules/configuration/setting/setti
|
||||
import { SettingService } from 'core/modules/configuration/setting/setting.service'
|
||||
import type { SettingKeyType } from 'core/modules/configuration/setting/setting.type'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||
@@ -65,7 +65,8 @@ export class AuthRegistrationService {
|
||||
await this.systemSettings.ensureRegistrationAllowed()
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
const effectiveTenantContext = isPlaceholderTenantContext(tenantContext) ? null : tenantContext
|
||||
const isPendingTenant = tenantContext ? isPlaceholderTenantContext(tenantContext) : false
|
||||
const effectiveTenantContext = isPendingTenant ? null : tenantContext
|
||||
const account = input.account ? this.normalizeAccountInput(input.account) : null
|
||||
const useSessionAccount = input.useSessionAccount ?? false
|
||||
const sessionUser = this.getSessionUser()
|
||||
@@ -74,6 +75,16 @@ export class AuthRegistrationService {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '请先登录后再创建工作区' })
|
||||
}
|
||||
|
||||
if (isPendingTenant && tenantContext) {
|
||||
return await this.finalizePendingTenant({
|
||||
tenantContext,
|
||||
tenantInput: input.tenant,
|
||||
settings: input.settings,
|
||||
sessionUser,
|
||||
useSessionAccount,
|
||||
})
|
||||
}
|
||||
|
||||
if (effectiveTenantContext) {
|
||||
if (useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户上下文下不支持会话注册' })
|
||||
@@ -195,18 +206,119 @@ export class AuthRegistrationService {
|
||||
})
|
||||
}
|
||||
|
||||
const schema = SETTING_SCHEMAS[key as SettingKeyType]
|
||||
const typedKey = key as SettingKeyType
|
||||
const schema = SETTING_SCHEMAS[typedKey]
|
||||
const value = schema.parse(entry.value)
|
||||
|
||||
normalized.push({
|
||||
key: key as SettingKeyType,
|
||||
key: typedKey,
|
||||
value,
|
||||
})
|
||||
} as SettingEntryInput)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async finalizePendingTenant(params: {
|
||||
tenantContext: { tenant: TenantRecord; requestedSlug?: string | null }
|
||||
tenantInput?: RegisterTenantInput['tenant']
|
||||
settings?: RegisterTenantInput['settings']
|
||||
sessionUser: AuthSession['user'] | null
|
||||
useSessionAccount: boolean
|
||||
}): Promise<RegisterTenantResult> {
|
||||
const { tenantContext, tenantInput, settings, sessionUser, useSessionAccount } = params
|
||||
if (!tenantInput) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' })
|
||||
}
|
||||
if (!useSessionAccount || !sessionUser) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '请通过已登录账号完成工作区初始化。',
|
||||
})
|
||||
}
|
||||
|
||||
const tenantName = tenantInput.name?.trim() ?? ''
|
||||
if (!tenantName) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户名称不能为空' })
|
||||
}
|
||||
|
||||
const currentSlug = tenantContext.tenant.slug?.toLowerCase() ?? ''
|
||||
const requestedSlug =
|
||||
tenantInput.slug?.trim().toLowerCase() ?? tenantContext.requestedSlug?.toLowerCase() ?? currentSlug
|
||||
if (!requestedSlug || requestedSlug !== currentSlug) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '当前子域与请求的空间标识不匹配,无法完成注册。',
|
||||
})
|
||||
}
|
||||
|
||||
const sessionUserId = (sessionUser as { id?: string } | null)?.id
|
||||
if (!sessionUserId) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '当前登录状态无效,请重新登录。' })
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
const [existingUser] = await db
|
||||
.select({ tenantId: authUsers.tenantId })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, sessionUserId))
|
||||
.limit(1)
|
||||
if (existingUser?.tenantId && existingUser.tenantId !== tenantContext.tenant.id) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '当前账号已属于其它工作区,无法重复注册。',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const [updatedTenant] = await db
|
||||
.update(tenants)
|
||||
.set({
|
||||
name: tenantName,
|
||||
status: 'active',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(tenants.id, tenantContext.tenant.id), eq(tenants.status, 'pending')))
|
||||
.returning()
|
||||
|
||||
if (!updatedTenant) {
|
||||
throw new BizException(ErrorCode.COMMON_CONFLICT, {
|
||||
message: '该空间已被其他用户绑定,请联系管理员。',
|
||||
})
|
||||
}
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
tenantId: updatedTenant.id,
|
||||
role: 'admin',
|
||||
name: sessionUser.name ?? sessionUser.email ?? 'Workspace Admin',
|
||||
})
|
||||
.where(eq(authUsers.id, sessionUserId))
|
||||
|
||||
const normalizedSettings = this.normalizeSettings(settings)
|
||||
if (normalizedSettings.length > 0) {
|
||||
await this.settingService.setMany(
|
||||
normalizedSettings.map((entry) => ({
|
||||
...entry,
|
||||
options: {
|
||||
tenantId: updatedTenant.id,
|
||||
isSensitive: false,
|
||||
},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const response = new Response(JSON.stringify({ tenant: updatedTenant }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
return {
|
||||
response,
|
||||
tenant: updatedTenant,
|
||||
accountId: sessionUserId,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
private async registerNewTenant(
|
||||
account: RegisterTenantAccountInput | null,
|
||||
tenantInput: RegisterTenantInput['tenant'],
|
||||
@@ -347,8 +459,8 @@ export class AuthRegistrationService {
|
||||
}
|
||||
|
||||
if (record.tenantId) {
|
||||
const isPlaceholder = await this.tenantService.isPlaceholderTenantId(record.tenantId)
|
||||
if (!isPlaceholder) {
|
||||
const isPending = await this.tenantService.isPendingTenantId(record.tenantId)
|
||||
if (!isPending) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前账号已属于其它工作区,无法重复注册。' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { TenantRecord } from '../tenant/tenant.types'
|
||||
@@ -127,10 +126,10 @@ export class AuthController {
|
||||
const { tenantId } = authContext.user as { tenantId?: string | null }
|
||||
if (tenantId) {
|
||||
try {
|
||||
const aggregate = await this.tenantService.getById(tenantId)
|
||||
const isPlaceholder = aggregate.tenant.slug === PLACEHOLDER_TENANT_SLUG
|
||||
const aggregate = await this.tenantService.getById(tenantId, { allowPending: true })
|
||||
const isPlaceholder = aggregate.tenant.status !== 'active'
|
||||
const existingRequestedSlug = tenantContext?.requestedSlug ?? null
|
||||
const derivedRequestedSlug = existingRequestedSlug ?? (isPlaceholder ? null : (aggregate.tenant.slug ?? null))
|
||||
const derivedRequestedSlug = existingRequestedSlug ?? aggregate.tenant.slug ?? null
|
||||
tenantContext = {
|
||||
tenant: aggregate.tenant,
|
||||
isPlaceholder,
|
||||
@@ -308,12 +307,17 @@ export class AuthController {
|
||||
const { headers } = context.req.raw
|
||||
const tenantContext = getTenantContext()
|
||||
|
||||
// Only allow auto sign-up on real tenants (not placeholder)
|
||||
// On placeholder tenant, users must explicitly register first
|
||||
const isRealTenant = tenantContext && !isPlaceholderTenantContext(tenantContext)
|
||||
const shouldAllowSignUp = body.requestSignUp ?? isRealTenant
|
||||
|
||||
const auth = await this.auth.getAuth()
|
||||
const response = await auth.api.signInSocial({
|
||||
body: {
|
||||
...body,
|
||||
provider,
|
||||
requestSignUp: body.requestSignUp ?? Boolean(tenantContext),
|
||||
requestSignUp: shouldAllowSignUp,
|
||||
},
|
||||
headers,
|
||||
asResponse: true,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createLogger, HttpContext } from '@afilmory/framework'
|
||||
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
|
||||
import { creem } from '@creem_io/better-auth'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { APIError, createAuthMiddleware } from 'better-auth/api'
|
||||
import { admin } from 'better-auth/plugins'
|
||||
import { DrizzleProvider } from 'core/database/database.provider'
|
||||
@@ -20,11 +19,11 @@ import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.s
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import { extractTenantSlugFromHost } from '../tenant/tenant-host.utils'
|
||||
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
|
||||
import { AuthConfig } from './auth.config'
|
||||
import { tenantAwareDrizzleAdapter } from './tenant-aware-adapter'
|
||||
|
||||
export type BetterAuthInstance = ReturnType<typeof betterAuth>
|
||||
|
||||
@@ -33,7 +32,6 @@ const logger = createLogger('Auth')
|
||||
@injectable()
|
||||
export class AuthProvider implements OnModuleInit {
|
||||
private instances = new Map<string, Promise<BetterAuthInstance>>()
|
||||
private placeholderTenantId: string | null = null
|
||||
|
||||
constructor(
|
||||
private readonly config: AuthConfig,
|
||||
@@ -83,16 +81,20 @@ export class AuthProvider implements OnModuleInit {
|
||||
return sanitizedSlug ? `better-auth-${sanitizedSlug}` : 'better-auth'
|
||||
}
|
||||
|
||||
private async resolveFallbackTenantId(): Promise<string | null> {
|
||||
if (this.placeholderTenantId) {
|
||||
return this.placeholderTenantId
|
||||
private async resolveTenantIdOrProvision(tenantSlug: string | null): Promise<string | null> {
|
||||
const tenantIdFromContext = this.resolveTenantIdFromContext()
|
||||
if (tenantIdFromContext) {
|
||||
return tenantIdFromContext
|
||||
}
|
||||
if (!tenantSlug) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
this.placeholderTenantId = placeholder.tenant.id
|
||||
return this.placeholderTenantId
|
||||
const aggregate = await this.tenantService.ensurePendingTenant(tenantSlug)
|
||||
return aggregate.tenant.id
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure placeholder tenant', error)
|
||||
logger.error(`Failed to provision tenant for slug=${tenantSlug}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -221,23 +223,42 @@ export class AuthProvider implements OnModuleInit {
|
||||
)
|
||||
const cookiePrefix = this.buildCookiePrefix(tenantSlug)
|
||||
|
||||
// Use tenant-aware adapter for multi-tenant user/account isolation
|
||||
// This ensures that user lookups (by email) and account lookups (by provider)
|
||||
// are scoped to the current tenant, allowing the same email/social account
|
||||
// to exist as different users in different tenants
|
||||
const ensureTenantId = async () => await this.resolveTenantIdOrProvision(tenantSlug)
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
verification: authVerifications,
|
||||
subscription: creemSubscriptions,
|
||||
database: tenantAwareDrizzleAdapter(
|
||||
db,
|
||||
{
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
verification: authVerifications,
|
||||
subscription: creemSubscriptions,
|
||||
},
|
||||
},
|
||||
}),
|
||||
ensureTenantId,
|
||||
),
|
||||
socialProviders: socialProviders as any,
|
||||
emailAndPassword: { enabled: true },
|
||||
trustedOrigins: await this.buildTrustedOrigins(),
|
||||
session: {
|
||||
freshAge: 0,
|
||||
additionalFields: {
|
||||
tenantId: { type: 'string', input: false },
|
||||
},
|
||||
},
|
||||
account: {
|
||||
additionalFields: {
|
||||
tenantId: { type: 'string', input: false },
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
tenantId: { type: 'string', input: false },
|
||||
@@ -249,27 +270,18 @@ export class AuthProvider implements OnModuleInit {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
if (tenantId) {
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
tenantId,
|
||||
role: user.role ?? 'guest',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTenantId = await this.resolveFallbackTenantId()
|
||||
if (!fallbackTenantId) {
|
||||
return { data: user }
|
||||
const tenantId = await ensureTenantId()
|
||||
if (!tenantId) {
|
||||
throw new APIError('BAD_REQUEST', {
|
||||
message: 'Missing tenant context during account creation.',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
tenantId: fallbackTenantId,
|
||||
role: user.role ?? 'guest',
|
||||
tenantId,
|
||||
role: user.role ?? 'user',
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -279,7 +291,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
const fallbackTenantId = tenantId ?? session.tenantId ?? (await this.resolveFallbackTenantId())
|
||||
const fallbackTenantId = tenantId ?? session.tenantId ?? (await ensureTenantId())
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
@@ -293,7 +305,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
create: {
|
||||
before: async (account) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
const resolvedTenantId = tenantId ?? (await this.resolveFallbackTenantId())
|
||||
const resolvedTenantId = tenantId ?? (await ensureTenantId())
|
||||
if (!resolvedTenantId) {
|
||||
return { data: account }
|
||||
}
|
||||
@@ -375,10 +387,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
const fallbackHost = options.baseDomain.trim().toLowerCase()
|
||||
const requestedHost = (endpoint.host ?? fallbackHost).trim().toLowerCase()
|
||||
const tenantSlugFromContext = this.resolveTenantSlugFromContext()
|
||||
const tenantSlug =
|
||||
tenantSlugFromContext && tenantSlugFromContext !== PLACEHOLDER_TENANT_SLUG
|
||||
? tenantSlugFromContext
|
||||
: (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext)
|
||||
const tenantSlug = tenantSlugFromContext ?? extractTenantSlugFromHost(requestedHost, options.baseDomain)
|
||||
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
|
||||
const protocol = this.determineProtocol(host, endpoint.protocol)
|
||||
|
||||
|
||||
146
be/apps/core/src/modules/platform/auth/tenant-aware-adapter.ts
Normal file
146
be/apps/core/src/modules/platform/auth/tenant-aware-adapter.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import type { Adapter, Where } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
type DrizzleAdapterConfig = Parameters<typeof drizzleAdapter>[1]
|
||||
type DrizzleDb = Parameters<typeof drizzleAdapter>[0]
|
||||
|
||||
type AdapterInstance = ReturnType<ReturnType<typeof drizzleAdapter>>
|
||||
|
||||
type FindOneParams = Parameters<Adapter['findOne']>[0]
|
||||
type FindManyParams = Parameters<Adapter['findMany']>[0]
|
||||
|
||||
/**
|
||||
* Creates a tenant-aware wrapper around the drizzle adapter.
|
||||
*
|
||||
* This wrapper intercepts findOne and findMany operations for 'user' and 'account' models
|
||||
* and automatically injects tenantId filtering based on the current HTTP context.
|
||||
*
|
||||
* This enables true multi-tenancy where:
|
||||
* - Same email can exist in different tenants as different users
|
||||
* - Same social account (e.g., GitHub) can be linked to different users in different tenants
|
||||
*/
|
||||
export function tenantAwareDrizzleAdapter(
|
||||
db: DrizzleDb,
|
||||
config: DrizzleAdapterConfig,
|
||||
getTenantId: () => string | null | Promise<string | null>,
|
||||
): ReturnType<typeof drizzleAdapter> {
|
||||
const baseAdapterFactory = drizzleAdapter(db, config)
|
||||
|
||||
return (options) => {
|
||||
const baseAdapter = baseAdapterFactory(options)
|
||||
|
||||
const wrapFindOne = (originalFindOne: AdapterInstance['findOne']) => {
|
||||
return async (params: FindOneParams) => {
|
||||
const enhancedParams = await injectTenantFilterForFindOne(params, getTenantId)
|
||||
|
||||
return originalFindOne(enhancedParams)
|
||||
}
|
||||
}
|
||||
|
||||
const wrapFindMany = (originalFindMany: AdapterInstance['findMany']) => {
|
||||
return async (params: FindManyParams) => {
|
||||
const enhancedParams = await injectTenantFilterForFindMany(params, getTenantId)
|
||||
return originalFindMany(enhancedParams)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseAdapter,
|
||||
findOne: wrapFindOne(baseAdapter.findOne),
|
||||
findMany: wrapFindMany(baseAdapter.findMany),
|
||||
} as AdapterInstance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects tenantId filter into the where clause for user and account models.
|
||||
* Only applies to models that need tenant isolation.
|
||||
*/
|
||||
async function injectTenantFilterForFindOne(
|
||||
params: FindOneParams,
|
||||
getTenantId: () => string | null | Promise<string | null>,
|
||||
): Promise<FindOneParams> {
|
||||
const modelsRequiringTenantFilter = ['user', 'account', 'session']
|
||||
|
||||
if (!modelsRequiringTenantFilter.includes(params.model)) {
|
||||
return params
|
||||
}
|
||||
|
||||
const tenantId = await getTenantId()
|
||||
if (!tenantId) {
|
||||
// No tenant context - allow query to proceed without tenant filter
|
||||
// This handles edge cases like initial setup or cross-tenant admin operations
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND, {
|
||||
message: 'Tenant Id is required',
|
||||
})
|
||||
}
|
||||
|
||||
const tenantFilter: Where = {
|
||||
field: 'tenantId',
|
||||
value: tenantId,
|
||||
connector: 'AND',
|
||||
}
|
||||
|
||||
const existingWhere = params.where ?? []
|
||||
|
||||
// Check if tenantId filter already exists
|
||||
const hasTenantFilter = existingWhere.some((clause) => clause.field === 'tenantId')
|
||||
|
||||
if (hasTenantFilter) {
|
||||
return params
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
where: [...existingWhere, tenantFilter],
|
||||
}
|
||||
}
|
||||
|
||||
async function injectTenantFilterForFindMany(
|
||||
params: FindManyParams,
|
||||
getTenantId: () => string | null | Promise<string | null>,
|
||||
): Promise<FindManyParams> {
|
||||
const modelsRequiringTenantFilter = ['user', 'account', 'session']
|
||||
|
||||
if (!modelsRequiringTenantFilter.includes(params.model)) {
|
||||
return params
|
||||
}
|
||||
|
||||
const tenantId = await getTenantId()
|
||||
if (!tenantId) {
|
||||
return params
|
||||
}
|
||||
|
||||
const tenantFilter: Where = {
|
||||
field: 'tenantId',
|
||||
value: tenantId,
|
||||
connector: 'AND',
|
||||
}
|
||||
|
||||
const existingWhere = params.where ?? []
|
||||
const hasTenantFilter = existingWhere.some((clause) => clause.field === 'tenantId')
|
||||
|
||||
if (hasTenantFilter) {
|
||||
return params
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
where: [...existingWhere, tenantFilter],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tenant ID resolver that reads from HttpContext.
|
||||
* Used when no custom resolver is provided.
|
||||
*/
|
||||
export function defaultTenantIdResolver(): string | null {
|
||||
try {
|
||||
const tenantContext = HttpContext.getValue('tenant') as { tenant?: { id?: string | null } } | undefined
|
||||
return tenantContext?.tenant?.id ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
|
||||
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
|
||||
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
|
||||
import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
|
||||
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -72,9 +72,9 @@ export class DataManagementService {
|
||||
})
|
||||
}
|
||||
|
||||
if (tenantSlug === ROOT_TENANT_SLUG || tenantSlug === PLACEHOLDER_TENANT_SLUG) {
|
||||
if (tenantSlug === ROOT_TENANT_SLUG || tenant.tenant.status === 'pending') {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '系统租户无法通过此操作删除。',
|
||||
message: '系统租户或未完成初始化的工作区无法通过此操作删除。',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
|
||||
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { logger } from 'core/helpers/logger.helper'
|
||||
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
|
||||
@@ -7,7 +7,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from './tenant.constants'
|
||||
import { ROOT_TENANT_SLUG } from './tenant.constants'
|
||||
import { TenantService } from './tenant.service'
|
||||
import type { TenantAggregate, TenantContext } from './tenant.types'
|
||||
import { TenantDomainService } from './tenant-domain.service'
|
||||
@@ -64,7 +64,7 @@ export class TenantContextResolver {
|
||||
if (host) {
|
||||
const domainMatch = await this.tenantDomainService.resolveTenantByDomain(host)
|
||||
if (domainMatch) {
|
||||
tenantContext = this.asTenantContext(domainMatch, false, domainMatch.tenant.slug)
|
||||
tenantContext = this.asTenantContext(domainMatch, domainMatch.tenant.slug)
|
||||
derivedSlug = domainMatch.tenant.slug
|
||||
this.log.verbose(
|
||||
`Resolved tenant by custom domain for request ${context.req.method} ${context.req.path} (host=${host})`,
|
||||
@@ -72,6 +72,14 @@ export class TenantContextResolver {
|
||||
}
|
||||
}
|
||||
|
||||
if (!derivedSlug) {
|
||||
// Allow resolving tenant from query param for OAuth callbacks (Gateway flow)
|
||||
const querySlug = context.req.query('tenantSlug')
|
||||
if (querySlug && context.req.path.startsWith('/api/auth/callback/')) {
|
||||
derivedSlug = querySlug
|
||||
}
|
||||
}
|
||||
|
||||
if (!derivedSlug) {
|
||||
derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
|
||||
}
|
||||
@@ -89,22 +97,23 @@ export class TenantContextResolver {
|
||||
{
|
||||
slug: derivedSlug,
|
||||
},
|
||||
true,
|
||||
{ noThrow: true, allowPending: true },
|
||||
)
|
||||
}
|
||||
|
||||
if (!tenantContext && this.shouldFallbackToPlaceholder(derivedSlug)) {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
tenantContext = this.asTenantContext(placeholder, true, requestedSlug)
|
||||
if (!tenantContext && this.shouldAutoProvisionTenant(derivedSlug, context.req.path)) {
|
||||
const pendingSlug = derivedSlug as string
|
||||
const pending = await this.tenantService.ensurePendingTenant(pendingSlug)
|
||||
tenantContext = this.asTenantContext(pending, requestedSlug)
|
||||
this.log.verbose(
|
||||
`Applied placeholder tenant context for ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'})`,
|
||||
`Provisioned pending tenant context for ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'})`,
|
||||
)
|
||||
} else if (tenantContext) {
|
||||
tenantContext = this.asTenantContext(
|
||||
tenantContext,
|
||||
tenantContext.tenant.slug === PLACEHOLDER_TENANT_SLUG,
|
||||
requestedSlug ?? tenantContext.tenant.slug ?? null,
|
||||
)
|
||||
tenantContext = {
|
||||
tenant: tenantContext.tenant,
|
||||
isPlaceholder: tenantContext.tenant.status !== 'active',
|
||||
requestedSlug: requestedSlug ?? tenantContext.tenant.slug ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
@@ -175,18 +184,33 @@ export class TenantContextResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldFallbackToPlaceholder(slug?: string | null): boolean {
|
||||
return !slug
|
||||
private shouldAutoProvisionTenant(slug: string | null | undefined, path: string): boolean {
|
||||
if (!slug || isTenantSlugReserved(slug)) {
|
||||
return false
|
||||
}
|
||||
const normalizedPath = path?.trim() || ''
|
||||
if (!normalizedPath) {
|
||||
return false
|
||||
}
|
||||
if (normalizedPath === '/auth' || normalizedPath === '/auth/') {
|
||||
return true
|
||||
}
|
||||
if (normalizedPath.startsWith('/auth/')) {
|
||||
return true
|
||||
}
|
||||
if (normalizedPath === '/api/auth' || normalizedPath === '/api/auth/') {
|
||||
return true
|
||||
}
|
||||
if (normalizedPath.startsWith('/api/auth/')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private asTenantContext(
|
||||
source: TenantAggregate,
|
||||
isPlaceholder: boolean,
|
||||
requestedSlug: string | null,
|
||||
): TenantContext {
|
||||
private asTenantContext(source: TenantAggregate, requestedSlug: string | null): TenantContext {
|
||||
return {
|
||||
tenant: source.tenant,
|
||||
isPlaceholder,
|
||||
isPlaceholder: source.tenant.status !== 'active',
|
||||
requestedSlug,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
|
||||
export const PENDING_TENANT_DEFAULT_NAME = 'Pending Workspace'
|
||||
export const ROOT_TENANT_NAME = 'System Control Room'
|
||||
export const ROOT_TENANT_SLUG = 'root'
|
||||
|
||||
export { PLACEHOLDER_TENANT_SLUG } from '@afilmory/utils'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
|
||||
export function getTenantContext<TRequired extends boolean = false>(options?: {
|
||||
@@ -22,9 +21,8 @@ export function isPlaceholderTenantContext(context?: TenantContext | null): bool
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
if (context.isPlaceholder) {
|
||||
return true
|
||||
if (typeof context.isPlaceholder === 'boolean') {
|
||||
return context.isPlaceholder
|
||||
}
|
||||
const slug = context.tenant.slug?.toLowerCase()
|
||||
return slug === PLACEHOLDER_TENANT_SLUG
|
||||
return context.tenant.status !== 'active'
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export class TenantRepository {
|
||||
slug: string
|
||||
planId?: BillingPlanId
|
||||
storagePlanId?: string | null
|
||||
status?: TenantAggregate['tenant']['status']
|
||||
}): Promise<TenantAggregate> {
|
||||
const db = this.dbAccessor.get()
|
||||
const tenantId = generateId()
|
||||
@@ -45,7 +46,7 @@ export class TenantRepository {
|
||||
slug: payload.slug,
|
||||
planId: payload.planId ?? 'free',
|
||||
storagePlanId: payload.storagePlanId ?? null,
|
||||
status: 'active',
|
||||
status: payload.status ?? 'active',
|
||||
}
|
||||
|
||||
await db.insert(tenants).values(tenantRecord)
|
||||
|
||||
@@ -4,14 +4,9 @@ import { normalizeString } from 'core/helpers/normalize.helper'
|
||||
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import {
|
||||
PLACEHOLDER_TENANT_NAME,
|
||||
PLACEHOLDER_TENANT_SLUG,
|
||||
ROOT_TENANT_NAME,
|
||||
ROOT_TENANT_SLUG,
|
||||
} from './tenant.constants'
|
||||
import { PENDING_TENANT_DEFAULT_NAME, ROOT_TENANT_NAME, ROOT_TENANT_SLUG } from './tenant.constants'
|
||||
import { TenantRepository } from './tenant.repository'
|
||||
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
|
||||
import type { TenantAggregate, TenantContext, TenantRecord, TenantResolutionInput } from './tenant.types'
|
||||
|
||||
@injectable()
|
||||
export class TenantService {
|
||||
@@ -22,6 +17,7 @@ export class TenantService {
|
||||
slug: string
|
||||
planId?: BillingPlanId
|
||||
storagePlanId?: string | null
|
||||
status?: TenantRecord['status']
|
||||
}): Promise<TenantAggregate> {
|
||||
const normalizedSlug = this.normalizeSlug(payload.slug)
|
||||
|
||||
@@ -41,22 +37,6 @@ export class TenantService {
|
||||
})
|
||||
}
|
||||
|
||||
async ensurePlaceholderTenant(): Promise<TenantAggregate> {
|
||||
const existing = await this.repository.findBySlug(PLACEHOLDER_TENANT_SLUG)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return await this.repository.createTenant({
|
||||
name: PLACEHOLDER_TENANT_NAME,
|
||||
slug: PLACEHOLDER_TENANT_SLUG,
|
||||
})
|
||||
}
|
||||
|
||||
getPlaceholderTenantSlug(): string {
|
||||
return PLACEHOLDER_TENANT_SLUG
|
||||
}
|
||||
|
||||
async ensureRootTenant(): Promise<TenantAggregate> {
|
||||
const existing = await this.repository.findBySlug(ROOT_TENANT_SLUG)
|
||||
if (existing) {
|
||||
@@ -69,17 +49,11 @@ export class TenantService {
|
||||
})
|
||||
}
|
||||
|
||||
async isPlaceholderTenantId(tenantId: string | null | undefined): Promise<boolean> {
|
||||
if (!tenantId) {
|
||||
return false
|
||||
}
|
||||
const placeholder = await this.ensurePlaceholderTenant()
|
||||
return placeholder.tenant.id === tenantId
|
||||
}
|
||||
|
||||
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
|
||||
async resolve(input: TenantResolutionInput): Promise<TenantContext>
|
||||
async resolve(input: TenantResolutionInput, noThrow = false): Promise<TenantContext | null> {
|
||||
async resolve(
|
||||
input: TenantResolutionInput,
|
||||
options?: { noThrow?: boolean; allowPending?: boolean },
|
||||
): Promise<TenantContext | null> {
|
||||
const { noThrow = false, allowPending = false } = options ?? {}
|
||||
const tenantId = normalizeString(input.tenantId)
|
||||
const slug = this.normalizeSlug(input.slug)
|
||||
|
||||
@@ -107,23 +81,23 @@ export class TenantService {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
|
||||
this.ensureTenantIsActive(aggregate.tenant)
|
||||
this.ensureTenantIsActive(aggregate.tenant, { allowPending })
|
||||
|
||||
return {
|
||||
tenant: aggregate.tenant,
|
||||
}
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<TenantAggregate> {
|
||||
async getById(id: string, options?: { allowPending?: boolean }): Promise<TenantAggregate> {
|
||||
const aggregate = await this.repository.findById(id)
|
||||
if (!aggregate) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
this.ensureTenantIsActive(aggregate.tenant)
|
||||
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
|
||||
return aggregate
|
||||
}
|
||||
|
||||
async getBySlug(slug: string): Promise<TenantAggregate> {
|
||||
async getBySlug(slug: string, options?: { allowPending?: boolean }): Promise<TenantAggregate> {
|
||||
const normalized = this.normalizeSlug(slug)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
@@ -133,7 +107,7 @@ export class TenantService {
|
||||
if (!aggregate) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
this.ensureTenantIsActive(aggregate.tenant)
|
||||
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
|
||||
return aggregate
|
||||
}
|
||||
|
||||
@@ -159,7 +133,8 @@ export class TenantService {
|
||||
return existing === null
|
||||
}
|
||||
|
||||
ensureTenantIsActive(tenant: TenantAggregate['tenant']): void {
|
||||
ensureTenantIsActive(tenant: TenantAggregate['tenant'], options?: { allowPending?: boolean }): void {
|
||||
const allowPending = options?.allowPending ?? false
|
||||
if (tenant.banned) {
|
||||
throw new BizException(ErrorCode.TENANT_BANNED)
|
||||
}
|
||||
@@ -168,11 +143,44 @@ export class TenantService {
|
||||
throw new BizException(ErrorCode.TENANT_SUSPENDED)
|
||||
}
|
||||
|
||||
if (tenant.status === 'pending' && allowPending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (tenant.status !== 'active') {
|
||||
throw new BizException(ErrorCode.TENANT_INACTIVE)
|
||||
}
|
||||
}
|
||||
|
||||
async ensurePendingTenant(slug: string): Promise<TenantAggregate> {
|
||||
const normalized = this.normalizeSlug(slug)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND, { message: 'Tenant slug is required' })
|
||||
}
|
||||
|
||||
const existing = await this.repository.findBySlug(normalized)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return await this.createTenant({
|
||||
name: PENDING_TENANT_DEFAULT_NAME,
|
||||
slug: normalized,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
|
||||
async isPendingTenantId(tenantId: string | null | undefined): Promise<boolean> {
|
||||
if (!tenantId) {
|
||||
return false
|
||||
}
|
||||
const aggregate = await this.repository.findById(tenantId)
|
||||
if (!aggregate) {
|
||||
return false
|
||||
}
|
||||
return aggregate.tenant.status === 'pending'
|
||||
}
|
||||
|
||||
private normalizeSlug(value?: string | null): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
return normalized ? normalized.toLowerCase() : null
|
||||
|
||||
@@ -17,6 +17,7 @@ const TENANT_MISSING_ERROR_CODES = new Set([AUTH_TENANT_NOT_FOUND_ERROR_CODE, TE
|
||||
const {
|
||||
LOGIN: DEFAULT_LOGIN_PATH,
|
||||
ROOT_LOGIN: ROOT_LOGIN_PATH,
|
||||
WELCOME: WELCOME_PATH,
|
||||
TENANT_MISSING: TENANT_MISSING_PATH,
|
||||
DEFAULT_AUTHENTICATED: DEFAULT_AUTHENTICATED_PATH,
|
||||
SUPERADMIN_ROOT: SUPERADMIN_ROOT_PATH,
|
||||
@@ -126,6 +127,9 @@ export function usePageRedirect() {
|
||||
const isSuperAdmin = session?.user.role === 'superadmin'
|
||||
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
|
||||
const isOnRootLoginPage = pathname === ROOT_LOGIN_PATH
|
||||
const tenant = session?.tenant ?? null
|
||||
const isTenantPending = Boolean(session && tenant?.isPlaceholder)
|
||||
const isOnWelcomePage = pathname === WELCOME_PATH
|
||||
|
||||
if (session && isSuperAdmin) {
|
||||
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH || isOnRootLoginPage) {
|
||||
@@ -134,6 +138,18 @@ export function usePageRedirect() {
|
||||
return
|
||||
}
|
||||
|
||||
if (session && isTenantPending) {
|
||||
if (!isOnWelcomePage) {
|
||||
navigate(WELCOME_PATH, { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (session && !isTenantPending && isOnWelcomePage) {
|
||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (session && !isSuperAdmin && isOnSuperAdminPage) {
|
||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||
return
|
||||
@@ -145,7 +161,8 @@ export function usePageRedirect() {
|
||||
}
|
||||
|
||||
if (session && (pathname === DEFAULT_LOGIN_PATH || pathname === ROOT_LOGIN_PATH)) {
|
||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||
const destination = isTenantPending ? WELCOME_PATH : DEFAULT_AUTHENTICATED_PATH
|
||||
navigate(destination, { replace: true })
|
||||
}
|
||||
}, [location, location.pathname, navigate, sessionQuery.data, sessionQuery.isError, sessionQuery.isPending])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { useSetAuthUser } from '~/atoms/auth'
|
||||
import { ROUTE_PATHS } from '~/constants/routes'
|
||||
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
||||
import { buildRootTenantUrl, buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
||||
|
||||
@@ -55,7 +56,12 @@ export function useLogin() {
|
||||
const { tenant } = session
|
||||
const isSuperAdmin = session.user.role === 'superadmin'
|
||||
|
||||
if (tenant && !tenant.isPlaceholder && tenant.slug) {
|
||||
if (tenant?.isPlaceholder) {
|
||||
navigate(ROUTE_PATHS.WELCOME, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (tenant && tenant.slug) {
|
||||
const currentSlug = getTenantSlugFromHost(window.location.hostname)
|
||||
if (!isSuperAdmin && tenant.slug !== currentSlug) {
|
||||
try {
|
||||
|
||||
11
be/packages/db/migrations/0011_omniscient_wraith.sql
Normal file
11
be/packages/db/migrations/0011_omniscient_wraith.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "auth_user" DROP CONSTRAINT "auth_user_email_unique";--> statement-breakpoint
|
||||
ALTER TABLE "photo_asset" ALTER COLUMN "manifest_version" SET DEFAULT 'v10';--> statement-breakpoint
|
||||
ALTER TABLE "auth_account" ADD COLUMN "tenant_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "auth_account" ADD CONSTRAINT "auth_account_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_auth_account_user" ON "auth_account" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_auth_account_tenant" ON "auth_account" USING btree ("tenant_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_auth_account_provider" ON "auth_account" USING btree ("provider_id","account_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_auth_user_email" ON "auth_user" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "idx_auth_user_tenant" ON "auth_user" USING btree ("tenant_id");--> statement-breakpoint
|
||||
ALTER TABLE "auth_account" ADD CONSTRAINT "uq_auth_account_tenant_provider" UNIQUE("tenant_id","provider_id","account_id");--> statement-breakpoint
|
||||
ALTER TABLE "auth_user" ADD CONSTRAINT "uq_auth_user_tenant_email" UNIQUE("tenant_id","email");
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE "auth_account"
|
||||
SET "tenant_id" = "auth_user"."tenant_id"
|
||||
FROM "auth_user"
|
||||
WHERE "auth_account"."user_id" = "auth_user"."id"
|
||||
AND "auth_account"."tenant_id" IS NULL
|
||||
AND "auth_user"."tenant_id" IS NOT NULL;--> statement-breakpoint
|
||||
|
||||
1
be/packages/db/migrations/0013_blushing_crystal.sql
Normal file
1
be/packages/db/migrations/0013_blushing_crystal.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."tenant_status" ADD VALUE 'pending' BEFORE 'active';
|
||||
2191
be/packages/db/migrations/meta/0011_snapshot.json
Normal file
2191
be/packages/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2191
be/packages/db/migrations/meta/0013_snapshot.json
Normal file
2191
be/packages/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,27 @@
|
||||
"when": 1764154685207,
|
||||
"tag": "0010_wise_doorman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1764226846630,
|
||||
"tag": "0011_omniscient_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1764300000000,
|
||||
"tag": "0012_populate_auth_account_tenant_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1764394870840,
|
||||
"tag": "0013_blushing_crystal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const snowflakeId = createSnowflakeId('id').primaryKey()
|
||||
|
||||
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'superadmin'])
|
||||
|
||||
export const tenantStatusEnum = pgEnum('tenant_status', ['active', 'inactive', 'suspended'])
|
||||
export const tenantStatusEnum = pgEnum('tenant_status', ['pending', 'active', 'inactive', 'suspended'])
|
||||
export const tenantDomainStatusEnum = pgEnum('tenant_domain_status', ['pending', 'verified', 'disabled'])
|
||||
export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict'])
|
||||
export const commentStatusEnum = pgEnum('comment_status', ['pending', 'approved', 'rejected', 'hidden'])
|
||||
@@ -107,24 +107,34 @@ export const tenantDomains = pgTable(
|
||||
)
|
||||
|
||||
// Custom users table (Better Auth: user)
|
||||
export const authUsers = pgTable('auth_user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
image: text('image'),
|
||||
creemCustomerId: text('creem_customer_id'),
|
||||
role: userRoleEnum('role').notNull().default('user'),
|
||||
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
|
||||
username: text('username'),
|
||||
displayUsername: text('display_username'),
|
||||
banned: boolean('banned').default(false).notNull(),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
|
||||
})
|
||||
// Note: Multi-tenant design - same email can exist in different tenants
|
||||
export const authUsers = pgTable(
|
||||
'auth_user',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
image: text('image'),
|
||||
creemCustomerId: text('creem_customer_id'),
|
||||
role: userRoleEnum('role').notNull().default('user'),
|
||||
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
|
||||
username: text('username'),
|
||||
displayUsername: text('display_username'),
|
||||
banned: boolean('banned').default(false).notNull(),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
|
||||
},
|
||||
(t) => [
|
||||
// Multi-tenant: same email can exist in different tenants
|
||||
unique('uq_auth_user_tenant_email').on(t.tenantId, t.email),
|
||||
index('idx_auth_user_email').on(t.email),
|
||||
index('idx_auth_user_tenant').on(t.tenantId),
|
||||
],
|
||||
)
|
||||
|
||||
// Custom sessions table (Better Auth: session)
|
||||
export const authSessions = pgTable('auth_session', {
|
||||
@@ -142,23 +152,35 @@ export const authSessions = pgTable('auth_session', {
|
||||
})
|
||||
|
||||
// Custom accounts table (Better Auth: account)
|
||||
export const authAccounts = pgTable('auth_account', {
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
})
|
||||
// Note: Multi-tenant design - same social account can exist in different tenants
|
||||
export const authAccounts = pgTable(
|
||||
'auth_account',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: 'cascade' }),
|
||||
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// Multi-tenant: same social account can exist in different tenants
|
||||
unique('uq_auth_account_tenant_provider').on(t.tenantId, t.providerId, t.accountId),
|
||||
index('idx_auth_account_user').on(t.userId),
|
||||
index('idx_auth_account_tenant').on(t.tenantId),
|
||||
index('idx_auth_account_provider').on(t.providerId, t.accountId),
|
||||
],
|
||||
)
|
||||
|
||||
export const authVerifications = pgTable('auth_verification', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
@@ -9,8 +9,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
|
||||
- Calls `AuthProvider.getAuth()` so downstream handlers reuse the tenant-aware Better Auth instance.
|
||||
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
|
||||
- Extracts a slug via `tenant-host.utils.ts`.
|
||||
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
|
||||
- Always stores the original `requestedSlug` (even when placeholder) so downstream services know which workspace was requested.
|
||||
- Loads the tenant aggregate; when a subdomain hits `/auth` or `/api/auth` for the first time, it auto-provisions a real tenant record with `status = "pending"` so auth flows have a fully-qualified tenant id.
|
||||
- Always stores the original `requestedSlug` so downstream services know which workspace was requested.
|
||||
|
||||
## Auth Provider
|
||||
|
||||
@@ -48,7 +48,7 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
|
||||
}
|
||||
```
|
||||
|
||||
- When the resolver falls back to the placeholder tenant, `tenant.slug` still holds the requested subdomain, and `isPlaceholder` is `true`.
|
||||
- When a tenant is still provisioning (`status = "pending"`), `tenant.slug` still holds the requested subdomain, `isPlaceholder` is `true`, and the dashboard stays on the onboarding surface.
|
||||
- Consumers simply check `tenant.isPlaceholder` to know whether they are in onboarding.
|
||||
|
||||
## Dashboard Behavior
|
||||
@@ -68,8 +68,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
|
||||
3. User clicks “Sign in with GitHub” → `/auth/social` uses `requestedSlug` and redirects via the OAuth gateway.
|
||||
4. Gateway forwards the callback to `https://slug.example.com/api/auth/callback/github`.
|
||||
5. Resolver again sets `requestedSlug = "slug"`; Better Auth instance cache hits, so `state` matches.
|
||||
6. `/auth/session` returns `{ tenant: { slug: "slug", isPlaceholder: true } }` → dashboard stays on welcome, no cross-subdomain jump.
|
||||
7. Once the tenant is provisioned, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
|
||||
6. `/auth/session` returns `{ tenant: { slug: "slug", isPlaceholder: true } }` while the workspace is pending → dashboard stays on welcome, no cross-subdomain jump.
|
||||
7. Once the onboarding API marks the tenant `active`, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
|
||||
|
||||
## Key Guarantees
|
||||
|
||||
|
||||
@@ -52,5 +52,3 @@ export function isTenantSlugReserved(slug: string): boolean {
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
|
||||
|
||||
export const PLACEHOLDER_TENANT_SLUG = 'holding'
|
||||
|
||||
Reference in New Issue
Block a user