From 7a7d7f9f41ca25608fd6f64053c77100e7878bee Mon Sep 17 00:00:00 2001 From: Gao Sun <gao@silverhand.io> Date: Sun, 12 Feb 2023 18:43:02 +0800 Subject: [PATCH] refactor: add alteration scripts --- .../cli/src/commands/database/utilities.ts | 4 +- packages/core/src/routes-me/init.ts | 2 - .../next-1676115897-add-admin-tenant.ts | 19 -- .../next-1676185899-fix-logs-index.ts | 32 +++ .../next-1676190092-migrate-admin-data.ts | 208 ++++++++++++++++++ packages/schemas/package.json | 1 + packages/schemas/tables/logs.sql | 4 +- packages/schemas/tables/passcodes.sql | 2 +- pnpm-lock.yaml | 10 +- 9 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 packages/schemas/alterations/next-1676185899-fix-logs-index.ts create mode 100644 packages/schemas/alterations/next-1676190092-migrate-admin-data.ts diff --git a/packages/cli/src/commands/database/utilities.ts b/packages/cli/src/commands/database/utilities.ts index 9dd689991..86e6e0873 100644 --- a/packages/cli/src/commands/database/utilities.ts +++ b/packages/cli/src/commands/database/utilities.ts @@ -1,7 +1,7 @@ import { generateKeyPair } from 'crypto'; import { promisify } from 'util'; -import { nanoid } from 'nanoid'; +import { generateStandardId } from '@logto/core-kit'; export const generateOidcPrivateKey = async (type: 'rsa' | 'ec' = 'ec') => { if (type === 'rsa') { @@ -41,4 +41,4 @@ export const generateOidcPrivateKey = async (type: 'rsa' | 'ec' = 'ec') => { throw new Error(`Unsupported private key ${String(type)}`); }; -export const generateOidcCookieKey = () => nanoid(); +export const generateOidcCookieKey = () => generateStandardId(); diff --git a/packages/core/src/routes-me/init.ts b/packages/core/src/routes-me/init.ts index fa26ef050..a20b00e38 100644 --- a/packages/core/src/routes-me/init.ts +++ b/packages/core/src/routes-me/init.ts @@ -21,8 +21,6 @@ export default function initMeApis(tenant: TenantContext): Koa { const { findUserById, updateUserById } = tenant.queries.users; const meRouter = new Router<unknown, WithAuthContext>(); - console.log('????', getManagementApiResourceIndicator(adminTenantId, 'me')); - meRouter.use( koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me')), async (ctx, next) => { diff --git a/packages/schemas/alterations/next-1676115897-add-admin-tenant.ts b/packages/schemas/alterations/next-1676115897-add-admin-tenant.ts index e56187c78..03d5278bf 100644 --- a/packages/schemas/alterations/next-1676115897-add-admin-tenant.ts +++ b/packages/schemas/alterations/next-1676115897-add-admin-tenant.ts @@ -5,25 +5,6 @@ import { raw } from 'slonik-sql-tag-raw'; import type { AlterationScript } from '../lib/types/alteration.js'; -const tables: string[] = [ - 'applications_roles', - 'applications', - 'connectors', - 'custom_phrases', - 'logs', - 'logto_configs', - 'oidc_model_instances', - 'passcodes', - 'resources', - 'roles_scopes', - 'roles', - 'scopes', - 'sign_in_experiences', - 'users_roles', - 'users', - 'hooks', -]; - const adminTenantId = 'admin'; const getId = (value: string) => sql.identifier([value]); diff --git a/packages/schemas/alterations/next-1676185899-fix-logs-index.ts b/packages/schemas/alterations/next-1676185899-fix-logs-index.ts new file mode 100644 index 000000000..68bbf4c4e --- /dev/null +++ b/packages/schemas/alterations/next-1676185899-fix-logs-index.ts @@ -0,0 +1,32 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + drop index logs__user_id; + drop index logs__application_id; + + create index logs__user_id + on logs (tenant_id, (payload->>'userId')); + + create index logs__application_id + on logs (tenant_id, (payload->>'applicationId')); + `); + }, + down: async (pool) => { + await pool.query(sql` + drop index logs__user_id; + drop index logs__application_id; + + create index logs__user_id + on logs (tenant_id, (payload->>'user_id') nulls last); + + create index logs__application_id + on logs (tenant_id, (payload->>'application_id') nulls last); + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/alterations/next-1676190092-migrate-admin-data.ts b/packages/schemas/alterations/next-1676190092-migrate-admin-data.ts new file mode 100644 index 000000000..579d7d6ab --- /dev/null +++ b/packages/schemas/alterations/next-1676190092-migrate-admin-data.ts @@ -0,0 +1,208 @@ +import { generateKeyPair } from 'crypto'; +import { promisify } from 'util'; + +import { generateStandardId } from '@logto/core-kit'; +import inquirer from 'inquirer'; +import type { CommonQueryMethods, SerializableValue } from 'slonik'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +// Copied from CLI with default execution path +const generateOidcPrivateKey = async () => { + const { privateKey } = await promisify(generateKeyPair)('ec', { + // https://security.stackexchange.com/questions/78621/which-elliptic-curve-should-i-use + namedCurve: 'secp384r1', + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + return privateKey; +}; + +const generateOidcCookieKey = () => generateStandardId(); + +// Edited from CLI +const updateConfigByKey = async <T>( + pool: CommonQueryMethods, + tenantId: string, + key: string, + value: SerializableValue +) => + pool.query( + sql` + insert into logto_configs (tenant_id, key, value) + values (${tenantId}, ${key}, ${sql.jsonb(value)}) + ` + ); + +const adminTenantId = 'admin'; +const defaultTenantId = 'default'; + +const alteration: AlterationScript = { + up: async (pool) => { + // Skipped tables: + // applications_roles, applications, connectors, custom_phrases, logto_configs, + // passcodes, resources, roles_scopes, roles, scopes, sign_in_experiences, + // systems, users_roles, hooks, tenants + // + // Migrate: logs, oidc_model_instances, users + + // Find admin users + const { rows } = await pool.query<{ userId: string; count: number }>(sql` + select + users.id as "userId", + (select count(*) from users_roles where user_id = user_id) + from users + inner join users_roles on users.id = users_roles.user_id + inner join roles on roles.id = users_roles.role_id + where roles.name = 'admin'; + `); + + const invalidUsers = rows.filter(({ count }) => count > 1); + + if (invalidUsers.length > 0) { + throw new Error( + 'Some of your current admin users has extra roles. Either remove their `admin` role to become a normal user, or remove all other roles to migrate them to the new Admin Tenant.\n\n' + + 'Invalid user IDs: ' + + invalidUsers.map(({ userId }) => userId).join(', ') + ); + } + + const userIds = rows.map(({ userId }) => userId); + + if (userIds.length === 0) { + console.log('No admin user found, skip alteration'); + + return; + } + + const inUserIds = sql`in (${sql.join(userIds, sql`, `)})`; + + // Remove the admin role from users_roles + await pool.query(sql` + delete from users_roles + where user_id ${inUserIds}; + `); + + // Update data + console.warn( + 'Some of the logs will stay in the default tenant since the related interaction has been removed.' + ); + + await pool.query(sql` + update users + set tenant_id = ${adminTenantId} + where id ${inUserIds}; + `); + await pool.query(sql` + update logs + set tenant_id = ${adminTenantId} + where payload->>'userId' ${inUserIds}; + `); + await pool.query(sql` + update oidc_model_instances + set tenant_id = ${adminTenantId} + where payload->>'accountId' ${inUserIds}; + `); + + // Assign roles + const { rows: roles } = await pool.query<{ id: string }>(sql` + select id from roles + where tenant_id = ${adminTenantId} + and (name = ${'default:admin'} or name = ${'user'}) + `); + + if (roles.length !== 2) { + throw new Error('Admin tenant should have both `default:admin` and `user` role.'); + } + + await pool.query(sql` + insert into users_roles (tenant_id, id, user_id, role_id) + values ${sql.join( + userIds.flatMap((userId) => + roles.map( + ({ id }) => sql`(${adminTenantId}, ${generateStandardId()}, ${userId}, ${id})` + ) + ), + sql`,` + )}; + `); + + // Init admin OIDC configs + await updateConfigByKey(pool, adminTenantId, 'oidc.privateKeys', [ + await generateOidcPrivateKey(), + ]); + await updateConfigByKey(pool, adminTenantId, 'oidc.cookieKeys', [generateOidcCookieKey()]); + }, + down: async (pool) => { + const { rows } = await pool.query<{ id: string }>(sql`select id from tenants;`); + const tenantIds = rows + .map(({ id }) => id) + .slice() + .sort((i, j) => i.localeCompare(j)); + + if (!(tenantIds.length === 2 && tenantIds[0] === 'admin' && tenantIds[1] === 'default')) { + throw new Error('The tenants table should only have exact `admin` and `default` tenant.'); + } + + const isCi = process.env.CI; + const { confirm } = await inquirer.prompt<{ confirm: boolean }>({ + type: 'confirm', + name: 'confirm', + message: String( + '***CAUTION***\n' + + 'The `down()` function will restore Admin Tenant users to the default tenant.\n' + + 'Except `users`, and `logs`, ALL other data will be dropped.\n' + + 'Are you sure to continue?' + ), + default: false, + when: !isCi, + }); + + if (!isCi && !confirm) { + throw new Error('User cancelled alteration.'); + } + + const { rows: adminUsers } = await pool.query<{ id: string }>(sql` + select users.id from users + inner join users_roles on users.id = users_roles.user_id + inner join roles on roles.id = users_roles.role_id + where roles.name = 'default:admin' + and users.tenant_id = 'admin'; + `); + const adminUserIds = adminUsers.map(({ id }) => id); + + if (adminUserIds.length > 0) { + await pool.query(sql` + insert into users_roles (tenant_id, id, user_id, role_id) + values ${sql.join( + adminUserIds.map( + (id) => sql`(${defaultTenantId}, ${generateStandardId()}, ${id}, ${'admin-role'})` + ), + sql`,` + )}; + `); + + console.log(`Converted admin role for user ID(s): ${adminUserIds.join(', ')}`); + } + + await pool.query(sql` + update users set tenant_id = ${defaultTenantId}; + `); + await pool.query(sql` + update logs set tenant_id = ${defaultTenantId}; + `); + await pool.query(sql` + delete from tenants where id = ${adminTenantId}; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 3dea0b0fd..02ebae63c 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -43,6 +43,7 @@ "@silverhand/eslint-config": "1.3.0", "@silverhand/essentials": "2.1.0", "@silverhand/ts-config": "1.2.1", + "@types/inquirer": "^9.0.0", "@types/jest": "^29.1.2", "@types/node": "^18.11.18", "@types/pluralize": "^0.0.29", diff --git a/packages/schemas/tables/logs.sql b/packages/schemas/tables/logs.sql index 6eb38f606..1d6b75220 100644 --- a/packages/schemas/tables/logs.sql +++ b/packages/schemas/tables/logs.sql @@ -15,7 +15,7 @@ create index logs__key on logs (tenant_id, key); create index logs__user_id - on logs (tenant_id, (payload->>'user_id') nulls last); + on logs (tenant_id, (payload->>'userId')); create index logs__application_id - on logs (tenant_id, (payload->>'application_id') nulls last); + on logs (tenant_id, (payload->>'applicationId')); diff --git a/packages/schemas/tables/passcodes.sql b/packages/schemas/tables/passcodes.sql index a8639b10b..a5d6be42e 100644 --- a/packages/schemas/tables/passcodes.sql +++ b/packages/schemas/tables/passcodes.sql @@ -7,7 +7,7 @@ create table passcodes ( email varchar(128), type varchar(32) not null, code varchar(6) not null, - consumed boolean not null default FALSE, + consumed boolean not null default false, try_count int2 not null default 0, created_at timestamptz not null default(now()), primary key (id) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c179a3f1..659baf862 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -586,6 +586,7 @@ importers: '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': 2.1.0 '@silverhand/ts-config': 1.2.1 + '@types/inquirer': ^9.0.0 '@types/jest': ^29.1.2 '@types/node': ^18.11.18 '@types/pluralize': ^0.0.29 @@ -613,6 +614,7 @@ importers: '@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4 '@silverhand/essentials': 2.1.0 '@silverhand/ts-config': 1.2.1_typescript@4.9.4 + '@types/inquirer': 9.0.3 '@types/jest': 29.1.2 '@types/node': 18.11.18 '@types/pluralize': 0.0.29 @@ -4002,7 +4004,7 @@ packages: resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==} dependencies: '@types/through': 0.0.30 - rxjs: 7.5.5 + rxjs: 7.8.0 dev: true /@types/is-ci/3.0.0: @@ -12927,12 +12929,6 @@ packages: queue-microtask: 1.2.3 dev: true - /rxjs/7.5.5: - resolution: {integrity: sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==} - dependencies: - tslib: 2.4.1 - dev: true - /rxjs/7.8.0: resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==} dependencies: