mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +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 { BizException, ErrorCode } from 'core/errors'
|
||||||
import type { AuthSession } from 'core/modules/platform/auth/auth.provider'
|
import type { AuthSession } from 'core/modules/platform/auth/auth.provider'
|
||||||
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
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 type { TenantContext } from 'core/modules/platform/tenant/tenant.types'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { injectable } from 'tsyringe'
|
import { injectable } from 'tsyringe'
|
||||||
@@ -18,10 +17,7 @@ import { logger } from '../helpers/logger.helper'
|
|||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private readonly log = logger.extend('AuthGuard')
|
private readonly log = logger.extend('AuthGuard')
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||||
private readonly dbAccessor: DbAccessor,
|
|
||||||
private readonly tenantService: TenantService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const store = context.getContext()
|
const store = context.getContext()
|
||||||
@@ -51,11 +47,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async requireTenantContext(method: string, path: string): Promise<TenantContext> {
|
private async requireTenantContext(method: string, path: string): Promise<TenantContext> {
|
||||||
let tenantContext = getTenantContext()
|
const tenantContext = getTenantContext()
|
||||||
if (!tenantContext && this.isPlaceholderAllowedPath(path)) {
|
|
||||||
tenantContext = (await this.createPlaceholderContext(method, path)) as TenantContext
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tenantContext) {
|
if (!tenantContext) {
|
||||||
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
|
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
|
||||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||||
@@ -151,20 +143,4 @@ export class AuthGuard implements CanActivate {
|
|||||||
|
|
||||||
return false
|
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 { SettingKeys } from './setting.constant'
|
||||||
import type { GetSettingsBodyDto } from './setting.dto'
|
import type { GetSettingsBodyDto } from './setting.dto'
|
||||||
import { DeleteSettingDto, GetSettingDto, SetSettingDto } from './setting.dto'
|
import { DeleteSettingDto, GetSettingDto, SetSettingDto } from './setting.dto'
|
||||||
|
import type { SettingEntryInput } from './setting.service'
|
||||||
import { SettingService } from './setting.service'
|
import { SettingService } from './setting.service'
|
||||||
|
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
@@ -48,7 +49,7 @@ export class SettingController {
|
|||||||
|
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async set(@Body() { entries }: SetSettingDto) {
|
async set(@Body() { entries }: SetSettingDto) {
|
||||||
await this.settingService.setMany(entries)
|
await this.settingService.setMany(entries as SettingEntryInput[])
|
||||||
return { updated: entries }
|
return { updated: entries }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createZodDto } from '@afilmory/framework'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { SETTING_SCHEMAS, SettingKeys } from './setting.constant'
|
import { SETTING_SCHEMAS, SettingKeys } from './setting.constant'
|
||||||
|
import type { SettingEntryInput } from './setting.service'
|
||||||
|
|
||||||
const keySchema = z.enum(SettingKeys)
|
const keySchema = z.enum(SettingKeys)
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ const normalizeEntries = z
|
|||||||
return entries.map((entry) => ({
|
return entries.map((entry) => ({
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
||||||
}))
|
})) as SettingEntryInput[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const keysInputSchema = z
|
const keysInputSchema = z
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { DbAccessor } from 'core/database/database.provider'
|
|||||||
import { eq, inArray } from 'drizzle-orm'
|
import { eq, inArray } from 'drizzle-orm'
|
||||||
import { injectable } from 'tsyringe'
|
import { injectable } from 'tsyringe'
|
||||||
|
|
||||||
|
import type { SystemSettingKey as SystemSettingLiteralKey } from './system-setting.constants'
|
||||||
import type {
|
import type {
|
||||||
SystemSettingEntryInput,
|
SystemSettingEntryInput,
|
||||||
SystemSettingKey,
|
SystemSettingKey as SystemSettingStoreKey,
|
||||||
SystemSettingRecord,
|
SystemSettingRecord,
|
||||||
SystemSettingSetOptions,
|
SystemSettingSetOptions,
|
||||||
} from './system-setting.store.types'
|
} from './system-setting.store.types'
|
||||||
@@ -18,27 +19,29 @@ export class SystemSettingStore {
|
|||||||
private readonly eventService: EventEmitterService,
|
private readonly eventService: EventEmitterService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
|
async get(key: SystemSettingStoreKey): Promise<SystemSettingRecord['value']> {
|
||||||
const record = await this.find(key)
|
const record = await this.find(key)
|
||||||
return record?.value ?? null
|
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) {
|
if (keys.length === 0) {
|
||||||
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
|
return {} as Record<SystemSettingStoreKey, SystemSettingRecord['value']>
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueKeys = Array.from(new Set(keys))
|
const uniqueKeys = Array.from(new Set(keys))
|
||||||
const db = this.dbAccessor.get()
|
const db = this.dbAccessor.get()
|
||||||
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
|
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(
|
return uniqueKeys.reduce(
|
||||||
(acc, key) => {
|
(acc, key) => {
|
||||||
acc[key] = map.get(key)?.value ?? null
|
acc[key] = map.get(key)?.value ?? null
|
||||||
return acc
|
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(
|
async set(
|
||||||
key: SystemSettingKey,
|
key: SystemSettingStoreKey,
|
||||||
value: SystemSettingRecord['value'],
|
value: SystemSettingRecord['value'],
|
||||||
options: SystemSettingSetOptions = {},
|
options: SystemSettingSetOptions = {},
|
||||||
): Promise<void> {
|
): 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> {
|
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()
|
const db = this.dbAccessor.get()
|
||||||
await db.delete(systemSettings).where(eq(systemSettings.key, key))
|
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)
|
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 { HttpContext } from '@afilmory/framework'
|
||||||
import { DbAccessor } from 'core/database/database.provider'
|
import { DbAccessor } from 'core/database/database.provider'
|
||||||
import { BizException, ErrorCode } from 'core/errors'
|
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 { SettingService } from 'core/modules/configuration/setting/setting.service'
|
||||||
import type { SettingKeyType } from 'core/modules/configuration/setting/setting.type'
|
import type { SettingKeyType } from 'core/modules/configuration/setting/setting.type'
|
||||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
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 { injectable } from 'tsyringe'
|
||||||
|
|
||||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||||
@@ -65,7 +65,8 @@ export class AuthRegistrationService {
|
|||||||
await this.systemSettings.ensureRegistrationAllowed()
|
await this.systemSettings.ensureRegistrationAllowed()
|
||||||
|
|
||||||
const tenantContext = getTenantContext()
|
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 account = input.account ? this.normalizeAccountInput(input.account) : null
|
||||||
const useSessionAccount = input.useSessionAccount ?? false
|
const useSessionAccount = input.useSessionAccount ?? false
|
||||||
const sessionUser = this.getSessionUser()
|
const sessionUser = this.getSessionUser()
|
||||||
@@ -74,6 +75,16 @@ export class AuthRegistrationService {
|
|||||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '请先登录后再创建工作区' })
|
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 (effectiveTenantContext) {
|
||||||
if (useSessionAccount) {
|
if (useSessionAccount) {
|
||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户上下文下不支持会话注册' })
|
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)
|
const value = schema.parse(entry.value)
|
||||||
|
|
||||||
normalized.push({
|
normalized.push({
|
||||||
key: key as SettingKeyType,
|
key: typedKey,
|
||||||
value,
|
value,
|
||||||
})
|
} as SettingEntryInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
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(
|
private async registerNewTenant(
|
||||||
account: RegisterTenantAccountInput | null,
|
account: RegisterTenantAccountInput | null,
|
||||||
tenantInput: RegisterTenantInput['tenant'],
|
tenantInput: RegisterTenantInput['tenant'],
|
||||||
@@ -347,8 +459,8 @@ export class AuthRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (record.tenantId) {
|
if (record.tenantId) {
|
||||||
const isPlaceholder = await this.tenantService.isPlaceholderTenantId(record.tenantId)
|
const isPending = await this.tenantService.isPendingTenantId(record.tenantId)
|
||||||
if (!isPlaceholder) {
|
if (!isPending) {
|
||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前账号已属于其它工作区,无法重复注册。' })
|
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 { eq } from 'drizzle-orm'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
|
||||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||||
import { TenantService } from '../tenant/tenant.service'
|
import { TenantService } from '../tenant/tenant.service'
|
||||||
import type { TenantRecord } from '../tenant/tenant.types'
|
import type { TenantRecord } from '../tenant/tenant.types'
|
||||||
@@ -127,10 +126,10 @@ export class AuthController {
|
|||||||
const { tenantId } = authContext.user as { tenantId?: string | null }
|
const { tenantId } = authContext.user as { tenantId?: string | null }
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
try {
|
try {
|
||||||
const aggregate = await this.tenantService.getById(tenantId)
|
const aggregate = await this.tenantService.getById(tenantId, { allowPending: true })
|
||||||
const isPlaceholder = aggregate.tenant.slug === PLACEHOLDER_TENANT_SLUG
|
const isPlaceholder = aggregate.tenant.status !== 'active'
|
||||||
const existingRequestedSlug = tenantContext?.requestedSlug ?? null
|
const existingRequestedSlug = tenantContext?.requestedSlug ?? null
|
||||||
const derivedRequestedSlug = existingRequestedSlug ?? (isPlaceholder ? null : (aggregate.tenant.slug ?? null))
|
const derivedRequestedSlug = existingRequestedSlug ?? aggregate.tenant.slug ?? null
|
||||||
tenantContext = {
|
tenantContext = {
|
||||||
tenant: aggregate.tenant,
|
tenant: aggregate.tenant,
|
||||||
isPlaceholder,
|
isPlaceholder,
|
||||||
@@ -308,12 +307,17 @@ export class AuthController {
|
|||||||
const { headers } = context.req.raw
|
const { headers } = context.req.raw
|
||||||
const tenantContext = getTenantContext()
|
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 auth = await this.auth.getAuth()
|
||||||
const response = await auth.api.signInSocial({
|
const response = await auth.api.signInSocial({
|
||||||
body: {
|
body: {
|
||||||
...body,
|
...body,
|
||||||
provider,
|
provider,
|
||||||
requestSignUp: body.requestSignUp ?? Boolean(tenantContext),
|
requestSignUp: shouldAllowSignUp,
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
asResponse: true,
|
asResponse: true,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { createLogger, HttpContext } from '@afilmory/framework'
|
|||||||
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
|
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
|
||||||
import { creem } from '@creem_io/better-auth'
|
import { creem } from '@creem_io/better-auth'
|
||||||
import { betterAuth } from 'better-auth'
|
import { betterAuth } from 'better-auth'
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
||||||
import { APIError, createAuthMiddleware } from 'better-auth/api'
|
import { APIError, createAuthMiddleware } from 'better-auth/api'
|
||||||
import { admin } from 'better-auth/plugins'
|
import { admin } from 'better-auth/plugins'
|
||||||
import { DrizzleProvider } from 'core/database/database.provider'
|
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 type { Context } from 'hono'
|
||||||
import { injectable } from 'tsyringe'
|
import { injectable } from 'tsyringe'
|
||||||
|
|
||||||
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
|
||||||
import { TenantService } from '../tenant/tenant.service'
|
import { TenantService } from '../tenant/tenant.service'
|
||||||
import { extractTenantSlugFromHost } from '../tenant/tenant-host.utils'
|
import { extractTenantSlugFromHost } from '../tenant/tenant-host.utils'
|
||||||
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
|
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
|
||||||
import { AuthConfig } from './auth.config'
|
import { AuthConfig } from './auth.config'
|
||||||
|
import { tenantAwareDrizzleAdapter } from './tenant-aware-adapter'
|
||||||
|
|
||||||
export type BetterAuthInstance = ReturnType<typeof betterAuth>
|
export type BetterAuthInstance = ReturnType<typeof betterAuth>
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ const logger = createLogger('Auth')
|
|||||||
@injectable()
|
@injectable()
|
||||||
export class AuthProvider implements OnModuleInit {
|
export class AuthProvider implements OnModuleInit {
|
||||||
private instances = new Map<string, Promise<BetterAuthInstance>>()
|
private instances = new Map<string, Promise<BetterAuthInstance>>()
|
||||||
private placeholderTenantId: string | null = null
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: AuthConfig,
|
private readonly config: AuthConfig,
|
||||||
@@ -83,16 +81,20 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
return sanitizedSlug ? `better-auth-${sanitizedSlug}` : 'better-auth'
|
return sanitizedSlug ? `better-auth-${sanitizedSlug}` : 'better-auth'
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveFallbackTenantId(): Promise<string | null> {
|
private async resolveTenantIdOrProvision(tenantSlug: string | null): Promise<string | null> {
|
||||||
if (this.placeholderTenantId) {
|
const tenantIdFromContext = this.resolveTenantIdFromContext()
|
||||||
return this.placeholderTenantId
|
if (tenantIdFromContext) {
|
||||||
|
return tenantIdFromContext
|
||||||
}
|
}
|
||||||
|
if (!tenantSlug) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
const aggregate = await this.tenantService.ensurePendingTenant(tenantSlug)
|
||||||
this.placeholderTenantId = placeholder.tenant.id
|
return aggregate.tenant.id
|
||||||
return this.placeholderTenantId
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to ensure placeholder tenant', error)
|
logger.error(`Failed to provision tenant for slug=${tenantSlug}`, error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,23 +223,42 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
)
|
)
|
||||||
const cookiePrefix = this.buildCookiePrefix(tenantSlug)
|
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({
|
return betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: tenantAwareDrizzleAdapter(
|
||||||
provider: 'pg',
|
db,
|
||||||
schema: {
|
{
|
||||||
user: authUsers,
|
provider: 'pg',
|
||||||
session: authSessions,
|
schema: {
|
||||||
account: authAccounts,
|
user: authUsers,
|
||||||
verification: authVerifications,
|
session: authSessions,
|
||||||
subscription: creemSubscriptions,
|
account: authAccounts,
|
||||||
|
verification: authVerifications,
|
||||||
|
subscription: creemSubscriptions,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
ensureTenantId,
|
||||||
|
),
|
||||||
socialProviders: socialProviders as any,
|
socialProviders: socialProviders as any,
|
||||||
emailAndPassword: { enabled: true },
|
emailAndPassword: { enabled: true },
|
||||||
trustedOrigins: await this.buildTrustedOrigins(),
|
trustedOrigins: await this.buildTrustedOrigins(),
|
||||||
session: {
|
session: {
|
||||||
freshAge: 0,
|
freshAge: 0,
|
||||||
|
additionalFields: {
|
||||||
|
tenantId: { type: 'string', input: false },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
account: {
|
||||||
|
additionalFields: {
|
||||||
|
tenantId: { type: 'string', input: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
tenantId: { type: 'string', input: false },
|
tenantId: { type: 'string', input: false },
|
||||||
@@ -249,27 +270,18 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
before: async (user) => {
|
before: async (user) => {
|
||||||
const tenantId = this.resolveTenantIdFromContext()
|
const tenantId = await ensureTenantId()
|
||||||
if (tenantId) {
|
if (!tenantId) {
|
||||||
return {
|
throw new APIError('BAD_REQUEST', {
|
||||||
data: {
|
message: 'Missing tenant context during account creation.',
|
||||||
...user,
|
})
|
||||||
tenantId,
|
|
||||||
role: user.role ?? 'guest',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackTenantId = await this.resolveFallbackTenantId()
|
|
||||||
if (!fallbackTenantId) {
|
|
||||||
return { data: user }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
...user,
|
...user,
|
||||||
tenantId: fallbackTenantId,
|
tenantId,
|
||||||
role: user.role ?? 'guest',
|
role: user.role ?? 'user',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -279,7 +291,7 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
create: {
|
create: {
|
||||||
before: async (session) => {
|
before: async (session) => {
|
||||||
const tenantId = this.resolveTenantIdFromContext()
|
const tenantId = this.resolveTenantIdFromContext()
|
||||||
const fallbackTenantId = tenantId ?? session.tenantId ?? (await this.resolveFallbackTenantId())
|
const fallbackTenantId = tenantId ?? session.tenantId ?? (await ensureTenantId())
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
...session,
|
...session,
|
||||||
@@ -293,7 +305,7 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
create: {
|
create: {
|
||||||
before: async (account) => {
|
before: async (account) => {
|
||||||
const tenantId = this.resolveTenantIdFromContext()
|
const tenantId = this.resolveTenantIdFromContext()
|
||||||
const resolvedTenantId = tenantId ?? (await this.resolveFallbackTenantId())
|
const resolvedTenantId = tenantId ?? (await ensureTenantId())
|
||||||
if (!resolvedTenantId) {
|
if (!resolvedTenantId) {
|
||||||
return { data: account }
|
return { data: account }
|
||||||
}
|
}
|
||||||
@@ -375,10 +387,7 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
const fallbackHost = options.baseDomain.trim().toLowerCase()
|
const fallbackHost = options.baseDomain.trim().toLowerCase()
|
||||||
const requestedHost = (endpoint.host ?? fallbackHost).trim().toLowerCase()
|
const requestedHost = (endpoint.host ?? fallbackHost).trim().toLowerCase()
|
||||||
const tenantSlugFromContext = this.resolveTenantSlugFromContext()
|
const tenantSlugFromContext = this.resolveTenantSlugFromContext()
|
||||||
const tenantSlug =
|
const tenantSlug = tenantSlugFromContext ?? extractTenantSlugFromHost(requestedHost, options.baseDomain)
|
||||||
tenantSlugFromContext && tenantSlugFromContext !== PLACEHOLDER_TENANT_SLUG
|
|
||||||
? tenantSlugFromContext
|
|
||||||
: (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext)
|
|
||||||
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
|
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
|
||||||
const protocol = this.determineProtocol(host, endpoint.protocol)
|
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 { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
|
||||||
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
|
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
|
||||||
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
|
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 { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { injectable } from 'tsyringe'
|
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, {
|
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||||
message: '系统租户无法通过此操作删除。',
|
message: '系统租户或未完成初始化的工作区无法通过此操作删除。',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpContext } from '@afilmory/framework'
|
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 { BizException, ErrorCode } from 'core/errors'
|
||||||
import { logger } from 'core/helpers/logger.helper'
|
import { logger } from 'core/helpers/logger.helper'
|
||||||
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
|
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 type { Context } from 'hono'
|
||||||
import { injectable } from 'tsyringe'
|
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 { TenantService } from './tenant.service'
|
||||||
import type { TenantAggregate, TenantContext } from './tenant.types'
|
import type { TenantAggregate, TenantContext } from './tenant.types'
|
||||||
import { TenantDomainService } from './tenant-domain.service'
|
import { TenantDomainService } from './tenant-domain.service'
|
||||||
@@ -64,7 +64,7 @@ export class TenantContextResolver {
|
|||||||
if (host) {
|
if (host) {
|
||||||
const domainMatch = await this.tenantDomainService.resolveTenantByDomain(host)
|
const domainMatch = await this.tenantDomainService.resolveTenantByDomain(host)
|
||||||
if (domainMatch) {
|
if (domainMatch) {
|
||||||
tenantContext = this.asTenantContext(domainMatch, false, domainMatch.tenant.slug)
|
tenantContext = this.asTenantContext(domainMatch, domainMatch.tenant.slug)
|
||||||
derivedSlug = domainMatch.tenant.slug
|
derivedSlug = domainMatch.tenant.slug
|
||||||
this.log.verbose(
|
this.log.verbose(
|
||||||
`Resolved tenant by custom domain for request ${context.req.method} ${context.req.path} (host=${host})`,
|
`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) {
|
if (!derivedSlug) {
|
||||||
derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
|
derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
|
||||||
}
|
}
|
||||||
@@ -89,22 +97,23 @@ export class TenantContextResolver {
|
|||||||
{
|
{
|
||||||
slug: derivedSlug,
|
slug: derivedSlug,
|
||||||
},
|
},
|
||||||
true,
|
{ noThrow: true, allowPending: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tenantContext && this.shouldFallbackToPlaceholder(derivedSlug)) {
|
if (!tenantContext && this.shouldAutoProvisionTenant(derivedSlug, context.req.path)) {
|
||||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
const pendingSlug = derivedSlug as string
|
||||||
tenantContext = this.asTenantContext(placeholder, true, requestedSlug)
|
const pending = await this.tenantService.ensurePendingTenant(pendingSlug)
|
||||||
|
tenantContext = this.asTenantContext(pending, requestedSlug)
|
||||||
this.log.verbose(
|
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) {
|
} else if (tenantContext) {
|
||||||
tenantContext = this.asTenantContext(
|
tenantContext = {
|
||||||
tenantContext,
|
tenant: tenantContext.tenant,
|
||||||
tenantContext.tenant.slug === PLACEHOLDER_TENANT_SLUG,
|
isPlaceholder: tenantContext.tenant.status !== 'active',
|
||||||
requestedSlug ?? tenantContext.tenant.slug ?? null,
|
requestedSlug: requestedSlug ?? tenantContext.tenant.slug ?? null,
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tenantContext) {
|
if (!tenantContext) {
|
||||||
@@ -175,18 +184,33 @@ export class TenantContextResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldFallbackToPlaceholder(slug?: string | null): boolean {
|
private shouldAutoProvisionTenant(slug: string | null | undefined, path: string): boolean {
|
||||||
return !slug
|
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(
|
private asTenantContext(source: TenantAggregate, requestedSlug: string | null): TenantContext {
|
||||||
source: TenantAggregate,
|
|
||||||
isPlaceholder: boolean,
|
|
||||||
requestedSlug: string | null,
|
|
||||||
): TenantContext {
|
|
||||||
return {
|
return {
|
||||||
tenant: source.tenant,
|
tenant: source.tenant,
|
||||||
isPlaceholder,
|
isPlaceholder: source.tenant.status !== 'active',
|
||||||
requestedSlug,
|
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_NAME = 'System Control Room'
|
||||||
export const ROOT_TENANT_SLUG = 'root'
|
export const ROOT_TENANT_SLUG = 'root'
|
||||||
|
|
||||||
export { PLACEHOLDER_TENANT_SLUG } from '@afilmory/utils'
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { HttpContext } from '@afilmory/framework'
|
import { HttpContext } from '@afilmory/framework'
|
||||||
import { BizException, ErrorCode } from 'core/errors'
|
import { BizException, ErrorCode } from 'core/errors'
|
||||||
|
|
||||||
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
|
|
||||||
import type { TenantContext } from './tenant.types'
|
import type { TenantContext } from './tenant.types'
|
||||||
|
|
||||||
export function getTenantContext<TRequired extends boolean = false>(options?: {
|
export function getTenantContext<TRequired extends boolean = false>(options?: {
|
||||||
@@ -22,9 +21,8 @@ export function isPlaceholderTenantContext(context?: TenantContext | null): bool
|
|||||||
if (!context) {
|
if (!context) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (context.isPlaceholder) {
|
if (typeof context.isPlaceholder === 'boolean') {
|
||||||
return true
|
return context.isPlaceholder
|
||||||
}
|
}
|
||||||
const slug = context.tenant.slug?.toLowerCase()
|
return context.tenant.status !== 'active'
|
||||||
return slug === PLACEHOLDER_TENANT_SLUG
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class TenantRepository {
|
|||||||
slug: string
|
slug: string
|
||||||
planId?: BillingPlanId
|
planId?: BillingPlanId
|
||||||
storagePlanId?: string | null
|
storagePlanId?: string | null
|
||||||
|
status?: TenantAggregate['tenant']['status']
|
||||||
}): Promise<TenantAggregate> {
|
}): Promise<TenantAggregate> {
|
||||||
const db = this.dbAccessor.get()
|
const db = this.dbAccessor.get()
|
||||||
const tenantId = generateId()
|
const tenantId = generateId()
|
||||||
@@ -45,7 +46,7 @@ export class TenantRepository {
|
|||||||
slug: payload.slug,
|
slug: payload.slug,
|
||||||
planId: payload.planId ?? 'free',
|
planId: payload.planId ?? 'free',
|
||||||
storagePlanId: payload.storagePlanId ?? null,
|
storagePlanId: payload.storagePlanId ?? null,
|
||||||
status: 'active',
|
status: payload.status ?? 'active',
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(tenants).values(tenantRecord)
|
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 type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
|
||||||
import { injectable } from 'tsyringe'
|
import { injectable } from 'tsyringe'
|
||||||
|
|
||||||
import {
|
import { PENDING_TENANT_DEFAULT_NAME, ROOT_TENANT_NAME, ROOT_TENANT_SLUG } from './tenant.constants'
|
||||||
PLACEHOLDER_TENANT_NAME,
|
|
||||||
PLACEHOLDER_TENANT_SLUG,
|
|
||||||
ROOT_TENANT_NAME,
|
|
||||||
ROOT_TENANT_SLUG,
|
|
||||||
} from './tenant.constants'
|
|
||||||
import { TenantRepository } from './tenant.repository'
|
import { TenantRepository } from './tenant.repository'
|
||||||
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
|
import type { TenantAggregate, TenantContext, TenantRecord, TenantResolutionInput } from './tenant.types'
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class TenantService {
|
export class TenantService {
|
||||||
@@ -22,6 +17,7 @@ export class TenantService {
|
|||||||
slug: string
|
slug: string
|
||||||
planId?: BillingPlanId
|
planId?: BillingPlanId
|
||||||
storagePlanId?: string | null
|
storagePlanId?: string | null
|
||||||
|
status?: TenantRecord['status']
|
||||||
}): Promise<TenantAggregate> {
|
}): Promise<TenantAggregate> {
|
||||||
const normalizedSlug = this.normalizeSlug(payload.slug)
|
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> {
|
async ensureRootTenant(): Promise<TenantAggregate> {
|
||||||
const existing = await this.repository.findBySlug(ROOT_TENANT_SLUG)
|
const existing = await this.repository.findBySlug(ROOT_TENANT_SLUG)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -69,17 +49,11 @@ export class TenantService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async isPlaceholderTenantId(tenantId: string | null | undefined): Promise<boolean> {
|
async resolve(
|
||||||
if (!tenantId) {
|
input: TenantResolutionInput,
|
||||||
return false
|
options?: { noThrow?: boolean; allowPending?: boolean },
|
||||||
}
|
): Promise<TenantContext | null> {
|
||||||
const placeholder = await this.ensurePlaceholderTenant()
|
const { noThrow = false, allowPending = false } = options ?? {}
|
||||||
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> {
|
|
||||||
const tenantId = normalizeString(input.tenantId)
|
const tenantId = normalizeString(input.tenantId)
|
||||||
const slug = this.normalizeSlug(input.slug)
|
const slug = this.normalizeSlug(input.slug)
|
||||||
|
|
||||||
@@ -107,23 +81,23 @@ export class TenantService {
|
|||||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ensureTenantIsActive(aggregate.tenant)
|
this.ensureTenantIsActive(aggregate.tenant, { allowPending })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tenant: aggregate.tenant,
|
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)
|
const aggregate = await this.repository.findById(id)
|
||||||
if (!aggregate) {
|
if (!aggregate) {
|
||||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||||
}
|
}
|
||||||
this.ensureTenantIsActive(aggregate.tenant)
|
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
|
||||||
return aggregate
|
return aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBySlug(slug: string): Promise<TenantAggregate> {
|
async getBySlug(slug: string, options?: { allowPending?: boolean }): Promise<TenantAggregate> {
|
||||||
const normalized = this.normalizeSlug(slug)
|
const normalized = this.normalizeSlug(slug)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||||
@@ -133,7 +107,7 @@ export class TenantService {
|
|||||||
if (!aggregate) {
|
if (!aggregate) {
|
||||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||||
}
|
}
|
||||||
this.ensureTenantIsActive(aggregate.tenant)
|
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
|
||||||
return aggregate
|
return aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +133,8 @@ export class TenantService {
|
|||||||
return existing === null
|
return existing === null
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureTenantIsActive(tenant: TenantAggregate['tenant']): void {
|
ensureTenantIsActive(tenant: TenantAggregate['tenant'], options?: { allowPending?: boolean }): void {
|
||||||
|
const allowPending = options?.allowPending ?? false
|
||||||
if (tenant.banned) {
|
if (tenant.banned) {
|
||||||
throw new BizException(ErrorCode.TENANT_BANNED)
|
throw new BizException(ErrorCode.TENANT_BANNED)
|
||||||
}
|
}
|
||||||
@@ -168,11 +143,44 @@ export class TenantService {
|
|||||||
throw new BizException(ErrorCode.TENANT_SUSPENDED)
|
throw new BizException(ErrorCode.TENANT_SUSPENDED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tenant.status === 'pending' && allowPending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (tenant.status !== 'active') {
|
if (tenant.status !== 'active') {
|
||||||
throw new BizException(ErrorCode.TENANT_INACTIVE)
|
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 {
|
private normalizeSlug(value?: string | null): string | null {
|
||||||
const normalized = normalizeString(value)
|
const normalized = normalizeString(value)
|
||||||
return normalized ? normalized.toLowerCase() : null
|
return normalized ? normalized.toLowerCase() : null
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const TENANT_MISSING_ERROR_CODES = new Set([AUTH_TENANT_NOT_FOUND_ERROR_CODE, TE
|
|||||||
const {
|
const {
|
||||||
LOGIN: DEFAULT_LOGIN_PATH,
|
LOGIN: DEFAULT_LOGIN_PATH,
|
||||||
ROOT_LOGIN: ROOT_LOGIN_PATH,
|
ROOT_LOGIN: ROOT_LOGIN_PATH,
|
||||||
|
WELCOME: WELCOME_PATH,
|
||||||
TENANT_MISSING: TENANT_MISSING_PATH,
|
TENANT_MISSING: TENANT_MISSING_PATH,
|
||||||
DEFAULT_AUTHENTICATED: DEFAULT_AUTHENTICATED_PATH,
|
DEFAULT_AUTHENTICATED: DEFAULT_AUTHENTICATED_PATH,
|
||||||
SUPERADMIN_ROOT: SUPERADMIN_ROOT_PATH,
|
SUPERADMIN_ROOT: SUPERADMIN_ROOT_PATH,
|
||||||
@@ -126,6 +127,9 @@ export function usePageRedirect() {
|
|||||||
const isSuperAdmin = session?.user.role === 'superadmin'
|
const isSuperAdmin = session?.user.role === 'superadmin'
|
||||||
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
|
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
|
||||||
const isOnRootLoginPage = pathname === ROOT_LOGIN_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 (session && isSuperAdmin) {
|
||||||
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH || isOnRootLoginPage) {
|
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH || isOnRootLoginPage) {
|
||||||
@@ -134,6 +138,18 @@ export function usePageRedirect() {
|
|||||||
return
|
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) {
|
if (session && !isSuperAdmin && isOnSuperAdminPage) {
|
||||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||||
return
|
return
|
||||||
@@ -145,7 +161,8 @@ export function usePageRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session && (pathname === DEFAULT_LOGIN_PATH || pathname === ROOT_LOGIN_PATH)) {
|
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])
|
}, [location, location.pathname, navigate, sessionQuery.data, sessionQuery.isError, sessionQuery.isPending])
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { useSetAuthUser } from '~/atoms/auth'
|
import { useSetAuthUser } from '~/atoms/auth'
|
||||||
|
import { ROUTE_PATHS } from '~/constants/routes'
|
||||||
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
||||||
import { buildRootTenantUrl, buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
import { buildRootTenantUrl, buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
||||||
|
|
||||||
@@ -55,7 +56,12 @@ export function useLogin() {
|
|||||||
const { tenant } = session
|
const { tenant } = session
|
||||||
const isSuperAdmin = session.user.role === 'superadmin'
|
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)
|
const currentSlug = getTenantSlugFromHost(window.location.hostname)
|
||||||
if (!isSuperAdmin && tenant.slug !== currentSlug) {
|
if (!isSuperAdmin && tenant.slug !== currentSlug) {
|
||||||
try {
|
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,
|
"when": 1764154685207,
|
||||||
"tag": "0010_wise_doorman",
|
"tag": "0010_wise_doorman",
|
||||||
"breakpoints": true
|
"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 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 tenantDomainStatusEnum = pgEnum('tenant_domain_status', ['pending', 'verified', 'disabled'])
|
||||||
export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict'])
|
export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict'])
|
||||||
export const commentStatusEnum = pgEnum('comment_status', ['pending', 'approved', 'rejected', 'hidden'])
|
export const commentStatusEnum = pgEnum('comment_status', ['pending', 'approved', 'rejected', 'hidden'])
|
||||||
@@ -107,24 +107,34 @@ export const tenantDomains = pgTable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Custom users table (Better Auth: user)
|
// Custom users table (Better Auth: user)
|
||||||
export const authUsers = pgTable('auth_user', {
|
// Note: Multi-tenant design - same email can exist in different tenants
|
||||||
id: text('id').primaryKey(),
|
export const authUsers = pgTable(
|
||||||
name: text('name').notNull(),
|
'auth_user',
|
||||||
email: text('email').notNull().unique(),
|
{
|
||||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
id: text('id').primaryKey(),
|
||||||
image: text('image'),
|
name: text('name').notNull(),
|
||||||
creemCustomerId: text('creem_customer_id'),
|
email: text('email').notNull(),
|
||||||
role: userRoleEnum('role').notNull().default('user'),
|
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||||
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
image: text('image'),
|
||||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
creemCustomerId: text('creem_customer_id'),
|
||||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
role: userRoleEnum('role').notNull().default('user'),
|
||||||
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
|
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
||||||
username: text('username'),
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
displayUsername: text('display_username'),
|
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
banned: boolean('banned').default(false).notNull(),
|
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
|
||||||
banReason: text('ban_reason'),
|
username: text('username'),
|
||||||
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
|
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)
|
// Custom sessions table (Better Auth: session)
|
||||||
export const authSessions = pgTable('auth_session', {
|
export const authSessions = pgTable('auth_session', {
|
||||||
@@ -142,23 +152,35 @@ export const authSessions = pgTable('auth_session', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Custom accounts table (Better Auth: account)
|
// Custom accounts table (Better Auth: account)
|
||||||
export const authAccounts = pgTable('auth_account', {
|
// Note: Multi-tenant design - same social account can exist in different tenants
|
||||||
id: text('id').primaryKey(),
|
export const authAccounts = pgTable(
|
||||||
accountId: text('account_id').notNull(),
|
'auth_account',
|
||||||
providerId: text('provider_id').notNull(),
|
{
|
||||||
userId: text('user_id')
|
id: text('id').primaryKey(),
|
||||||
.notNull()
|
accountId: text('account_id').notNull(),
|
||||||
.references(() => authUsers.id, { onDelete: 'cascade' }),
|
providerId: text('provider_id').notNull(),
|
||||||
accessToken: text('access_token'),
|
userId: text('user_id')
|
||||||
refreshToken: text('refresh_token'),
|
.notNull()
|
||||||
idToken: text('id_token'),
|
.references(() => authUsers.id, { onDelete: 'cascade' }),
|
||||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
|
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
|
||||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
|
accessToken: text('access_token'),
|
||||||
scope: text('scope'),
|
refreshToken: text('refresh_token'),
|
||||||
password: text('password'),
|
idToken: text('id_token'),
|
||||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
|
||||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
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', {
|
export const authVerifications = pgTable('auth_verification', {
|
||||||
id: text('id').primaryKey(),
|
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.
|
- Calls `AuthProvider.getAuth()` so downstream handlers reuse the tenant-aware Better Auth instance.
|
||||||
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
|
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
|
||||||
- Extracts a slug via `tenant-host.utils.ts`.
|
- Extracts a slug via `tenant-host.utils.ts`.
|
||||||
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
|
- 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` (even when placeholder) so downstream services know which workspace was requested.
|
- Always stores the original `requestedSlug` so downstream services know which workspace was requested.
|
||||||
|
|
||||||
## Auth Provider
|
## 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.
|
- Consumers simply check `tenant.isPlaceholder` to know whether they are in onboarding.
|
||||||
|
|
||||||
## Dashboard Behavior
|
## 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.
|
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`.
|
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.
|
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.
|
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 tenant is provisioned, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
|
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
|
## Key Guarantees
|
||||||
|
|
||||||
|
|||||||
@@ -52,5 +52,3 @@ export function isTenantSlugReserved(slug: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
|
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
|
||||||
|
|
||||||
export const PLACEHOLDER_TENANT_SLUG = 'holding'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user