Skip to content

Commit

Permalink
feat(core): set user default roles from env (#1793)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored Aug 19, 2022
1 parent 84c0d8f commit 4afdf3c
Show file tree
Hide file tree
Showing 13 changed files with 87 additions and 75 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/env-set/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()),
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/lib/user.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,3 +61,30 @@ export const findUserByUsernameAndPassword = async (

export const updateLastSignInAt = async (userId: string) =>
updateUserById(userId, { lastSignInAt: Date.now() });

const insertUserQuery = buildInsertInto<CreateUser, User>(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 });
};
9 changes: 9 additions & 0 deletions packages/core/src/queries/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `
)}
`);
28 changes: 0 additions & 28 deletions packages/core/src/queries/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
hasUserWithEmail,
hasUserWithIdentity,
hasUserWithPhone,
insertUser,
countUsers,
findUsers,
updateUserById,
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 0 additions & 4 deletions packages/core/src/queries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,6 @@ export const hasUserWithIdentity = async (target: string, userId: string) =>
`
);

export const insertUser = buildInsertInto<CreateUser, User>(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 + '%'}`);
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/routes/admin-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,6 @@ jest.mock('@/queries/user', () => ({
})
),
deleteUserById: jest.fn(),
insertUser: jest.fn(
async (user: CreateUser): Promise<User> => ({
...mockUser,
...user,
})
),
deleteUserIdentity: jest.fn(),
}));

Expand All @@ -54,6 +48,12 @@ jest.mock('@/lib/user', () => ({
passwordEncrypted: 'password',
passwordEncryptionMethod: 'Argon2i',
})),
insertUser: jest.fn(
async (user: CreateUser): Promise<User> => ({
...mockUser,
...user,
})
),
}));

jest.mock('@/queries/roles', () => ({
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/routes/admin-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +16,6 @@ import {
countUsers,
findUserById,
hasUser,
insertUser,
updateUserById,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/routes/session/passwordless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@ import { createRequester } from '@/utils/test-utils';

import sessionPasswordlessRoutes from './passwordless';

const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => 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),
}));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));

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 === '[email protected]',
}));

const sendPasscode = jest.fn(async () => ({ connector: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/routes/session/passwordless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
37 changes: 19 additions & 18 deletions packages/core/src/routes/session/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> => 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 === '[email protected]',
hasActiveUsers: async () => hasActiveUsers(),
}));

jest.mock('@/lib/user', () => ({
async findUserByUsernameAndPassword(username: string, password: string) {
if (username !== 'username' && username !== 'admin') {
Expand All @@ -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<User> => 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 === '[email protected]',
hasActiveUsers: async () => hasActiveUsers(),
}));

const grantSave = jest.fn(async () => 'finalGrantId');
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/routes/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/routes/session/social.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -39,14 +35,21 @@ jest.mock('@/lib/social', () => ({
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => 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',
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/routes/session/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 4afdf3c

Please sign in to comment.