diff --git a/packages/backend/bin/database/utils.js b/packages/backend/bin/database/utils.js index 4d373353..0a3ae129 100644 --- a/packages/backend/bin/database/utils.js +++ b/packages/backend/bin/database/utils.js @@ -2,6 +2,7 @@ import appConfig from '../../src/config/app.js'; import logger from '../../src/helpers/logger.js'; import client from './client.js'; import User from '../../src/models/user.js'; +import Config from '../../src/models/config.js'; import Role from '../../src/models/role.js'; import '../../src/config/orm.js'; import process from 'process'; @@ -21,6 +22,14 @@ export async function createUser( email = 'user@automatisch.io', password = 'sample' ) { + if (appConfig.disableSeedUser) { + logger.info('Seed user is disabled.'); + + process.exit(0); + + return; + } + const UNIQUE_VIOLATION_CODE = '23505'; const role = await fetchAdminRole(); @@ -37,6 +46,8 @@ export async function createUser( if (userCount === 0) { const user = await User.query().insertAndFetch(userParams); logger.info(`User has been saved: ${user.email}`); + + await Config.markInstallationCompleted(); } else { logger.info('No need to seed a user.'); } diff --git a/packages/backend/src/config/app.js b/packages/backend/src/config/app.js index 26241f6f..c2050d82 100644 --- a/packages/backend/src/config/app.js +++ b/packages/backend/src/config/app.js @@ -98,6 +98,7 @@ const appConfig = { disableFavicon: process.env.DISABLE_FAVICON === 'true', additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK, additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT, + disableSeedUser: process.env.DISABLE_SEED_USER === 'true', }; if (!appConfig.encryptionKey) { diff --git a/packages/backend/src/controllers/api/v1/installation/users/create-user.js b/packages/backend/src/controllers/api/v1/installation/users/create-user.js new file mode 100644 index 00000000..84172310 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/installation/users/create-user.js @@ -0,0 +1,9 @@ +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const { email, password, fullName } = request.body; + + await User.createAdmin({ email, password, fullName }); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js new file mode 100644 index 00000000..a157dbb3 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import Config from '../../../../../models/config.js'; +import User from '../../../../../models/user.js'; +import { createRole } from '../../../../../../test/factories/role'; +import { createUser } from '../../../../../../test/factories/user'; +import { createInstallationCompletedConfig } from '../../../../../../test/factories/config'; + +describe('POST /api/v1/installation/users', () => { + let adminRole; + + beforeEach(async () => { + adminRole = await createRole({ + name: 'Admin', + key: 'admin', + }) + }); + + describe('for incomplete installations', () => { + it('should respond with HTTP 204 with correct payload when no user', async () => { + expect(await Config.isInstallationCompleted()).toBe(false); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin' + }) + .expect(204); + + const user = await User.query().findOne({ email: 'user@automatisch.io' }); + + expect(user.roleId).toBe(adminRole.id); + expect(await Config.isInstallationCompleted()).toBe(true); + }); + + it('should respond with HTTP 403 with correct payload when one user exists at least', async () => { + expect(await Config.isInstallationCompleted()).toBe(false); + + await createUser(); + + const usersCountBefore = await User.query().resultSize(); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin' + }) + .expect(403); + + const usersCountAfter = await User.query().resultSize(); + + expect(usersCountBefore).toEqual(usersCountAfter); + }); + }); + + describe('for completed installations', () => { + beforeEach(async () => { + await createInstallationCompletedConfig(); + }); + + it('should respond with HTTP 403 when installation completed', async () => { + expect(await Config.isInstallationCompleted()).toBe(true); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin' + }) + .expect(403); + + const user = await User.query().findOne({ email: 'user@automatisch.io' }); + + expect(user).toBeUndefined(); + expect(await Config.isInstallationCompleted()).toBe(true); + }); + }) +}); diff --git a/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js b/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js new file mode 100644 index 00000000..0ffbfd6d --- /dev/null +++ b/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js @@ -0,0 +1,17 @@ +export async function up(knex) { + const users = await knex('users').limit(1); + + // no user implies installation is not completed yet. + if (users.length === 0) return; + + await knex('config').insert({ + key: 'installation.completed', + value: { + data: true + } + }); +}; + +export async function down(knex) { + await knex('config').where({ key: 'installation.completed' }).delete(); +}; diff --git a/packages/backend/src/helpers/allow-installation.js b/packages/backend/src/helpers/allow-installation.js new file mode 100644 index 00000000..33826a4b --- /dev/null +++ b/packages/backend/src/helpers/allow-installation.js @@ -0,0 +1,16 @@ +import Config from '../models/config.js'; +import User from '../models/user.js'; + +export async function allowInstallation(request, response, next) { + if (await Config.isInstallationCompleted()) { + return response.status(403).end(); + } + + const hasAnyUsers = await User.query().resultSize() > 0; + + if (hasAnyUsers) { + return response.status(403).end(); + } + + next(); +}; diff --git a/packages/backend/src/models/config.js b/packages/backend/src/models/config.js index 949c6aaf..b65b6ece 100644 --- a/packages/backend/src/models/config.js +++ b/packages/backend/src/models/config.js @@ -13,6 +13,28 @@ class Config extends Base { value: { type: 'object' }, }, }; + + static async isInstallationCompleted() { + const installationCompletedEntry = await this + .query() + .where({ + key: 'installation.completed' + }) + .first(); + + const installationCompleted = installationCompletedEntry?.value?.data === true; + + return installationCompleted; + } + + static async markInstallationCompleted() { + return await this.query().insert({ + key: 'installation.completed', + value: { + data: true, + }, + }); + } } export default Config; diff --git a/packages/backend/src/models/role.js b/packages/backend/src/models/role.js index af6ccafa..08b19673 100644 --- a/packages/backend/src/models/role.js +++ b/packages/backend/src/models/role.js @@ -45,6 +45,10 @@ class Role extends Base { get isAdmin() { return this.key === 'admin'; } + + static async findAdmin() { + return await this.query().findOne({ key: 'admin' }); + } } export default Role; diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 893f057e..c3900b4f 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -10,6 +10,7 @@ import Base from './base.js'; import App from './app.js'; import AccessToken from './access-token.js'; import Connection from './connection.js'; +import Config from './config.js'; import Execution from './execution.js'; import Flow from './flow.js'; import Identity from './identity.ee.js'; @@ -373,6 +374,21 @@ class User extends Base { return apps; } + static async createAdmin({ email, password, fullName }) { + const adminRole = await Role.findAdmin(); + + const adminUser = await this.query().insert({ + email, + password, + fullName, + roleId: adminRole.id + }); + + await Config.markInstallationCompleted(); + + return adminUser; + } + async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); diff --git a/packages/backend/src/routes/api/v1/installation/users.js b/packages/backend/src/routes/api/v1/installation/users.js new file mode 100644 index 00000000..9a3c8fd7 --- /dev/null +++ b/packages/backend/src/routes/api/v1/installation/users.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import asyncHandler from 'express-async-handler'; +import { allowInstallation } from '../../../../helpers/allow-installation.js'; +import createUserAction from '../../../../controllers/api/v1/installation/users/create-user.js'; + +const router = Router(); + +router.post( + '/', + allowInstallation, + asyncHandler(createUserAction) +); + +export default router; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index a7f8c30a..c67a9cf3 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -18,6 +18,7 @@ import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee. import rolesRouter from './api/v1/admin/roles.ee.js'; import permissionsRouter from './api/v1/admin/permissions.ee.js'; import adminUsersRouter from './api/v1/admin/users.ee.js'; +import installationUsersRouter from './api/v1/installation/users.js'; const router = Router(); @@ -40,5 +41,7 @@ router.use('/api/v1/admin/users', adminUsersRouter); router.use('/api/v1/admin/roles', rolesRouter); router.use('/api/v1/admin/permissions', permissionsRouter); router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter); +router.use('/api/v1/installation/users', installationUsersRouter); + export default router; diff --git a/packages/backend/test/factories/config.js b/packages/backend/test/factories/config.js index a8d59787..5a8e316c 100644 --- a/packages/backend/test/factories/config.js +++ b/packages/backend/test/factories/config.js @@ -11,3 +11,7 @@ export const createConfig = async (params = {}) => { return config; }; + +export const createInstallationCompletedConfig = async () => { + return await createConfig({ key: 'installation.completed', value: { data: true } }); +} diff --git a/packages/backend/test/setup/global-hooks.js b/packages/backend/test/setup/global-hooks.js index d6f8a562..7e4eee85 100644 --- a/packages/backend/test/setup/global-hooks.js +++ b/packages/backend/test/setup/global-hooks.js @@ -8,7 +8,7 @@ global.beforeAll(async () => { logger.silent = true; // Remove default roles and permissions before running the test suite - await knex.raw('TRUNCATE TABLE roles, permissions CASCADE'); + await knex.raw('TRUNCATE TABLE config, roles, permissions CASCADE'); }); global.beforeEach(async () => {