From 4afdf3cb4c868cc85ba1d6b155165515a431d771 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 19 Aug 2022 16:53:19 +0800 Subject: [PATCH] feat(core): set user default roles from env (#1793) --- packages/core/src/env-set/index.ts | 4 +- packages/core/src/lib/user.ts | 32 +++++++++++++++- packages/core/src/queries/roles.ts | 9 +++++ packages/core/src/queries/user.test.ts | 28 -------------- packages/core/src/queries/user.ts | 4 -- packages/core/src/routes/admin-user.test.ts | 12 +++--- packages/core/src/routes/admin-user.ts | 3 +- .../src/routes/session/passwordless.test.ts | 13 ++++--- .../core/src/routes/session/passwordless.ts | 3 +- .../core/src/routes/session/session.test.ts | 37 ++++++++++--------- packages/core/src/routes/session/session.ts | 3 +- .../core/src/routes/session/social.test.ts | 13 ++++--- packages/core/src/routes/session/social.ts | 3 +- 13 files changed, 88 insertions(+), 76 deletions(-) diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 00125f838..f560beb42 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -23,7 +23,6 @@ const loadEnvValues = async () => { const port = Number(getEnv('PORT', '3001')); const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`; const endpoint = getEnv('ENDPOINT', localhostUrl); - const additionalConnectorPackages = getEnvAsStringArray('ADDITIONAL_CONNECTOR_PACKAGES', []); return Object.freeze({ isTest, @@ -35,7 +34,8 @@ const loadEnvValues = async () => { port, localhostUrl, endpoint, - additionalConnectorPackages, + additionalConnectorPackages: getEnvAsStringArray('ADDITIONAL_CONNECTOR_PACKAGES'), + userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'), developmentUserId: getEnv('DEVELOPMENT_USER_ID'), trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')), oidc: await loadOidcValues(appendPath(endpoint, '/oidc').toString()), diff --git a/packages/core/src/lib/user.ts b/packages/core/src/lib/user.ts index 517e76047..ea79c1d9e 100644 --- a/packages/core/src/lib/user.ts +++ b/packages/core/src/lib/user.ts @@ -1,7 +1,10 @@ -import { User, UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { User, CreateUser, Users, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { argon2Verify } from 'hash-wasm'; import pRetry from 'p-retry'; +import { buildInsertInto } from '@/database/insert-into'; +import envSet from '@/env-set'; +import { findRolesByRoleNames, insertRoles } from '@/queries/roles'; import { findUserByUsername, hasUserWithId, updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { buildIdGenerator } from '@/utils/id'; @@ -58,3 +61,30 @@ export const findUserByUsernameAndPassword = async ( export const updateLastSignInAt = async (userId: string) => updateUserById(userId, { lastSignInAt: Date.now() }); + +const insertUserQuery = buildInsertInto(Users, { + returning: true, +}); + +// Temp solution since Hasura requires a role to proceed authn. +// The source of default roles should be guarded and moved to database once we implement RBAC. +export const insertUser: typeof insertUserQuery = async ({ roleNames, ...rest }) => { + const computedRoleNames = [ + ...new Set((roleNames ?? []).concat(envSet.values.userDefaultRoleNames)), + ]; + + if (computedRoleNames.length > 0) { + const existingRoles = await findRolesByRoleNames(computedRoleNames); + const missingRoleNames = computedRoleNames.filter( + (roleName) => !existingRoles.some(({ name }) => roleName === name) + ); + + if (missingRoleNames.length > 0) { + await insertRoles( + missingRoleNames.map((name) => ({ name, description: 'User default role.' })) + ); + } + } + + return insertUserQuery({ roleNames: computedRoleNames, ...rest }); +}; diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 81d844ae4..535861411 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -18,3 +18,12 @@ export const findRolesByRoleNames = async (roleNames: string[]) => from ${table} where ${fields.name} in (${sql.join(roleNames, sql`, `)}) `); + +export const insertRoles = async (roles: Role[]) => + envSet.pool.query(sql` + insert into ${table} (${fields.name}, ${fields.description}) values + ${sql.join( + roles.map(({ name, description }) => sql`(${name}, ${description})`), + sql`, ` + )} + `); diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index e63303b11..a153bdc26 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -18,7 +18,6 @@ import { hasUserWithEmail, hasUserWithIdentity, hasUserWithPhone, - insertUser, countUsers, findUsers, updateUserById, @@ -235,33 +234,6 @@ describe('user query', () => { await expect(hasUserWithIdentity(target, mockUser.id)).resolves.toEqual(true); }); - it('insertUser', async () => { - const expectSql = sql` - insert into ${table} (${sql.join(Object.values(fields), sql`, `)}) - values (${sql.join( - Object.values(fields) - .slice(0, -1) - .map((_, index) => `$${index + 1}`), - sql`, ` - )}, to_timestamp(${Object.values(fields).length}::double precision / 1000)) - returning * - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - - expect(values).toEqual( - Users.fieldKeys.map((k) => - k === 'lastSignInAt' ? mockUser[k] : convertToPrimitiveOrSql(k, mockUser[k]) - ) - ); - - return createMockQueryResult([dbvalue]); - }); - - await expect(insertUser(mockUser)).resolves.toEqual(dbvalue); - }); - it('countUsers', async () => { const search = 'foo'; const expectSql = sql` diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 4a24b5ea7..34a065e90 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -83,10 +83,6 @@ export const hasUserWithIdentity = async (target: string, userId: string) => ` ); -export const insertUser = buildInsertInto(Users, { - returning: true, -}); - const buildUserSearchConditionSql = (search: string) => { const searchFields = [fields.primaryEmail, fields.primaryPhone, fields.username, fields.name]; const conditions = searchFields.map((filedName) => sql`${filedName} like ${'%' + search + '%'}`); diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 7f95c2919..fc9bade1a 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -39,12 +39,6 @@ jest.mock('@/queries/user', () => ({ }) ), deleteUserById: jest.fn(), - insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...user, - }) - ), deleteUserIdentity: jest.fn(), })); @@ -54,6 +48,12 @@ jest.mock('@/lib/user', () => ({ passwordEncrypted: 'password', passwordEncryptionMethod: 'Argon2i', })), + insertUser: jest.fn( + async (user: CreateUser): Promise => ({ + ...mockUser, + ...user, + }) + ), })); jest.mock('@/queries/roles', () => ({ diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 46f2a586b..adaa2d8ae 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -5,7 +5,7 @@ import pick from 'lodash.pick'; import { literal, object, string } from 'zod'; import RequestError from '@/errors/RequestError'; -import { encryptUserPassword, generateUserId } from '@/lib/user'; +import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import koaPagination from '@/middleware/koa-pagination'; import { findRolesByRoleNames } from '@/queries/roles'; @@ -16,7 +16,6 @@ import { countUsers, findUserById, hasUser, - insertUser, updateUserById, } from '@/queries/user'; import assertThat from '@/utils/assert-that'; diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index c0da0e386..d210f0b46 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -7,23 +7,26 @@ import { createRequester } from '@/utils/test-utils'; import sessionPasswordlessRoutes from './passwordless'; -jest.mock('@/lib/user', () => ({ - generateUserId: () => 'user1', - updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), -})); const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); + +jest.mock('@/lib/user', () => ({ + generateUserId: () => 'user1', + updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), + insertUser: async (...args: unknown[]) => insertUser(...args), +})); + jest.mock('@/queries/user', () => ({ findUserById: async () => findUserById(), findUserByPhone: async () => ({ id: 'id' }), findUserByEmail: async () => ({ id: 'id' }), - insertUser: async (...args: unknown[]) => insertUser(...args), updateUserById: async (...args: unknown[]) => updateUserById(...args), hasUser: async (username: string) => username === 'username1', hasUserWithPhone: async (phone: string) => phone === '13000000000', hasUserWithEmail: async (email: string) => email === 'a@a.com', })); + const sendPasscode = jest.fn(async () => ({ connector: { id: 'connectorIdValue' } })); jest.mock('@/lib/passcode', () => ({ createPasscode: async () => ({ id: 'id' }), diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index d46b00f63..def941c07 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -6,12 +6,11 @@ import { object, string } from 'zod'; import RequestError from '@/errors/RequestError'; import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode'; import { assignInteractionResults } from '@/lib/session'; -import { generateUserId, updateLastSignInAt } from '@/lib/user'; +import { generateUserId, insertUser, updateLastSignInAt } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { hasUserWithEmail, hasUserWithPhone, - insertUser, findUserByEmail, findUserByPhone, } from '@/queries/user'; diff --git a/packages/core/src/routes/session/session.test.ts b/packages/core/src/routes/session/session.test.ts index 782872678..ba6da5418 100644 --- a/packages/core/src/routes/session/session.test.ts +++ b/packages/core/src/routes/session/session.test.ts @@ -8,6 +8,25 @@ import { createRequester } from '@/utils/test-utils'; import sessionRoutes from './session'; +const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +const findUserById = jest.fn(async (): Promise => mockUser); +const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +const hasActiveUsers = jest.fn(async () => true); + +jest.mock('@/queries/user', () => ({ + findUserById: async () => findUserById(), + findUserByIdentity: async () => ({ id: 'id', identities: {} }), + findUserByPhone: async () => ({ id: 'id' }), + findUserByEmail: async () => ({ id: 'id' }), + updateUserById: async (...args: unknown[]) => updateUserById(...args), + hasUser: async (username: string) => username === 'username1', + hasUserWithIdentity: async (connectorId: string, userId: string) => + connectorId === 'connectorId' && userId === 'id', + hasUserWithPhone: async (phone: string) => phone === '13000000000', + hasUserWithEmail: async (email: string) => email === 'a@a.com', + hasActiveUsers: async () => hasActiveUsers(), +})); + jest.mock('@/lib/user', () => ({ async findUserByUsernameAndPassword(username: string, password: string) { if (username !== 'username' && username !== 'admin') { @@ -28,25 +47,7 @@ jest.mock('@/lib/user', () => ({ passwordEncryptionMethod: 'Argon2i', }), updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), -})); -const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -const hasActiveUsers = jest.fn(async () => true); - -jest.mock('@/queries/user', () => ({ - findUserById: async () => findUserById(), - findUserByIdentity: async () => ({ id: 'id', identities: {} }), - findUserByPhone: async () => ({ id: 'id' }), - findUserByEmail: async () => ({ id: 'id' }), insertUser: async (...args: unknown[]) => insertUser(...args), - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUser: async (username: string) => username === 'username1', - hasUserWithIdentity: async (connectorId: string, userId: string) => - connectorId === 'connectorId' && userId === 'id', - hasUserWithPhone: async (phone: string) => phone === '13000000000', - hasUserWithEmail: async (email: string) => email === 'a@a.com', - hasActiveUsers: async () => hasActiveUsers(), })); const grantSave = jest.fn(async () => 'finalGrantId'); diff --git a/packages/core/src/routes/session/session.ts b/packages/core/src/routes/session/session.ts index a036f317d..d38bbf823 100644 --- a/packages/core/src/routes/session/session.ts +++ b/packages/core/src/routes/session/session.ts @@ -15,9 +15,10 @@ import { generateUserId, findUserByUsernameAndPassword, updateLastSignInAt, + insertUser, } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; -import { hasUser, insertUser, hasActiveUsers } from '@/queries/user'; +import { hasUser, hasActiveUsers } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; diff --git a/packages/core/src/routes/session/social.test.ts b/packages/core/src/routes/session/social.test.ts index 338f2ac8a..d470a9b6d 100644 --- a/packages/core/src/routes/session/social.test.ts +++ b/packages/core/src/routes/session/social.test.ts @@ -9,10 +9,6 @@ import { createRequester } from '@/utils/test-utils'; import sessionSocialRoutes from './social'; -jest.mock('@/lib/user', () => ({ - generateUserId: () => 'user1', - updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), -})); jest.mock('@/lib/social', () => ({ ...jest.requireActual('@/lib/social'), async findSocialRelatedUser() { @@ -39,14 +35,21 @@ jest.mock('@/lib/social', () => ({ const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); + jest.mock('@/queries/user', () => ({ findUserById: async () => findUserById(), findUserByIdentity: async () => ({ id: 'id', identities: {} }), - insertUser: async (...args: unknown[]) => insertUser(...args), updateUserById: async (...args: unknown[]) => updateUserById(...args), hasUserWithIdentity: async (target: string, userId: string) => target === 'connectorTarget' && userId === 'id', })); + +jest.mock('@/lib/user', () => ({ + generateUserId: () => 'user1', + updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), + insertUser: async (...args: unknown[]) => insertUser(...args), +})); + const getConnectorInstanceByIdHelper = jest.fn(async (connectorId: string) => { const connector = { enabled: connectorId === 'social_enabled', diff --git a/packages/core/src/routes/session/social.ts b/packages/core/src/routes/session/social.ts index 96495c216..f0da63ed8 100644 --- a/packages/core/src/routes/session/social.ts +++ b/packages/core/src/routes/session/social.ts @@ -12,11 +12,10 @@ import { getUserInfoByAuthCode, getUserInfoFromInteractionResult, } from '@/lib/social'; -import { generateUserId, updateLastSignInAt } from '@/lib/user'; +import { generateUserId, insertUser, updateLastSignInAt } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { hasUserWithIdentity, - insertUser, findUserById, updateUserById, findUserByIdentity,