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:
commit
a852114caf
7 changed files with 120 additions and 22 deletions
7
.changeset/flat-mirrors-kiss.md
Normal file
7
.changeset/flat-mirrors-kiss.md
Normal 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
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue