mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat: init multi-tenancy environment (#2929)
This commit is contained in:
parent
4944062f1f
commit
95a44929a5
22 changed files with 210 additions and 83 deletions
|
@ -34,8 +34,8 @@
|
|||
"@logto/schemas": "workspace:*",
|
||||
"@logto/shared": "workspace:*",
|
||||
"@silverhand/essentials": "2.1.0",
|
||||
"@withtyped/postgres": "^0.3.1",
|
||||
"@withtyped/server": "^0.3.1",
|
||||
"@withtyped/postgres": "^0.4.0",
|
||||
"@withtyped/server": "^0.4.0",
|
||||
"chalk": "^5.0.0",
|
||||
"clean-deep": "^3.4.0",
|
||||
"date-fns": "^2.29.3",
|
||||
|
|
|
@ -6,20 +6,39 @@ import chalk from 'chalk';
|
|||
import type Koa from 'koa';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { tenantPool, defaultTenant } from '#src/tenants/index.js';
|
||||
import { defaultTenant, tenantPool } from '#src/tenants/index.js';
|
||||
|
||||
const logListening = () => {
|
||||
const { localhostUrl, endpoint } = EnvSet.values;
|
||||
|
||||
for (const url of deduplicate([localhostUrl, endpoint])) {
|
||||
for (const url of deduplicate([localhostUrl, endpoint.toString()])) {
|
||||
console.log(chalk.bold(chalk.green(`App is running at ${url}`)));
|
||||
}
|
||||
};
|
||||
|
||||
const getTenantId = () => {
|
||||
if (!EnvSet.values.isMultiTenancy) {
|
||||
return defaultTenant;
|
||||
}
|
||||
|
||||
if (EnvSet.values.multiTenancyMode === 'domain') {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
return !EnvSet.values.isProduction && EnvSet.values.developmentTenantId;
|
||||
};
|
||||
|
||||
export default async function initApp(app: Koa): Promise<void> {
|
||||
app.use(async (ctx, next) => {
|
||||
// TODO: add multi-tenancy logic
|
||||
const tenant = await tenantPool.get(defaultTenant);
|
||||
const tenantId = getTenantId();
|
||||
|
||||
if (!tenantId) {
|
||||
ctx.status = 404;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const tenant = await tenantPool.get(tenantId);
|
||||
|
||||
return tenant.run(ctx, next);
|
||||
});
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import net from 'net';
|
||||
|
||||
import { tryThat } from '@logto/shared';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||
|
@ -23,29 +25,64 @@ export enum MountedApps {
|
|||
Welcome = 'welcome',
|
||||
}
|
||||
|
||||
type MultiTenancyMode = 'domain' | 'env';
|
||||
|
||||
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
|
||||
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
|
||||
|
||||
const loadEnvValues = () => {
|
||||
const isProduction = getEnv('NODE_ENV') === 'production';
|
||||
const isTest = getEnv('NODE_ENV') === 'test';
|
||||
const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
|
||||
const isHttpsEnabled = Boolean(process.env.HTTPS_CERT_PATH && process.env.HTTPS_KEY_PATH);
|
||||
const isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
|
||||
const port = Number(getEnv('PORT', '3001'));
|
||||
const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||
const endpoint = getEnv('ENDPOINT', localhostUrl);
|
||||
const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
|
||||
|
||||
const { hostname } = new URL(endpoint);
|
||||
const multiTenancyMode: MultiTenancyMode =
|
||||
isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost' ? 'domain' : 'env';
|
||||
const developmentTenantId = getEnv(developmentTenantIdKey);
|
||||
|
||||
if (!isMultiTenancy && developmentTenantId) {
|
||||
throw new Error(
|
||||
`Multi-tenancy is disabled but development tenant env \`${developmentTenantIdKey}\` found. Please enable multi-tenancy by setting \`${enableMultiTenancyKey}\` to true.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isMultiTenancy && multiTenancyMode === 'env') {
|
||||
if (isProduction) {
|
||||
throw new Error(
|
||||
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
|
||||
'An endpoint with a valid domain is required for multi-tenancy mode.'
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[warn]',
|
||||
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
|
||||
`Logto is using \`${developmentTenantIdKey}\` env (current value: ${developmentTenantId}) for tenant recognition which is not supported in production.`
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
isTest,
|
||||
isIntegrationTest,
|
||||
isProduction,
|
||||
isHttpsEnabled,
|
||||
isMultiTenancy,
|
||||
httpsCert: process.env.HTTPS_CERT_PATH,
|
||||
httpsKey: process.env.HTTPS_KEY_PATH,
|
||||
port,
|
||||
localhostUrl,
|
||||
endpoint,
|
||||
multiTenancyMode,
|
||||
dbUrl: databaseUrl,
|
||||
userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'),
|
||||
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
|
||||
developmentTenantId,
|
||||
trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')),
|
||||
adminConsoleUrl: appendPath(endpoint, '/console'),
|
||||
});
|
||||
|
@ -53,6 +90,8 @@ const loadEnvValues = () => {
|
|||
|
||||
export class EnvSet {
|
||||
static values: ReturnType<typeof loadEnvValues> = loadEnvValues();
|
||||
static default = new EnvSet(EnvSet.values.dbUrl);
|
||||
|
||||
static get isTest() {
|
||||
return this.values.isTest;
|
||||
}
|
||||
|
@ -63,7 +102,7 @@ export class EnvSet {
|
|||
#queryClient: Optional<QueryClient<PostgreSql>>;
|
||||
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
|
||||
|
||||
constructor(public readonly databaseUrl = EnvSet.values.dbUrl) {}
|
||||
constructor(public readonly databaseUrl: string) {}
|
||||
|
||||
get pool() {
|
||||
if (!this.#pool) {
|
||||
|
@ -111,3 +150,5 @@ export class EnvSet {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
await EnvSet.default.load();
|
||||
|
|
|
@ -3,15 +3,14 @@ import dotenv from 'dotenv';
|
|||
import { findUp } from 'find-up';
|
||||
import Koa from 'koa';
|
||||
|
||||
import initI18n from './i18n/init.js';
|
||||
|
||||
dotenv.config({ path: await findUp('.env', {}) });
|
||||
|
||||
// Import after env has configured
|
||||
|
||||
const { loadConnectorFactories } = await import('./utils/connectors/factories.js');
|
||||
const { EnvSet } = await import('./env-set/index.js');
|
||||
const { tenantPool } = await import('./tenants/index.js');
|
||||
const { default: initI18n } = await import('./i18n/init.js');
|
||||
const { tenantPool, checkRowLevelSecurity } = await import('./tenants/index.js');
|
||||
|
||||
try {
|
||||
const app = new Koa({
|
||||
|
@ -20,6 +19,10 @@ try {
|
|||
await initI18n();
|
||||
await loadConnectorFactories();
|
||||
|
||||
if (EnvSet.values.isMultiTenancy) {
|
||||
await checkRowLevelSecurity(EnvSet.default.queryClient);
|
||||
}
|
||||
|
||||
// Import last until init completed
|
||||
const { default: initApp } = await import('./app/init.js');
|
||||
await initApp(app);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { managementResourceScope } from '@logto/schemas';
|
||||
import { managementResourceScope, UserRole } from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
import type { Context } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
@ -6,7 +6,6 @@ import Sinon from 'sinon';
|
|||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { envSetForTest } from '#src/test-utils/env-set.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { WithAuthContext } from './koa-auth.js';
|
||||
|
@ -64,7 +63,7 @@ describe('koaAuth middleware', () => {
|
|||
developmentUserId: 'foo',
|
||||
});
|
||||
|
||||
await koaAuth(envSetForTest)(ctx, next);
|
||||
await koaAuth(EnvSet.default)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -79,7 +78,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(envSetForTest)(mockCtx, next);
|
||||
await koaAuth(EnvSet.default)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
});
|
||||
|
||||
|
@ -91,7 +90,7 @@ describe('koaAuth middleware', () => {
|
|||
isIntegrationTest: true,
|
||||
});
|
||||
|
||||
await koaAuth(envSetForTest)(ctx, next);
|
||||
await koaAuth(EnvSet.default)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -112,7 +111,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(envSetForTest)(mockCtx, next);
|
||||
await koaAuth(EnvSet.default)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -125,12 +124,12 @@ describe('koaAuth middleware', () => {
|
|||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
await koaAuth(envSetForTest)(ctx, next);
|
||||
await koaAuth(EnvSet.default)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header is missing', async () => {
|
||||
await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
|
||||
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header token type not recognized ', async () => {
|
||||
|
@ -141,7 +140,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
|
||||
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
|
||||
});
|
||||
|
||||
it('expect to throw if jwt sub is missing', async () => {
|
||||
|
@ -154,7 +153,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
|
||||
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
|
||||
});
|
||||
|
||||
it('expect to have `client` type per jwt verify result', async () => {
|
||||
|
@ -167,7 +166,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(envSetForTest)(ctx, next);
|
||||
await koaAuth(EnvSet.default)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' });
|
||||
});
|
||||
|
||||
|
@ -181,9 +180,9 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
koaAuth(envSetForTest, managementResourceScope.name)(ctx, next)
|
||||
).rejects.toMatchError(forbiddenError);
|
||||
await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError(
|
||||
forbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('expect to throw if jwt scope does not include management resource scope', async () => {
|
||||
|
@ -198,9 +197,9 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
koaAuth(envSetForTest, managementResourceScope.name)(ctx, next)
|
||||
).rejects.toMatchError(forbiddenError);
|
||||
await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError(
|
||||
forbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('expect to throw unauthorized error if unknown error occurs', async () => {
|
||||
|
@ -214,7 +213,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(
|
||||
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(
|
||||
new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error'))
|
||||
);
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import { createMockUtils } from '@logto/shared/esm';
|
|||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
import { mockApplication } from '#src/__mocks__/index.js';
|
||||
import { envSetForTest } from '#src/test-utils/env-set.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { getConstantClientMetadata } from './utils.js';
|
||||
|
@ -48,7 +48,7 @@ const now = Date.now();
|
|||
describe('postgres Adapter', () => {
|
||||
it('Client Modal', async () => {
|
||||
const rejectError = new Error('Not implemented');
|
||||
const adapter = postgresAdapter(envSetForTest, queries, 'Client');
|
||||
const adapter = postgresAdapter(EnvSet.default, queries, 'Client');
|
||||
|
||||
await expect(adapter.upsert('client', {}, 0)).rejects.toMatchError(rejectError);
|
||||
await expect(adapter.findByUserCode('foo')).rejects.toMatchError(rejectError);
|
||||
|
@ -72,7 +72,7 @@ describe('postgres Adapter', () => {
|
|||
client_id,
|
||||
client_name,
|
||||
client_secret,
|
||||
...getConstantClientMetadata(envSetForTest, type),
|
||||
...getConstantClientMetadata(EnvSet.default, type),
|
||||
...snakecaseKeys(oidcClientMetadata),
|
||||
...customClientMetadata,
|
||||
});
|
||||
|
@ -85,7 +85,7 @@ describe('postgres Adapter', () => {
|
|||
const id = 'fooId';
|
||||
const grantId = 'grantId';
|
||||
const expireAt = 60;
|
||||
const adapter = postgresAdapter(envSetForTest, queries, modelName);
|
||||
const adapter = postgresAdapter(EnvSet.default, queries, modelName);
|
||||
|
||||
await adapter.upsert(id, { uid, userCode }, expireAt);
|
||||
expect(upsertInstance).toBeCalledWith({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { envSetForTest } from '#src/test-utils/env-set.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import initOidc from './init.js';
|
||||
|
@ -7,6 +7,6 @@ describe('oidc provider init', () => {
|
|||
it('init should not throw', async () => {
|
||||
const { queries, libraries } = new MockTenant();
|
||||
|
||||
expect(() => initOidc(envSetForTest, queries, libraries)).not.toThrow();
|
||||
expect(() => initOidc(EnvSet.default, queries, libraries)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas';
|
||||
|
||||
import { envSetForTest } from '#src/test-utils/env-set.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
import {
|
||||
isOriginAllowed,
|
||||
|
@ -10,22 +10,22 @@ import {
|
|||
} from './utils.js';
|
||||
|
||||
describe('getConstantClientMetadata()', () => {
|
||||
expect(getConstantClientMetadata(envSetForTest, ApplicationType.SPA)).toEqual({
|
||||
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.SPA)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(envSetForTest, ApplicationType.Native)).toEqual({
|
||||
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Native)).toEqual({
|
||||
application_type: 'native',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(envSetForTest, ApplicationType.Traditional)).toEqual({
|
||||
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Traditional)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
});
|
||||
expect(getConstantClientMetadata(envSetForTest, ApplicationType.MachineToMachine)).toEqual({
|
||||
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.MachineToMachine)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.ClientCredentials],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
|
|
|
@ -3,6 +3,8 @@ import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
|||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { emptyMiddleware } from '#src/utils/test-utils.js';
|
||||
|
||||
import { defaultTenant } from './consts.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmDefault } = createMockUtils(jest);
|
||||
|
||||
|
@ -30,7 +32,7 @@ const Tenant = await pickDefault(import('./Tenant.js'));
|
|||
|
||||
describe('Tenant', () => {
|
||||
it('should call middleware factories', async () => {
|
||||
await Tenant.create('foo');
|
||||
await Tenant.create(defaultTenant);
|
||||
|
||||
for (const middleware of middlewareList) {
|
||||
expect(middleware).toBeCalled();
|
||||
|
|
|
@ -24,10 +24,23 @@ import initRouter from '#src/routes/init.js';
|
|||
import Libraries from './Libraries.js';
|
||||
import Queries from './Queries.js';
|
||||
import type TenantContext from './TenantContext.js';
|
||||
import { defaultTenant } from './consts.js';
|
||||
import { getTenantDatabaseDsn } from './utils.js';
|
||||
|
||||
export default class Tenant implements TenantContext {
|
||||
static async create(id: string) {
|
||||
const envSet = new EnvSet();
|
||||
static async create(id: string): Promise<Tenant> {
|
||||
if (!EnvSet.values.isMultiTenancy) {
|
||||
if (id !== defaultTenant) {
|
||||
throw new Error(
|
||||
`Trying to create a tenant instance with ID ${id} in single-tenancy mode. This is a no-op.`
|
||||
);
|
||||
}
|
||||
|
||||
return new Tenant(EnvSet.default, id);
|
||||
}
|
||||
|
||||
// In multi-tenancy mode, treat the default database URL as the management URL
|
||||
const envSet = new EnvSet(await getTenantDatabaseDsn(EnvSet.default, id));
|
||||
await envSet.load();
|
||||
|
||||
return new Tenant(envSet, id);
|
||||
|
|
|
@ -32,3 +32,4 @@ class TenantPool {
|
|||
export const tenantPool = new TenantPool();
|
||||
|
||||
export * from './consts.js';
|
||||
export * from './utils.js';
|
||||
|
|
54
packages/core/src/tenants/utils.ts
Normal file
54
packages/core/src/tenants/utils.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Tenants } from '@logto/schemas/models';
|
||||
import { isKeyInObject } from '@logto/shared';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import { identifier, sql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
import { parseDsn, stringifyDsn } from 'slonik';
|
||||
|
||||
import type { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
/**
|
||||
* This function is to fetch the tenant password for the corresponding Postgres user.
|
||||
*
|
||||
* In multi-tenancy mode, Logto should ALWAYS use a restricted user with RLS enforced to ensure data isolation between tenants.
|
||||
*/
|
||||
export const getTenantDatabaseDsn = async (defaultEnvSet: EnvSet, tenantId: string) => {
|
||||
const {
|
||||
tableName,
|
||||
rawKeys: { id, dbUserPassword },
|
||||
} = Tenants;
|
||||
|
||||
const { rows } = await defaultEnvSet.queryClient.query(sql`
|
||||
select ${identifier(dbUserPassword)}
|
||||
from ${identifier(tableName)}
|
||||
where ${identifier(id)} = ${tenantId}
|
||||
`);
|
||||
const password = rows[0]?.db_user_password;
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`);
|
||||
}
|
||||
|
||||
const options = parseDsn(defaultEnvSet.databaseUrl);
|
||||
|
||||
return stringifyDsn({ ...options, username: `tenant_${tenantId}`, password });
|
||||
};
|
||||
|
||||
export const checkRowLevelSecurity = async (client: QueryClient) => {
|
||||
const { rows } = await client.query(sql`
|
||||
select tablename
|
||||
from pg_catalog.pg_tables
|
||||
where schemaname = current_schema()
|
||||
and rowsecurity=false
|
||||
`);
|
||||
|
||||
if (rows.length > 0) {
|
||||
throw new Error(
|
||||
'Row-level security has to be enforced on EVERY table when starting Logto in multi-tenancy mode.\n' +
|
||||
`Found following table(s) without RLS: ${rows
|
||||
.map((row) => conditionalString(isKeyInObject(row, 'tablename') && String(row.tablename)))
|
||||
.join(', ')}\n\n` +
|
||||
'Did you forget to run `npm cli db multi-tenancy enable`?'
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
/** FOR TEST PURPOSE ONLY, DON'T USE IN PROD. */
|
||||
export const envSetForTest = new EnvSet();
|
||||
|
||||
await envSetForTest.load();
|
|
@ -1,11 +1,11 @@
|
|||
import { createMockPool, createMockQueryResult } from 'slonik';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { createModelRouters } from '#src/model-routers/index.js';
|
||||
import Libraries from '#src/tenants/Libraries.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
import { envSetForTest } from './env-set.js';
|
||||
import type { GrantMock } from './oidc-provider.js';
|
||||
import { createMockProvider } from './oidc-provider.js';
|
||||
import { MockQueryClient } from './query-client.js';
|
||||
|
@ -45,7 +45,7 @@ export type DeepPartial<T> = T extends object
|
|||
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
|
||||
|
||||
export class MockTenant implements TenantContext {
|
||||
public envSet = envSetForTest;
|
||||
public envSet = EnvSet.default;
|
||||
public queries: Queries;
|
||||
public libraries: Libraries;
|
||||
public modelRouters = createModelRouters(new MockQueryClient());
|
||||
|
|
|
@ -2,18 +2,12 @@ import path from 'path';
|
|||
|
||||
import type { AllConnector, CreateConnector } from '@logto/connector-kit';
|
||||
import connectorKitMeta from '@logto/connector-kit/package.json' assert { type: 'json' };
|
||||
import { isKeyInObject } from '@logto/shared';
|
||||
import { satisfies } from 'semver';
|
||||
|
||||
const connectorKit = '@logto/connector-kit';
|
||||
const { version: currentVersion } = connectorKitMeta;
|
||||
|
||||
const isKeyInObject = <Key extends string>(
|
||||
object: unknown,
|
||||
key: Key
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
): object is object & Record<Key, unknown> =>
|
||||
object !== null && typeof object === 'object' && key in object;
|
||||
|
||||
const checkConnectorKitVersion = (dependencies: unknown) => {
|
||||
if (isKeyInObject(dependencies, connectorKit)) {
|
||||
const value = dependencies[connectorKit];
|
||||
|
|
|
@ -53,6 +53,6 @@
|
|||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc",
|
||||
"dependencies": {
|
||||
"@withtyped/server": "^0.3.1"
|
||||
"@withtyped/server": "^0.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
"@logto/language-kit": "workspace:*",
|
||||
"@logto/phrases": "workspace:*",
|
||||
"@logto/phrases-ui": "workspace:*",
|
||||
"@withtyped/server": "^0.3.1",
|
||||
"@withtyped/server": "^0.4.0",
|
||||
"zod": "^3.20.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from './hooks.js';
|
||||
export * from './tenants.js';
|
||||
|
|
9
packages/schemas/src/models/tenants.ts
Normal file
9
packages/schemas/src/models/tenants.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createModel } from '@withtyped/server';
|
||||
|
||||
export const Tenants = createModel(/* sql */ `
|
||||
create table tenants (
|
||||
id varchar(32) not null,
|
||||
db_user_password varchar(128) not null,
|
||||
primary key (id)
|
||||
);
|
||||
`);
|
|
@ -1,2 +1,3 @@
|
|||
export * from './function.js';
|
||||
export * from './object.js';
|
||||
export { default as findPackage } from './find-package.js';
|
||||
|
|
5
packages/shared/src/utils/object.ts
Normal file
5
packages/shared/src/utils/object.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const isKeyInObject = <Key extends string>(
|
||||
object: unknown,
|
||||
key: Key
|
||||
): object is object & Record<Key, unknown> =>
|
||||
object !== null && typeof object === 'object' && key in object;
|
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
|
@ -274,8 +274,8 @@ importers:
|
|||
'@types/semver': ^7.3.12
|
||||
'@types/sinon': ^10.0.13
|
||||
'@types/supertest': ^2.0.11
|
||||
'@withtyped/postgres': ^0.3.1
|
||||
'@withtyped/server': ^0.3.1
|
||||
'@withtyped/postgres': ^0.4.0
|
||||
'@withtyped/server': ^0.4.0
|
||||
chalk: ^5.0.0
|
||||
clean-deep: ^3.4.0
|
||||
copyfiles: ^2.4.1
|
||||
|
@ -333,8 +333,8 @@ importers:
|
|||
'@logto/schemas': link:../schemas
|
||||
'@logto/shared': link:../shared
|
||||
'@silverhand/essentials': 2.1.0
|
||||
'@withtyped/postgres': 0.3.1_@withtyped+server@0.3.1
|
||||
'@withtyped/server': 0.3.1
|
||||
'@withtyped/postgres': 0.4.0_@withtyped+server@0.4.0
|
||||
'@withtyped/server': 0.4.0
|
||||
chalk: 5.1.2
|
||||
clean-deep: 3.4.0
|
||||
date-fns: 2.29.3
|
||||
|
@ -479,7 +479,7 @@ importers:
|
|||
'@types/jest': ^29.1.2
|
||||
'@types/jest-environment-puppeteer': ^5.0.2
|
||||
'@types/node': ^18.11.18
|
||||
'@withtyped/server': ^0.3.1
|
||||
'@withtyped/server': ^0.4.0
|
||||
dotenv: ^16.0.0
|
||||
eslint: ^8.21.0
|
||||
got: ^12.5.3
|
||||
|
@ -493,7 +493,7 @@ importers:
|
|||
text-encoder: ^0.0.4
|
||||
typescript: ^4.9.4
|
||||
dependencies:
|
||||
'@withtyped/server': 0.3.1
|
||||
'@withtyped/server': 0.4.0
|
||||
devDependencies:
|
||||
'@jest/types': 29.1.2
|
||||
'@logto/connector-kit': link:../toolkit/connector-kit
|
||||
|
@ -583,7 +583,7 @@ importers:
|
|||
'@types/jest': ^29.1.2
|
||||
'@types/node': ^18.11.18
|
||||
'@types/pluralize': ^0.0.29
|
||||
'@withtyped/server': ^0.3.1
|
||||
'@withtyped/server': ^0.4.0
|
||||
camelcase: ^7.0.0
|
||||
eslint: ^8.21.0
|
||||
jest: ^29.1.2
|
||||
|
@ -599,7 +599,7 @@ importers:
|
|||
'@logto/language-kit': link:../toolkit/language-kit
|
||||
'@logto/phrases': link:../phrases
|
||||
'@logto/phrases-ui': link:../phrases-ui
|
||||
'@withtyped/server': 0.3.1
|
||||
'@withtyped/server': 0.4.0
|
||||
zod: 3.20.2
|
||||
devDependencies:
|
||||
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
|
||||
|
@ -4080,21 +4080,12 @@ packages:
|
|||
resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==}
|
||||
dev: true
|
||||
|
||||
/@types/pg/8.6.5:
|
||||
resolution: {integrity: sha512-tOkGtAqRVkHa/PVZicq67zuujI4Oorfglsr2IbKofDwBSysnaqSx7W1mDqFqdkGE6Fbgh+PZAl0r/BWON/mozw==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
pg-protocol: 1.5.0
|
||||
pg-types: 2.2.0
|
||||
dev: false
|
||||
|
||||
/@types/pg/8.6.6:
|
||||
resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
pg-protocol: 1.5.0
|
||||
pg-types: 2.2.0
|
||||
dev: true
|
||||
|
||||
/@types/pluralize/0.0.29:
|
||||
resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==}
|
||||
|
@ -4397,21 +4388,21 @@ packages:
|
|||
eslint-visitor-keys: 3.3.0
|
||||
dev: true
|
||||
|
||||
/@withtyped/postgres/0.3.1_@withtyped+server@0.3.1:
|
||||
resolution: {integrity: sha512-+XP+kbmTKKpv/5Nf4KDVKfWp6kYGIyty3aUUnSrBY0KLdOUfesuPjFK6S7sNgbh+7pvk/iU48/3UDsjuy4m+SQ==}
|
||||
/@withtyped/postgres/0.4.0_@withtyped+server@0.4.0:
|
||||
resolution: {integrity: sha512-jzDdXhGNkIBeWlnEU3hft2CriyWgabI46a5n5T7faMUkHzjHlgIH4IscdT8Vq7n3YIdAC6ovFtQW8g6SNyVvlg==}
|
||||
peerDependencies:
|
||||
'@withtyped/server': ^0.3.0
|
||||
'@withtyped/server': ^0.4.0
|
||||
dependencies:
|
||||
'@types/pg': 8.6.5
|
||||
'@withtyped/server': 0.3.1
|
||||
'@types/pg': 8.6.6
|
||||
'@withtyped/server': 0.4.0
|
||||
'@withtyped/shared': 0.2.0
|
||||
pg: 8.8.0
|
||||
transitivePeerDependencies:
|
||||
- pg-native
|
||||
dev: false
|
||||
|
||||
/@withtyped/server/0.3.1:
|
||||
resolution: {integrity: sha512-AI4QDHVTgv5GWPomWCgP5vqgVWaiby1vm56LBbSqe6r1DTPOZrySoxNoaE5XTQzYX1Jd3pzq1CsOd5AxkgCfpg==}
|
||||
/@withtyped/server/0.4.0:
|
||||
resolution: {integrity: sha512-72WUKDnhJl5FZurPUrvrwCcyIrj+U5Vq4vghmB/Lg+Bb9eTgSFbsaKujJtJNFor+1eSEDdCNNNUvOxfwZEz2JQ==}
|
||||
dependencies:
|
||||
'@withtyped/shared': 0.2.0
|
||||
dev: false
|
||||
|
|
Loading…
Add table
Reference in a new issue