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

refactor: fix alteration

This commit is contained in:
Gao Sun 2023-02-09 18:31:14 +08:00
parent 99837b4e48
commit 6b09da2f5d
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
27 changed files with 161 additions and 108 deletions

View file

@ -88,7 +88,7 @@ jobs:
- name: Run Logto - name: Run Logto
working-directory: logto/ working-directory: logto/
run: npm start & run: nohup npm start > nohup.out 2> nohup.err < /dev/null &
env: env:
INTEGRATION_TEST: true INTEGRATION_TEST: true
@ -101,3 +101,11 @@ jobs:
cd tests/packages/integration-tests cd tests/packages/integration-tests
pnpm build pnpm build
pnpm test:${{ matrix.test_target }} pnpm test:${{ matrix.test_target }}
- name: Show logs
working-directory: logto/
run: cat nohup.out
- name: Show error logs
working-directory: logto/
run: cat nohup.err

View file

@ -173,10 +173,6 @@ jobs:
run: node .scripts/compare-database.js fresh old run: node .scripts/compare-database.js fresh old
# ** End ** # ** End **
- name: Check database
working-directory: ./fresh
run: node .scripts/check-database.js fresh
- name: Check alteration databases - name: Check alteration databases
working-directory: ./fresh working-directory: ./fresh
run: node .scripts/check-alterations-sequence.js run: node .scripts/check-alterations-sequence.js

View file

@ -1,3 +0,0 @@
throw new Error('not implemented');
// TODO: check tables have tenant_id

View file

@ -117,20 +117,12 @@ assert.deepStrictEqual(...manifests);
const queryDatabaseData = async (database) => { const queryDatabaseData = async (database) => {
const pool = new pg.Pool({ database, user: 'postgres', password: 'postgres' }); const pool = new pg.Pool({ database, user: 'postgres', password: 'postgres' });
const result = await Promise.all(manifests[0].tables const result = await Promise.all(manifests[0].tables
.filter(({ table_name }) => !['logto_configs', '_logto_configs'].includes(table_name)) // system configs are usually generated or time-relative, ignore for now
.filter(({ table_name }) => !['logto_configs', '_logto_configs', 'systems'].includes(table_name))
.map(async ({ table_name }) => { .map(async ({ table_name }) => {
const { rows } = await pool.query(/* sql */`select * from ${table_name};`); const { rows } = await pool.query(/* sql */`select * from ${table_name};`);
if (table_name === 'systems') { return [table_name, omitArray(rows, 'created_at', 'updated_at', 'secret', 'db_user', 'db_user_password')];
return [
table_name,
rows.map(({ value, ...rest }) =>
({ ...rest, value: omit(value, 'createdAt', 'updatedAt') })
),
];
}
return [table_name, omitArray(rows, 'created_at', 'updated_at', 'secret')];
}) })
); );

View file

@ -3,7 +3,6 @@
*/ */
import { createMockUtils } from '@logto/shared/esm'; import { createMockUtils } from '@logto/shared/esm';
import { createMockQueryResult, createMockPool } from 'slonik';
const { jest } = import.meta; const { jest } = import.meta;
const { mockEsm, mockEsmWithActual, mockEsmDefault } = createMockUtils(jest); const { mockEsm, mockEsmWithActual, mockEsmDefault } = createMockUtils(jest);
@ -12,6 +11,7 @@ process.env.DB_URL = 'postgres://mock.db.url';
process.env.ENDPOINT = 'https://logto.test'; process.env.ENDPOINT = 'https://logto.test';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
/* Mock for EnvSet */
mockEsm('#src/libraries/logto-config.js', () => ({ mockEsm('#src/libraries/logto-config.js', () => ({
createLogtoConfigLibrary: () => ({ getOidcConfigs: () => ({}) }), createLogtoConfigLibrary: () => ({ getOidcConfigs: () => ({}) }),
})); }));
@ -24,6 +24,7 @@ mockEsm('#src/env-set/check-alteration-state.js', () => ({
mockEsmDefault('#src/env-set/oidc.js', () => () => ({ mockEsmDefault('#src/env-set/oidc.js', () => () => ({
issuer: 'https://logto.test/oidc', issuer: 'https://logto.test/oidc',
})); }));
/* End */
await mockEsmWithActual('#src/env-set/index.js', () => ({ await mockEsmWithActual('#src/env-set/index.js', () => ({
MountedApps: { MountedApps: {
@ -33,18 +34,6 @@ await mockEsmWithActual('#src/env-set/index.js', () => ({
DemoApp: 'demo-app', DemoApp: 'demo-app',
Welcome: 'welcome', Welcome: 'welcome',
}, },
// TODO: Remove after clean up of default env sets
default: {
get oidc() {
return {
issuer: 'https://logto.test/oidc',
};
},
get pool() {
return createMockPool({ query: async () => createMockQueryResult([]) });
},
load: jest.fn(),
},
})); }));
// Logger is not considered in all test cases // Logger is not considered in all test cases

View file

@ -34,8 +34,8 @@
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*", "@logto/shared": "workspace:*",
"@silverhand/essentials": "2.1.0", "@silverhand/essentials": "2.1.0",
"@withtyped/postgres": "^0.4.1", "@withtyped/postgres": "^0.5.1",
"@withtyped/server": "^0.4.1", "@withtyped/server": "^0.5.1",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",

View file

@ -17,8 +17,15 @@ const logListening = (type: 'core' | 'admin' = 'core') => {
}; };
const getTenantId = () => { const getTenantId = () => {
if (!EnvSet.values.isDomainBasedMultiTenancy) { const { isDomainBasedMultiTenancy, isProduction, isIntegrationTest, developmentTenantId } =
return (!EnvSet.values.isProduction && EnvSet.values.developmentTenantId) || defaultTenant; EnvSet.values;
if (!isDomainBasedMultiTenancy) {
if ((!isProduction || isIntegrationTest) && developmentTenantId) {
return developmentTenantId;
}
return defaultTenant;
} }
throw new Error('Not implemented'); throw new Error('Not implemented');

View file

@ -7,7 +7,6 @@ import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { appendPath } from '#src/utils/url.js'; import { appendPath } from '#src/utils/url.js';
import GlobalValues from './GlobalValues.js'; import GlobalValues from './GlobalValues.js';
import { checkAlterationState } from './check-alteration-state.js';
import createPool from './create-pool.js'; import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js'; import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js'; import loadOidcValues from './oidc.js';
@ -23,12 +22,20 @@ export enum MountedApps {
export class EnvSet { export class EnvSet {
static values = new GlobalValues(); static values = new GlobalValues();
static default = new EnvSet(EnvSet.values.dbUrl);
static get isTest() { static get isTest() {
return this.values.isTest; return this.values.isTest;
} }
static get dbUrl() {
return this.values.dbUrl;
}
static queryClient = createQueryClient(this.dbUrl, this.isTest);
/** @deprecated Only for backward compatibility; Will be replaced soon. */
static pool = createPool(this.dbUrl, this.isTest);
#pool: Optional<DatabasePool>; #pool: Optional<DatabasePool>;
// Use another pool for `withtyped` while adopting the new model, // Use another pool for `withtyped` while adopting the new model,
// as we cannot extract the original PgPool from slonik // as we cannot extract the original PgPool from slonik
@ -76,12 +83,11 @@ export class EnvSet {
this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest); this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest);
const { getOidcConfigs } = createLogtoConfigLibrary(pool); const { getOidcConfigs } = createLogtoConfigLibrary(pool);
const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs()]);
const oidcConfigs = await getOidcConfigs();
this.#oidc = await loadOidcValues( this.#oidc = await loadOidcValues(
appendPath(EnvSet.values.endpoint, '/oidc').toString(), appendPath(EnvSet.values.endpoint, '/oidc').toString(),
oidcConfigs oidcConfigs
); );
} }
} }
await EnvSet.default.load();

View file

@ -3,6 +3,8 @@ import dotenv from 'dotenv';
import { findUp } from 'find-up'; import { findUp } from 'find-up';
import Koa from 'koa'; import Koa from 'koa';
import { checkAlterationState } from './env-set/check-alteration-state.js';
dotenv.config({ path: await findUp('.env', {}) }); dotenv.config({ path: await findUp('.env', {}) });
// Import after env has been configured // Import after env has been configured
@ -17,7 +19,10 @@ try {
}); });
await initI18n(); await initI18n();
await loadConnectorFactories(); await loadConnectorFactories();
await checkRowLevelSecurity(EnvSet.default.queryClient); await Promise.all([
checkRowLevelSecurity(EnvSet.queryClient),
checkAlterationState(await EnvSet.pool),
]);
// Import last until init completed // Import last until init completed
const { default: initApp } = await import('./app/init.js'); const { default: initApp } = await import('./app/init.js');

View file

@ -27,7 +27,6 @@ const queryFunction = jest.fn();
const url = 'https://logto.gg'; const url = 'https://logto.gg';
const hook: InferModelType<ModelRouters['hook']['model']> = { const hook: InferModelType<ModelRouters['hook']['model']> = {
tenantId: undefined,
id: 'foo', id: 'foo',
event: HookEvent.PostSignIn, event: HookEvent.PostSignIn,
config: { headers: { bar: 'baz' }, url, retries: 3 }, config: { headers: { bar: 'baz' }, url, retries: 3 },

View file

@ -6,6 +6,7 @@ import Sinon from 'sinon';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { mockEnvSet } from '#src/test-utils/env-set.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithAuthContext } from './koa-auth.js'; import type { WithAuthContext } from './koa-auth.js';
@ -63,7 +64,7 @@ describe('koaAuth middleware', () => {
developmentUserId: 'foo', developmentUserId: 'foo',
}); });
await koaAuth(EnvSet.default)(ctx, next); await koaAuth(mockEnvSet)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore(); stub.restore();
@ -78,7 +79,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await koaAuth(EnvSet.default)(mockCtx, next); await koaAuth(mockEnvSet)(mockCtx, next);
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
}); });
@ -90,7 +91,7 @@ describe('koaAuth middleware', () => {
isIntegrationTest: true, isIntegrationTest: true,
}); });
await koaAuth(EnvSet.default)(ctx, next); await koaAuth(mockEnvSet)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore(); stub.restore();
@ -111,7 +112,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await koaAuth(EnvSet.default)(mockCtx, next); await koaAuth(mockEnvSet)(mockCtx, next);
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore(); stub.restore();
@ -124,12 +125,12 @@ describe('koaAuth middleware', () => {
authorization: 'Bearer access_token', authorization: 'Bearer access_token',
}, },
}; };
await koaAuth(EnvSet.default)(ctx, next); await koaAuth(mockEnvSet)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' }); expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
}); });
it('expect to throw if authorization header is missing', async () => { it('expect to throw if authorization header is missing', async () => {
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(authHeaderMissingError); await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
}); });
it('expect to throw if authorization header token type not recognized ', async () => { it('expect to throw if authorization header token type not recognized ', async () => {
@ -140,7 +141,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(tokenNotSupportedError); await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
}); });
it('expect to throw if jwt sub is missing', async () => { it('expect to throw if jwt sub is missing', async () => {
@ -153,7 +154,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(jwtSubMissingError); await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
}); });
it('expect to have `client` type per jwt verify result', async () => { it('expect to have `client` type per jwt verify result', async () => {
@ -166,7 +167,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await koaAuth(EnvSet.default)(ctx, next); await koaAuth(mockEnvSet)(ctx, next);
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); expect(ctx.auth).toEqual({ type: 'app', id: 'bar' });
}); });
@ -180,7 +181,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError( await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
forbiddenError forbiddenError
); );
}); });
@ -197,7 +198,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError( await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
forbiddenError forbiddenError
); );
}); });
@ -213,7 +214,7 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError( await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(
new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error')) new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error'))
); );
}); });

View file

@ -3,7 +3,7 @@ import { createMockUtils } from '@logto/shared/esm';
import snakecaseKeys from 'snakecase-keys'; import snakecaseKeys from 'snakecase-keys';
import { mockApplication } from '#src/__mocks__/index.js'; import { mockApplication } from '#src/__mocks__/index.js';
import { EnvSet } from '#src/env-set/index.js'; import { mockEnvSet } from '#src/test-utils/env-set.js';
import { MockQueries } from '#src/test-utils/tenant.js'; import { MockQueries } from '#src/test-utils/tenant.js';
import { getConstantClientMetadata } from './utils.js'; import { getConstantClientMetadata } from './utils.js';
@ -48,7 +48,7 @@ const now = Date.now();
describe('postgres Adapter', () => { describe('postgres Adapter', () => {
it('Client Modal', async () => { it('Client Modal', async () => {
const rejectError = new Error('Not implemented'); const rejectError = new Error('Not implemented');
const adapter = postgresAdapter(EnvSet.default, queries, 'Client'); const adapter = postgresAdapter(mockEnvSet, queries, 'Client');
await expect(adapter.upsert('client', {}, 0)).rejects.toMatchError(rejectError); await expect(adapter.upsert('client', {}, 0)).rejects.toMatchError(rejectError);
await expect(adapter.findByUserCode('foo')).rejects.toMatchError(rejectError); await expect(adapter.findByUserCode('foo')).rejects.toMatchError(rejectError);
@ -72,7 +72,7 @@ describe('postgres Adapter', () => {
client_id, client_id,
client_name, client_name,
client_secret, client_secret,
...getConstantClientMetadata(EnvSet.default, type), ...getConstantClientMetadata(mockEnvSet, type),
...snakecaseKeys(oidcClientMetadata), ...snakecaseKeys(oidcClientMetadata),
...customClientMetadata, ...customClientMetadata,
}); });
@ -85,7 +85,7 @@ describe('postgres Adapter', () => {
const id = 'fooId'; const id = 'fooId';
const grantId = 'grantId'; const grantId = 'grantId';
const expireAt = 60; const expireAt = 60;
const adapter = postgresAdapter(EnvSet.default, queries, modelName); const adapter = postgresAdapter(mockEnvSet, queries, modelName);
await adapter.upsert(id, { uid, userCode }, expireAt); await adapter.upsert(id, { uid, userCode }, expireAt);
expect(upsertInstance).toBeCalledWith({ expect(upsertInstance).toBeCalledWith({

View file

@ -1,4 +1,4 @@
import { EnvSet } from '#src/env-set/index.js'; import { mockEnvSet } from '#src/test-utils/env-set.js';
import { MockTenant } from '#src/test-utils/tenant.js'; import { MockTenant } from '#src/test-utils/tenant.js';
import initOidc from './init.js'; import initOidc from './init.js';
@ -7,6 +7,6 @@ describe('oidc provider init', () => {
it('init should not throw', async () => { it('init should not throw', async () => {
const { queries, libraries } = new MockTenant(); const { queries, libraries } = new MockTenant();
expect(() => initOidc(EnvSet.default, queries, libraries)).not.toThrow(); expect(() => initOidc(mockEnvSet, queries, libraries)).not.toThrow();
}); });
}); });

View file

@ -1,6 +1,6 @@
import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas'; import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas';
import { EnvSet } from '#src/env-set/index.js'; import { mockEnvSet } from '#src/test-utils/env-set.js';
import { import {
isOriginAllowed, isOriginAllowed,
@ -10,22 +10,22 @@ import {
} from './utils.js'; } from './utils.js';
describe('getConstantClientMetadata()', () => { describe('getConstantClientMetadata()', () => {
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.SPA)).toEqual({ expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toEqual({
application_type: 'web', application_type: 'web',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
token_endpoint_auth_method: 'none', token_endpoint_auth_method: 'none',
}); });
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Native)).toEqual({ expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Native)).toEqual({
application_type: 'native', application_type: 'native',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
token_endpoint_auth_method: 'none', token_endpoint_auth_method: 'none',
}); });
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Traditional)).toEqual({ expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional)).toEqual({
application_type: 'web', application_type: 'web',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
token_endpoint_auth_method: 'client_secret_basic', token_endpoint_auth_method: 'client_secret_basic',
}); });
expect(getConstantClientMetadata(EnvSet.default, ApplicationType.MachineToMachine)).toEqual({ expect(getConstantClientMetadata(mockEnvSet, ApplicationType.MachineToMachine)).toEqual({
application_type: 'web', application_type: 'web',
grant_types: [GrantType.ClientCredentials], grant_types: [GrantType.ClientCredentials],
token_endpoint_auth_method: 'client_secret_basic', token_endpoint_auth_method: 'client_secret_basic',

View file

@ -25,6 +25,10 @@ const middlewareList = [
return mock; return mock;
}); });
mockEsm('./utils.js', () => ({
getTenantDatabaseDsn: async () => 'postgres://mock.db.url',
}));
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider()); mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider());

View file

@ -28,7 +28,7 @@ import { getTenantDatabaseDsn } from './utils.js';
export default class Tenant implements TenantContext { export default class Tenant implements TenantContext {
static async create(id: string): Promise<Tenant> { static async create(id: string): Promise<Tenant> {
// Treat the default database URL as the management URL // Treat the default database URL as the management URL
const envSet = new EnvSet(await getTenantDatabaseDsn(EnvSet.default, id)); const envSet = new EnvSet(await getTenantDatabaseDsn(id));
await envSet.load(); await envSet.load();
return new Tenant(envSet, id); return new Tenant(envSet, id);

View file

@ -12,6 +12,7 @@ class TenantPool {
return tenant; return tenant;
} }
console.log('Init tenant:', tenantId);
const newTenant = await Tenant.create(tenantId); const newTenant = await Tenant.create(tenantId);
this.cache.set(tenantId, newTenant); this.cache.set(tenantId, newTenant);

View file

@ -6,20 +6,21 @@ import { identifier, sql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server'; import type { QueryClient } from '@withtyped/server';
import { parseDsn, stringifyDsn } from 'slonik'; import { parseDsn, stringifyDsn } from 'slonik';
import type { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
/** /**
* This function is to fetch the tenant password for the corresponding Postgres user. * 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. * 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) => { export const getTenantDatabaseDsn = async (tenantId: string) => {
const { queryClient, dbUrl } = EnvSet;
const { const {
tableName, tableName,
rawKeys: { id, dbUser, dbUserPassword }, rawKeys: { id, dbUser, dbUserPassword },
} = Tenants; } = Tenants;
const { rows } = await defaultEnvSet.queryClient.query(sql` const { rows } = await queryClient.query(sql`
select ${identifier(dbUser)}, ${identifier(dbUserPassword)} select ${identifier(dbUser)}, ${identifier(dbUserPassword)}
from ${identifier(tableName)} from ${identifier(tableName)}
where ${identifier(id)} = ${tenantId} where ${identifier(id)} = ${tenantId}
@ -29,14 +30,14 @@ export const getTenantDatabaseDsn = async (defaultEnvSet: EnvSet, tenantId: stri
throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`); throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`);
} }
const options = parseDsn(defaultEnvSet.databaseUrl); const options = parseDsn(dbUrl);
const username = rows[0][dbUser]; const username = rows[0][dbUser];
const password = rows[0][dbUserPassword]; const password = rows[0][dbUserPassword];
return stringifyDsn({ return stringifyDsn({
...options, ...options,
username: conditional(!username && String(username)), username: conditional(typeof username === 'string' && username),
password: conditional(!password && String(password)), password: conditional(typeof password === 'string' && password),
}); });
}; };
@ -48,9 +49,7 @@ export const checkRowLevelSecurity = async (client: QueryClient) => {
and rowsecurity=false and rowsecurity=false
`); `);
if ( if (rows.some(({ tablename }) => tablename !== Systems.table)) {
rows.some(({ tablename }) => tablename !== Systems.table && tablename !== Tenants.tableName)
) {
throw new Error( throw new Error(
'Row-level security has to be enforced on EVERY business table when starting Logto.\n' + 'Row-level security has to be enforced on EVERY business table when starting Logto.\n' +
`Found following table(s) without RLS: ${rows `Found following table(s) without RLS: ${rows

View file

@ -0,0 +1,5 @@
import { EnvSet } from '#src/env-set/index.js';
export const mockEnvSet = new EnvSet(EnvSet.values.dbUrl);
await mockEnvSet.load();

View file

@ -1,11 +1,11 @@
import { createMockPool, createMockQueryResult } from 'slonik'; import { createMockPool, createMockQueryResult } from 'slonik';
import { EnvSet } from '#src/env-set/index.js';
import { createModelRouters } from '#src/model-routers/index.js'; import { createModelRouters } from '#src/model-routers/index.js';
import Libraries from '#src/tenants/Libraries.js'; import Libraries from '#src/tenants/Libraries.js';
import Queries from '#src/tenants/Queries.js'; import Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import { mockEnvSet } from './env-set.js';
import type { GrantMock } from './oidc-provider.js'; import type { GrantMock } from './oidc-provider.js';
import { createMockProvider } from './oidc-provider.js'; import { createMockProvider } from './oidc-provider.js';
import { MockQueryClient } from './query-client.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 type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
export class MockTenant implements TenantContext { export class MockTenant implements TenantContext {
public envSet = EnvSet.default; public envSet = mockEnvSet;
public queries: Queries; public queries: Queries;
public libraries: Libraries; public libraries: Libraries;
public modelRouters = createModelRouters(new MockQueryClient()); public modelRouters = createModelRouters(new MockQueryClient());

View file

@ -53,6 +53,6 @@
}, },
"prettier": "@silverhand/eslint-config/.prettierrc", "prettier": "@silverhand/eslint-config/.prettierrc",
"dependencies": { "dependencies": {
"@withtyped/server": "^0.4.1" "@withtyped/server": "^0.5.1"
} }
} }

View file

@ -44,15 +44,34 @@ const alteration: AlterationScript = {
await pool.query(sql` await pool.query(sql`
alter table hooks alter table hooks
add column tenant_id varchar(21) not null default 'default' add column tenant_id varchar(21) not null default 'default'
references tenants (id) on update cascade on delete cascade; references tenants (id) on update cascade on delete cascade,
alter column id type varchar(21); -- OK to downsize since we use length 21 for ID generation in core
alter table hooks alter table hooks
alter column tenant_id drop default; alter column tenant_id drop default;
create index hooks__id on hooks (tenant_id, id); create index hooks__id on hooks (tenant_id, id);
drop index hooks__event;
create index hooks__event on hooks (tenant_id, event);
create trigger set_tenant_id before insert on hooks
for each row execute procedure set_tenant_id();
`);
// Add db_user column to tenants table
await pool.query(sql`
alter table tenants
add column db_user varchar(128),
add constraint tenants__db_user
unique (db_user);
`); `);
// Create role and setup privileges // Create role and setup privileges
const baseRole = `logto_tenant_${database}`; const baseRole = `logto_tenant_${database}`;
const baseRoleId = getId(baseRole); const baseRoleId = getId(baseRole);
// See `_after_all.sql` for comments
await pool.query(sql` await pool.query(sql`
create role ${baseRoleId} noinherit; create role ${baseRoleId} noinherit;
@ -65,19 +84,21 @@ const alteration: AlterationScript = {
on table tenants on table tenants
from ${baseRoleId}; from ${baseRoleId};
grant select (id, db_user)
on table tenants
to ${baseRoleId};
alter table tenants enable row level security;
create policy tenants_tenant_id on tenants
to ${baseRoleId}
using (db_user = current_user);
revoke all privileges revoke all privileges
on table systems on table systems
from ${baseRoleId}; from ${baseRoleId};
`); `);
// Add db_user column to tenants table
await pool.query(sql`
alter table tenants
add column db_user varchar(128),
add constraint tenants__db_user
unique (db_user);
`);
// Enable RLS // Enable RLS
await Promise.all( await Promise.all(
tables.map(async (tableName) => tables.map(async (tableName) =>
@ -130,6 +151,9 @@ const alteration: AlterationScript = {
in schema public in schema public
from ${baseRoleId}; from ${baseRoleId};
drop policy tenants_tenant_id on tenants;
alter table tenants disable row level security;
drop role ${baseRoleId}; drop role ${baseRoleId};
`); `);
@ -139,13 +163,17 @@ const alteration: AlterationScript = {
drop column db_user; drop column db_user;
`); `);
console.log('3');
// Revert hooks table from multi-tenancy // Revert hooks table from multi-tenancy
await pool.query(sql` await pool.query(sql`
drop index hooks__id; drop index hooks__id;
alter table hooks alter table hooks
drop column tenant_id; drop column tenant_id,
alter column id type varchar(32);
create index hooks__event on hooks (event);
drop trigger set_tenant_id on hooks;
`); `);
}, },
}; };

View file

@ -83,7 +83,7 @@
"@logto/language-kit": "workspace:*", "@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*", "@logto/phrases": "workspace:*",
"@logto/phrases-ui": "workspace:*", "@logto/phrases-ui": "workspace:*",
"@withtyped/server": "^0.4.1", "@withtyped/server": "^0.5.1",
"zod": "^3.20.2" "zod": "^3.20.2"
} }
} }

View file

@ -58,7 +58,7 @@ export const Hooks = createModel(/* sql */ `
create index hooks__event on hooks (tenant_id, event); create index hooks__event on hooks (tenant_id, event);
`) `)
.extend('tenantId', z.string().optional())
.extend('id', { default: () => generateStandardId(), readonly: true }) .extend('id', { default: () => generateStandardId(), readonly: true })
.extend('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working. .extend('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working.
.extend('config', hookConfigGuard); .extend('config', hookConfigGuard)
.exclude('tenantId');

View file

@ -10,6 +10,5 @@ export const Tenants = createModel(/* sql */ `
constraint tenants__db_user constraint tenants__db_user
unique (db_user) unique (db_user)
); );
/* no_after_each */ /* no_after_each */
`); `);

View file

@ -5,10 +5,27 @@ grant select, insert, update, delete
in schema public in schema public
to logto_tenant_${database}; to logto_tenant_${database};
-- Security policies for tenants table --
revoke all privileges revoke all privileges
on table tenants on table tenants
from logto_tenant_${database}; from logto_tenant_${database};
/* Allow limited select to perform RLS query in `after_each` (using select ... from tenants ...) */
grant select (id, db_user)
on table tenants
to logto_tenant_${database};
alter table tenants enable row level security;
/* Create RLS policy to minimize the privilege */
create policy tenants_tenant_id on tenants
to logto_tenant_${database}
using (db_user = current_user);
-- End --
/* Revoke all privileges on systems table for tenant roles */
revoke all privileges revoke all privileges
on table systems on table systems
from logto_tenant_${database}; from logto_tenant_${database};

View file

@ -276,8 +276,8 @@ importers:
'@types/semver': ^7.3.12 '@types/semver': ^7.3.12
'@types/sinon': ^10.0.13 '@types/sinon': ^10.0.13
'@types/supertest': ^2.0.11 '@types/supertest': ^2.0.11
'@withtyped/postgres': ^0.4.1 '@withtyped/postgres': ^0.5.1
'@withtyped/server': ^0.4.1 '@withtyped/server': ^0.5.1
chalk: ^5.0.0 chalk: ^5.0.0
clean-deep: ^3.4.0 clean-deep: ^3.4.0
copyfiles: ^2.4.1 copyfiles: ^2.4.1
@ -335,8 +335,8 @@ importers:
'@logto/schemas': link:../schemas '@logto/schemas': link:../schemas
'@logto/shared': link:../shared '@logto/shared': link:../shared
'@silverhand/essentials': 2.1.0 '@silverhand/essentials': 2.1.0
'@withtyped/postgres': 0.4.1_@withtyped+server@0.4.1 '@withtyped/postgres': 0.5.1_@withtyped+server@0.5.1
'@withtyped/server': 0.4.1 '@withtyped/server': 0.5.1
chalk: 5.1.2 chalk: 5.1.2
clean-deep: 3.4.0 clean-deep: 3.4.0
date-fns: 2.29.3 date-fns: 2.29.3
@ -481,7 +481,7 @@ importers:
'@types/jest': ^29.1.2 '@types/jest': ^29.1.2
'@types/jest-environment-puppeteer': ^5.0.2 '@types/jest-environment-puppeteer': ^5.0.2
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@withtyped/server': ^0.4.1 '@withtyped/server': ^0.5.1
dotenv: ^16.0.0 dotenv: ^16.0.0
eslint: ^8.21.0 eslint: ^8.21.0
got: ^12.5.3 got: ^12.5.3
@ -495,7 +495,7 @@ importers:
text-encoder: ^0.0.4 text-encoder: ^0.0.4
typescript: ^4.9.4 typescript: ^4.9.4
dependencies: dependencies:
'@withtyped/server': 0.4.1 '@withtyped/server': 0.5.1
devDependencies: devDependencies:
'@jest/types': 29.1.2 '@jest/types': 29.1.2
'@logto/connector-kit': link:../toolkit/connector-kit '@logto/connector-kit': link:../toolkit/connector-kit
@ -585,7 +585,7 @@ importers:
'@types/jest': ^29.1.2 '@types/jest': ^29.1.2
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@types/pluralize': ^0.0.29 '@types/pluralize': ^0.0.29
'@withtyped/server': ^0.4.1 '@withtyped/server': ^0.5.1
camelcase: ^7.0.0 camelcase: ^7.0.0
eslint: ^8.21.0 eslint: ^8.21.0
jest: ^29.1.2 jest: ^29.1.2
@ -603,7 +603,7 @@ importers:
'@logto/language-kit': link:../toolkit/language-kit '@logto/language-kit': link:../toolkit/language-kit
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui '@logto/phrases-ui': link:../phrases-ui
'@withtyped/server': 0.4.1 '@withtyped/server': 0.5.1
zod: 3.20.2 zod: 3.20.2
devDependencies: devDependencies:
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4 '@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
@ -4463,21 +4463,21 @@ packages:
eslint-visitor-keys: 3.3.0 eslint-visitor-keys: 3.3.0
dev: true dev: true
/@withtyped/postgres/0.4.1_@withtyped+server@0.4.1: /@withtyped/postgres/0.5.1_@withtyped+server@0.5.1:
resolution: {integrity: sha512-UZtwUieJyj3tHxGgiskBPFefpkFZgv7yzlXFMmuyG2NNu6P8mFnCSp4vuycmGEJnYRLFrxDvLhjx1qpVlD3k6A==} resolution: {integrity: sha512-Le4iIHEc4LRgDn4rjnwbGJ/J15PpqEoltgoZAOhYgnZznKBzkp4W3vxbav29x7IMOvzgum+Jo5HOW1q0kRfROg==}
peerDependencies: peerDependencies:
'@withtyped/server': ^0.4.1 '@withtyped/server': ^0.5.1
dependencies: dependencies:
'@types/pg': 8.6.6 '@types/pg': 8.6.6
'@withtyped/server': 0.4.1 '@withtyped/server': 0.5.1
'@withtyped/shared': 0.2.0 '@withtyped/shared': 0.2.0
pg: 8.8.0 pg: 8.8.0
transitivePeerDependencies: transitivePeerDependencies:
- pg-native - pg-native
dev: false dev: false
/@withtyped/server/0.4.1: /@withtyped/server/0.5.1:
resolution: {integrity: sha512-QMhFntmF2o/e9dEJi84+RL+BySzV6+uayY1dL372OmYCcYftPUYYCctvpTaz5eWUipqV/hL4FaEpK1YVEKYPHQ==} resolution: {integrity: sha512-CR7Y4R2YsUNJ7STEzhJjBjCKIJg49r2Jun5tFuTmmH8IAdHacisWPuKyGMz8o8jnatGTBRJNvc2wjjhg0l8ptw==}
dependencies: dependencies:
'@withtyped/shared': 0.2.0 '@withtyped/shared': 0.2.0
dev: false dev: false