0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #3269 from logto-io/gao-support-path-based-multi-tenancy

feat(core): support path-based multi-tenancy
This commit is contained in:
Gao Sun 2023-03-03 16:13:05 +08:00 committed by GitHub
commit a852114caf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 120 additions and 22 deletions

View file

@ -0,0 +1,7 @@
---
"@logto/console": minor
"@logto/core": minor
---
- Automatically create a new tenant for new cloud users
- Support path-based multi-tenancy

View file

@ -8,7 +8,6 @@ export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === '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 httpsKey = process.env.HTTPS_KEY_PATH;
@ -28,6 +27,11 @@ export default class GlobalValues {
* - Admin Console will NOT be served under admin tenant since the cloud service will do.
* - Incoming requests will use glob matching to parse the tenant ID from the request URL.
*
* ```ts
* // ENDPOINT='https://*.domain.com'
* getTenantEndpoint('foo') => 'https://foo.domain.com'
* ```
*
* **When DBMT is disabled**
*
* - For non-admin tenants, tenant endpoint will always be `urlSet.endpoint`.
@ -52,6 +56,30 @@ export default class GlobalValues {
/** @see urlSet For detailed explanation. */
public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*');
/**
* This value indicates path-based multi-tenancy (PBMT) is enabled by setting env variable `PATH_BASED_MULTI_TENANCY` to a truthy value.
*
* Note the value will always be `false` if domain-based multi-tenancy is enabled.
*
* **When PBMT is enabled**
*
* - For non-admin tenants, tenant endpoint will be generated by appending the tenant ID to `urlSet.endpoint`.
* - For admin tenant, if `adminUrlSet` has no endpoint available, tenant endpoint will be generated by appending the tenant ID to `urlSet.endpoint`.
* - Admin Console will NOT be served under admin tenant since the cloud service will do.
* - Incoming requests will try to match the position of pathname segments of the URLs in `urlSet.deduplicated()` to parse the tenant ID from the request URL.
*
* ```ts
* // ENDPOINT='https://domain.com/foo'
* getTenantEndpoint('bar') => 'https://domain.com/foo/bar'
* matchTenantId('https://domain.com/foo/bar') => 'bar'
* matchTenantId('http://localhost:3001/foo/bar') => 'foo'
* ```
*
* @see urlSet
*/
public readonly isPathBasedMultiTenancy =
!this.isDomainBasedMultiTenancy && yes(getEnv('PATH_BASED_MULTI_TENANCY'));
// eslint-disable-next-line unicorn/consistent-function-scoping
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID');

View file

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

View file

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

View file

@ -43,6 +43,14 @@ export default class Tenant implements TenantContext {
public readonly app: Koa;
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);
}
@ -79,6 +87,8 @@ export default class Tenant implements TenantContext {
// Mount APIs
app.use(mount('/api', initApis(tenantContext)));
const { isDomainBasedMultiTenancy, isPathBasedMultiTenancy } = EnvSet.values;
// Mount admin tenant APIs and app
if (id === adminTenantId) {
// Mount `/me` APIs for admin tenant
@ -86,7 +96,7 @@ export default class Tenant implements TenantContext {
// Mount Admin Console when needed
// 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(
mount(
@ -101,7 +111,7 @@ export default class Tenant implements TenantContext {
// 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
// testing without signing out of the admin console.
if (id !== adminTenantId || EnvSet.values.isDomainBasedMultiTenancy) {
if (id !== adminTenantId || isDomainBasedMultiTenancy || isPathBasedMultiTenancy) {
// Mount demo app
app.use(
mount(

View file

@ -77,7 +77,7 @@ describe('getTenantId()', () => {
ADMIN_DISABLE_LOCALHOST: '1',
};
expect(getTenantId(new URL('http://localhost:5000/app///asdasd'))).toBe(defaultTenantId);
expect(getTenantId(new URL('http://localhost:5000/app///asdasd'))).toBe(undefined);
expect(getTenantId(new URL('http://localhost:3002/app///asdasd'))).toBe(undefined);
expect(getTenantId(new URL('https://user.foo.logto.mock/app'))).toBe('foo');
expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe(undefined); // Admin endpoint is explicitly set
@ -92,4 +92,21 @@ describe('getTenantId()', () => {
};
expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe('admin');
});
it('should resolve proper tenant ID for path-based multi-tenancy', async () => {
process.env = {
...backupEnv,
NODE_ENV: 'production',
PORT: '5000',
ENDPOINT: 'https://user.logto.mock/app',
PATH_BASED_MULTI_TENANCY: '1',
};
expect(getTenantId(new URL('http://localhost:5000/app///asdasd'))).toBe('app');
expect(getTenantId(new URL('http://localhost:3002///bar///asdasd'))).toBe(adminTenantId);
expect(getTenantId(new URL('https://user.foo.logto.mock/app'))).toBe(undefined);
expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe(undefined);
expect(getTenantId(new URL('https://user.logto.mock/app'))).toBe(undefined);
expect(getTenantId(new URL('https://user.logto.mock/app/admin'))).toBe('admin');
});
});

View file

@ -1,6 +1,7 @@
import { adminTenantId, defaultTenantId } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import type UrlSet from '#src/env-set/UrlSet.js';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
const normalizePathname = (pathname: string) =>
@ -14,9 +15,36 @@ const isEndpointOf = (current: URL, endpoint: URL) => {
);
};
const matchDomainBasedTenantId = (pattern: URL, url: URL) => {
const toMatch = pattern.hostname.replace('*', '([^.]*)');
const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1];
if (!matchedId || matchedId === '*') {
return;
}
if (isEndpointOf(url, getTenantEndpoint(matchedId, EnvSet.values))) {
return matchedId;
}
};
const matchPathBasedTenantId = (urlSet: UrlSet, url: URL) => {
const found = urlSet.deduplicated().find((value) => isEndpointOf(url, value));
if (!found) {
return;
}
const urlSegments = url.pathname.split('/');
const endpointSegments = found.pathname.split('/');
return urlSegments[found.pathname === '/' ? 1 : endpointSegments.length];
};
export const getTenantId = (url: URL) => {
const {
isDomainBasedMultiTenancy,
isPathBasedMultiTenancy,
isProduction,
isIntegrationTest,
developmentTenantId,
@ -34,21 +62,13 @@ export const getTenantId = (url: URL) => {
return developmentTenantId;
}
if (
!isDomainBasedMultiTenancy ||
(!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl))
) {
if (!isDomainBasedMultiTenancy && !isPathBasedMultiTenancy) {
return defaultTenantId;
}
const toMatch = urlSet.endpoint.hostname.replace('*', '([^.]*)');
const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1];
if (!matchedId || matchedId === '*') {
return;
if (isPathBasedMultiTenancy) {
return matchPathBasedTenantId(urlSet, url);
}
if (isEndpointOf(url, getTenantEndpoint(matchedId, EnvSet.values))) {
return matchedId;
}
return matchDomainBasedTenantId(urlSet.endpoint, url);
};