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: