0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core): support path-based multi-tenancy

This commit is contained in:
Gao Sun 2023-03-02 13:20:21 +08:00
parent 2a7c623240
commit d29432f45d
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
5 changed files with 46 additions and 13 deletions

View file

@ -8,7 +8,6 @@ export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production'; public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === 'test'; public readonly isTest = getEnv('NODE_ENV') === 'test';
public readonly isIntegrationTest = yes(getEnv('INTEGRATION_TEST')); public readonly isIntegrationTest = yes(getEnv('INTEGRATION_TEST'));
public readonly isCloud = yes(process.env.IS_CLOUD);
public readonly httpsCert = process.env.HTTPS_CERT_PATH; public readonly httpsCert = process.env.HTTPS_CERT_PATH;
public readonly httpsKey = process.env.HTTPS_KEY_PATH; public readonly httpsKey = process.env.HTTPS_KEY_PATH;
@ -52,6 +51,9 @@ export default class GlobalValues {
/** @see urlSet For detailed explanation. */ /** @see urlSet For detailed explanation. */
public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*'); public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*');
public readonly isPathBasedMultiTenancy =
!this.isDomainBasedMultiTenancy && yes(getEnv('PATH_BASED_MULTI_TENANCY'));
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage); public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID'); public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID');

View file

@ -1,3 +1,5 @@
import path from 'path';
import { adminTenantId } from '@logto/schemas'; import { adminTenantId } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials'; import type { Optional } from '@silverhand/essentials';
import { deduplicate, trySafe } from '@silverhand/essentials'; import { deduplicate, trySafe } from '@silverhand/essentials';
@ -6,7 +8,7 @@ import type GlobalValues from './GlobalValues.js';
export const getTenantEndpoint = ( export const getTenantEndpoint = (
id: string, id: string,
{ urlSet, adminUrlSet, isDomainBasedMultiTenancy }: GlobalValues { urlSet, adminUrlSet, isDomainBasedMultiTenancy, isPathBasedMultiTenancy }: GlobalValues
): URL => { ): URL => {
const adminUrl = trySafe(() => adminUrlSet.endpoint); const adminUrl = trySafe(() => adminUrlSet.endpoint);
@ -14,6 +16,10 @@ export const getTenantEndpoint = (
return adminUrl; return adminUrl;
} }
if (isPathBasedMultiTenancy) {
return new URL(path.join(urlSet.endpoint.pathname, id), urlSet.endpoint);
}
if (!isDomainBasedMultiTenancy) { if (!isDomainBasedMultiTenancy) {
return urlSet.endpoint; return urlSet.endpoint;
} }
@ -27,7 +33,7 @@ export const getTenantEndpoint = (
export const getTenantLocalhost = ( export const getTenantLocalhost = (
id: string, id: string,
{ urlSet, adminUrlSet, isDomainBasedMultiTenancy }: GlobalValues { urlSet, adminUrlSet, isDomainBasedMultiTenancy, isPathBasedMultiTenancy }: GlobalValues
): Optional<URL> => { ): Optional<URL> => {
const adminUrl = trySafe(() => adminUrlSet.localhostUrl); const adminUrl = trySafe(() => adminUrlSet.localhostUrl);
@ -35,8 +41,14 @@ export const getTenantLocalhost = (
return adminUrl; return adminUrl;
} }
const localhost = trySafe(() => urlSet.localhostUrl);
if (isPathBasedMultiTenancy && localhost) {
return new URL(path.join(localhost.pathname, id), localhost);
}
if (!isDomainBasedMultiTenancy) { if (!isDomainBasedMultiTenancy) {
return trySafe(() => urlSet.localhostUrl); return localhost;
} }
}; };

View file

@ -27,9 +27,13 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
keys: JWK[]; keys: JWK[];
issuer: string[]; issuer: string[];
}> => { }> => {
const { isDomainBasedMultiTenancy, adminUrlSet } = EnvSet.values; const { isDomainBasedMultiTenancy, isPathBasedMultiTenancy, adminUrlSet } = EnvSet.values;
if (!isDomainBasedMultiTenancy && adminUrlSet.deduplicated().length === 0) { if (
!isDomainBasedMultiTenancy &&
!isPathBasedMultiTenancy &&
adminUrlSet.deduplicated().length === 0
) {
return { keys: [], issuer: [] }; return { keys: [], issuer: [] };
} }
@ -48,7 +52,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))), keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
issuer: [ issuer: [
appendPath( appendPath(
isDomainBasedMultiTenancy isDomainBasedMultiTenancy || isPathBasedMultiTenancy
? getTenantEndpoint(adminTenantId, EnvSet.values) ? getTenantEndpoint(adminTenantId, EnvSet.values)
: adminUrlSet.endpoint, : adminUrlSet.endpoint,
'/oidc' '/oidc'

View file

@ -43,6 +43,14 @@ export default class Tenant implements TenantContext {
public readonly app: Koa; public readonly app: Koa;
get run(): MiddlewareType { get run(): MiddlewareType {
if (
EnvSet.values.isPathBasedMultiTenancy &&
// If admin URL Set is specified, consider that URL first
!(EnvSet.values.adminUrlSet.deduplicated().length > 0 && this.id === adminTenantId)
) {
return mount('/' + this.id, this.app);
}
return mount(this.app); return mount(this.app);
} }
@ -79,6 +87,8 @@ export default class Tenant implements TenantContext {
// Mount APIs // Mount APIs
app.use(mount('/api', initApis(tenantContext))); app.use(mount('/api', initApis(tenantContext)));
const { isDomainBasedMultiTenancy, isPathBasedMultiTenancy } = EnvSet.values;
// Mount admin tenant APIs and app // Mount admin tenant APIs and app
if (id === adminTenantId) { if (id === adminTenantId) {
// Mount `/me` APIs for admin tenant // Mount `/me` APIs for admin tenant
@ -86,7 +96,7 @@ export default class Tenant implements TenantContext {
// Mount Admin Console when needed // Mount Admin Console when needed
// Skip in domain-based multi-tenancy since Logto Cloud serves Admin Console in this case // Skip in domain-based multi-tenancy since Logto Cloud serves Admin Console in this case
if (!EnvSet.values.isDomainBasedMultiTenancy) { if (!isDomainBasedMultiTenancy && !isPathBasedMultiTenancy) {
app.use(koaConsoleRedirectProxy(queries)); app.use(koaConsoleRedirectProxy(queries));
app.use( app.use(
mount( mount(
@ -101,7 +111,7 @@ export default class Tenant implements TenantContext {
// while distinguishing "demo app from admin tenant" and "demo app from user tenant"; // while distinguishing "demo app from admin tenant" and "demo app from user tenant";
// on the cloud, we need to configure admin tenant sign-in experience, so a preview is needed for // on the cloud, we need to configure admin tenant sign-in experience, so a preview is needed for
// testing without signing out of the admin console. // testing without signing out of the admin console.
if (id !== adminTenantId || EnvSet.values.isDomainBasedMultiTenancy) { if (id !== adminTenantId || isDomainBasedMultiTenancy || isPathBasedMultiTenancy) {
// Mount demo app // Mount demo app
app.use( app.use(
mount( mount(

View file

@ -17,6 +17,7 @@ const isEndpointOf = (current: URL, endpoint: URL) => {
export const getTenantId = (url: URL) => { export const getTenantId = (url: URL) => {
const { const {
isDomainBasedMultiTenancy, isDomainBasedMultiTenancy,
isPathBasedMultiTenancy,
isProduction, isProduction,
isIntegrationTest, isIntegrationTest,
developmentTenantId, developmentTenantId,
@ -34,13 +35,17 @@ export const getTenantId = (url: URL) => {
return developmentTenantId; return developmentTenantId;
} }
if ( if (!isDomainBasedMultiTenancy && !isPathBasedMultiTenancy) {
!isDomainBasedMultiTenancy ||
(!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl))
) {
return defaultTenantId; return defaultTenantId;
} }
if (isPathBasedMultiTenancy) {
const urlSegments = url.pathname.split('/');
const endpointSegments = urlSet.endpoint.pathname.split('/');
return urlSegments[endpointSegments.length - 1];
}
const toMatch = urlSet.endpoint.hostname.replace('*', '([^.]*)'); const toMatch = urlSet.endpoint.hostname.replace('*', '([^.]*)');
const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1]; const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1];