diff --git a/forge/db/controllers/StorageSession.js b/forge/db/controllers/StorageSession.js index 453a7da7b5..4f06acf441 100644 --- a/forge/db/controllers/StorageSession.js +++ b/forge/db/controllers/StorageSession.js @@ -50,5 +50,38 @@ module.exports = { sessions.session = '{}' await sessions.save() } + }, + async removeUserFromTeamSessions (app, user, team) { + const instances = await app.db.models.Project.byTeam(team.hashid) + for (let i = 0; i < instances.length; i++) { + const instance = instances[i] + const sessions = await app.db.models.StorageSession.byProject(instance.id) + if (sessions) { + const sessionInfo = JSON.parse(sessions.sessions) + let modified = false + const userSessions = Object.values(sessionInfo).filter(session => { + if (session.user === user.username) { + delete sessionInfo[session.accessToken] + modified = true + return true + } + return false + }) + if (modified) { + sessions.sessions = JSON.stringify(sessionInfo) + await sessions.save() + } + if (userSessions.length > 0) { + for (let i = 0; i < userSessions.length; i++) { + const token = userSessions[i].accessToken + try { + await app.containers.revokeUserToken(instance, token) // logout:nodered(step-2) + } catch (error) { + app.log.warn(`Failed to revoke token for Instance ${instance.id}: ${error.toString()}`) // log error but continue to delete session + } + } + } + } + } } } diff --git a/forge/db/controllers/Team.js b/forge/db/controllers/Team.js index cad7c736b7..ca09e6324c 100644 --- a/forge/db/controllers/Team.js +++ b/forge/db/controllers/Team.js @@ -90,8 +90,9 @@ module.exports = { } await userRole.destroy() + await app.db.controllers.StorageSession.removeUserFromTeamSessions(user, team) + return true - // console.warn('TODO: forge.db.controllers.Team.removeUser - expire oauth sessions') } return false diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 660746243e..437e0d59a2 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -17,7 +17,7 @@ const { DataTypes, Op } = require('sequelize') const Controllers = require('../controllers') -const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('./ProjectSettings') +const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('./ProjectSettings') const BANNED_NAME_LIST = [ 'www', @@ -358,7 +358,8 @@ module.exports = { { key: KEY_SETTINGS }, { key: KEY_HOSTNAME }, { key: KEY_HA }, - { key: KEY_PROTECTED } + { key: KEY_PROTECTED }, + { key: KEY_HEALTH_CHECK_INTERVAL } ] }, required: false diff --git a/forge/db/models/ProjectSettings.js b/forge/db/models/ProjectSettings.js index 5bb9de7eba..1f9c566c13 100644 --- a/forge/db/models/ProjectSettings.js +++ b/forge/db/models/ProjectSettings.js @@ -13,12 +13,14 @@ const KEY_SETTINGS = 'settings' const KEY_HOSTNAME = 'hostname' const KEY_HA = 'ha' const KEY_PROTECTED = 'protected' +const KEY_HEALTH_CHECK_INTERVAL = 'healthCheckInterval' module.exports = { KEY_SETTINGS, KEY_HOSTNAME, KEY_HA, KEY_PROTECTED, + KEY_HEALTH_CHECK_INTERVAL, name: 'ProjectSettings', schema: { ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' }, diff --git a/forge/db/views/Project.js b/forge/db/views/Project.js index 527e8bc317..6163610a69 100644 --- a/forge/db/views/Project.js +++ b/forge/db/views/Project.js @@ -1,4 +1,4 @@ -const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('../models/ProjectSettings') +const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('../models/ProjectSettings') module.exports = function (app) { app.addSchema({ @@ -32,6 +32,13 @@ module.exports = function (app) { protected: { type: 'object', additionalProperties: true + }, + launcherSettings: { + type: 'object', + properties: { + healthCheckInterval: { type: 'number' } + }, + additionalProperties: false } } }) @@ -61,6 +68,13 @@ module.exports = function (app) { } else { result.settings = {} } + // Launcher Settings + const heathCheckIntervalRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HEALTH_CHECK_INTERVAL) + if (heathCheckIntervalRow) { + result.launcherSettings = {} + result.launcherSettings.healthCheckInterval = heathCheckIntervalRow?.value + } + // Environment result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env) if (!result.settings.palette?.modules) { // If there are no modules listed in settings, check the StorageSettings diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index d5585a20f5..359325f8a9 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -1,4 +1,4 @@ -const { KEY_HOSTNAME, KEY_SETTINGS } = require('../../db/models/ProjectSettings') +const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL } = require('../../db/models/ProjectSettings') const { Roles } = require('../../lib/roles') const { isFQDN } = require('../../lib/validate') @@ -312,6 +312,7 @@ module.exports = async function (app) { name: { type: 'string' }, hostname: { type: 'string' }, settings: { type: 'object' }, + launcherSettings: { type: 'object' }, projectType: { type: 'string' }, stack: { type: 'string' }, sourceProject: { @@ -478,6 +479,19 @@ module.exports = async function (app) { changesToPersist.stack = { from: request.project.stack, to: stack } } + // Launcher settings + if (request.body?.launcherSettings?.healthCheckInterval) { + const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL) + const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10) + if (isNaN(newInterval) || newInterval < 5000) { + reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' }) + return + } + if (oldInterval !== newInterval) { + changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval } + } + } + /// Persist the changes const updates = new app.auditLog.formatters.UpdatesCollection() const transaction = await app.db.sequelize.transaction() // start a transaction @@ -541,6 +555,11 @@ module.exports = async function (app) { } } + if (changesToPersist.healthCheckInterval) { + await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction }) + updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to }) + } + await transaction.commit() // all good, commit the transaction // Log the updates @@ -795,7 +814,9 @@ module.exports = async function (app) { reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' }) return } + // get settings from the driver const settings = await app.containers.settings(request.project) + // add instance settings settings.env = settings.env || {} settings.baseURL = request.project.url settings.forgeURL = app.config.base_url @@ -805,6 +826,7 @@ module.exports = async function (app) { settings.auditURL = request.project.auditURL settings.state = request.project.state settings.stack = request.project.ProjectStack?.properties || {} + settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL) settings.settings = await app.db.controllers.Project.getRuntimeSettings(request.project) if (settings.settings.env) { settings.env = Object.assign({}, settings.settings.env, settings.env) diff --git a/forge/routes/api/teamMembers.js b/forge/routes/api/teamMembers.js index 026430efc7..cc20504726 100644 --- a/forge/routes/api/teamMembers.js +++ b/forge/routes/api/teamMembers.js @@ -155,6 +155,11 @@ module.exports = async function (app) { const role = app.auditLog.formatters.roleObject(result.role) updates.push('role', oldRole?.role || result.oldRole, role?.role || result.role) await app.auditLog.Team.team.user.roleChanged(request.session.User, null, request.team, result.user, updates) + if (result.role < result.oldRole) { + // We should invalidate session for this user for the teams NR instances if lower + // might want to make this only if it drop under Member + await app.db.controllers.StorageSession.removeUserFromTeamSessions(request.user, request.team) + } } reply.send({ status: 'okay' }) } catch (err) { diff --git a/frontend/src/pages/instance/Settings/LauncherSettings.vue b/frontend/src/pages/instance/Settings/LauncherSettings.vue new file mode 100644 index 0000000000..271180bae1 --- /dev/null +++ b/frontend/src/pages/instance/Settings/LauncherSettings.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/pages/instance/Settings/index.vue b/frontend/src/pages/instance/Settings/index.vue index 1b2dec9146..52054cb9a9 100644 --- a/frontend/src/pages/instance/Settings/index.vue +++ b/frontend/src/pages/instance/Settings/index.vue @@ -68,6 +68,7 @@ export default { this.sideNavigation.push({ name: 'Editor', path: './editor' }) this.sideNavigation.push({ name: 'Security', path: './security' }) this.sideNavigation.push({ name: 'Palette', path: './palette' }) + this.sideNavigation.push({ name: 'Launcher', path: './launcher' }) if (this.features.emailAlerts && this.team.type.properties.features?.emailAlerts) { this.sideNavigation.push({ name: 'Alerts', path: './alerts' }) } diff --git a/frontend/src/pages/instance/Settings/routes.js b/frontend/src/pages/instance/Settings/routes.js index 5ace28ff09..a8fb46bf2e 100644 --- a/frontend/src/pages/instance/Settings/routes.js +++ b/frontend/src/pages/instance/Settings/routes.js @@ -5,6 +5,7 @@ import InstanceSettingsEditor from './Editor.vue' import InstanceSettingsEnvVar from './Environment.vue' import InstanceSettingsGeneral from './General.vue' import InstanceSettingsHA from './HighAvailability.vue' +import InstanceSettingsLauncher from './LauncherSettings.vue' import InstanceSettingsPalette from './Palette.vue' import InstanceSettingsProtect from './ProtectInstance.vue' import InstanceSettingsSecurity from './Security.vue' @@ -26,5 +27,6 @@ export default [ title: 'Instance - Change Type' } }, + { path: 'launcher', name: 'instance-settings-launcher', component: InstanceSettingsLauncher }, { path: 'alerts', name: 'instance-settings-alerts', component: InstanceSettingsAlerts } ] diff --git a/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js b/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js index abba6c0778..405e480ac3 100644 --- a/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js +++ b/test/e2e/frontend/cypress/tests-ee/instances/alerts.spec.js @@ -28,7 +28,6 @@ describe('FlowFuse EE - Instance - Alerts', () => { navigateToInstanceSettings('BTeam', 'instance-2-1') // check Alerts in list and click - cy.get('[data-el="section-side-menu"] li').should('have.length', 8) cy.get('[data-el="section-side-menu"] li:last a').contains('Alerts') cy.get('[data-el="section-side-menu"] li:last').click() diff --git a/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js b/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js new file mode 100644 index 0000000000..72ec0d70e1 --- /dev/null +++ b/test/e2e/frontend/cypress/tests/instances/settings/launcher.spec.js @@ -0,0 +1,88 @@ +/// +describe('FlowFuse - Instance - Settings - Launcher', () => { + function navigateToInstanceSettings (teamName, projectName) { + cy.request('GET', '/api/v1/user/teams') + .then((response) => { + const team = response.body.teams.find( + (team) => team.name === teamName + ) + return cy.request('GET', `/api/v1/teams/${team.id}/projects`) + }) + .then((response) => { + const project = response.body.projects.find( + (project) => project.name === projectName + ) + cy.visit(`/instance/${project.id}/settings/general`) + cy.wait('@getInstance') + }) + } + + function getForm () { + return cy.get('[data-el="launcher-settings-form"]') + } + + beforeEach(() => { + cy.intercept('GET', '/api/*/projects/').as('getProjects') + cy.intercept('GET', '/api/*/projects/*').as('getInstance') + cy.login('bob', 'bbPassword') + cy.home() + }) + + it('Validates health check interval user input', () => { + cy.intercept('PUT', '/api/*/projects/*').as('updateInstance') + // navigate to instance settings -> launcher tab + cy.login('bob', 'bbPassword') + cy.home() + navigateToInstanceSettings('BTeam', 'instance-2-1') + + cy.get('[data-el="section-side-menu"] li').contains('Launcher').click() + + // wait for url /instance/***/settings/launcher + cy.url().should('include', 'settings/launcher') + + // Change value to < 5000 + getForm().first('div').get('.ff-input > input[type=number]').clear() + getForm().first('div').get('.ff-input > input[type=number]').type(4999) + cy.get('[data-action="save-settings"]').should('be.disabled') + getForm().first('div').get('[data-el="form-row-error"').contains('Health check interval must be 5000 or greater').should('exist') + + // Change value to > 5000 + getForm().first('div').get('.ff-input > input[type=number]').clear() + getForm().first('div').get('.ff-input > input[type=number]').type(5001) + cy.get('[data-action="save-settings"]').should('not.be.disabled') + getForm().first('div').get('[data-el="form-row-error"').should('not.exist') + }) + + it('Can set health check interval value', () => { + cy.intercept('PUT', '/api/*/projects/*').as('updateInstance') + + navigateToInstanceSettings('BTeam', 'instance-2-1') + + // locate and click on the launcher tab + cy.get('[data-el="section-side-menu"] li').contains('Launcher').click() + + // wait for url /instance/***/settings/launcher + cy.url().should('include', 'settings/launcher') + + // // ensure the first child's title is correct + getForm().should('exist') + getForm().first('div').should('exist') + getForm().first('div').get('[data-el="form-row-title"]').contains('Health check interval (ms)').should('exist') + // ensure the first child's numeric input exists + getForm().first('div').get('.ff-input > input[type=number]').should('exist') + + // Change value & save + const randomBetween6789and9876 = Math.floor(Math.random() * (9876 - 6789 + 1)) + 6789 + getForm().first('div').get('.ff-input > input[type=number]').clear() + getForm().first('div').get('.ff-input > input[type=number]').type(randomBetween6789and9876) + cy.get('[data-action="save-settings"]').click() + cy.wait('@updateInstance') + + // refresh page + navigateToInstanceSettings('BTeam', 'instance-2-1') + cy.get('[data-el="section-side-menu"] li').contains('Launcher').click() + + // check value is restored + cy.get('[data-el="launcher-settings-form"]').first().get('.ff-input > input[type=number]').should('have.value', randomBetween6789and9876) + }) +}) diff --git a/test/unit/forge/db/controllers/StorageSession_spec.js b/test/unit/forge/db/controllers/StorageSession_spec.js index df902c2b7c..b89e698088 100644 --- a/test/unit/forge/db/controllers/StorageSession_spec.js +++ b/test/unit/forge/db/controllers/StorageSession_spec.js @@ -71,4 +71,79 @@ describe('Storage Session controller', function () { s3Sessions.should.not.have.property('token5') }) }) + + describe('removeUserFromTeamSessions', function () { + it('remove user sessions from a teams instance\'s', async function () { + const projectType = await app.factory.createProjectType({ + name: 'projectType2', + description: 'default project type', + properties: { foo: 'bar' } + }) + + const template = await app.factory.createProjectTemplate({ + name: 'template1', + settings: { + httpAdminRoot: '', + codeEditor: '', + palette: { + npmrc: 'example npmrc', + catalogue: ['https://example.com/catalog'], + modules: [ + { name: 'node-red-dashboard', version: '3.0.0' }, + { name: 'node-red-contrib-ping', version: '0.3.0' } + ] + } + }, + policy: { + httpAdminRoot: true, + dashboardUI: true, + codeEditor: true + } + }, app.TestObjects.userAlice) + const stack = await app.factory.createStack({ name: 'stack1' }, projectType) + const application = await app.factory.createApplication({ name: 'application-1' }, app.TestObjects.team1) + + const p1 = await app.factory.createInstance( + { name: 'project-1' }, + application, + stack, + template, + projectType, + { start: false } + ) + const p2 = await app.factory.createInstance( + { name: 'project-2' }, + application, + stack, + template, + projectType, + { start: false } + ) + + // p1 - two active sessions for alice, one for bob + // p2 - no sessions for alice + const s1 = await app.db.models.StorageSession.create({ + sessions: '{"token1":{"user":"alice","client":"node-red-editor","scope":["*"],"accessToken":"token1","expires":1676376174919},"token2":{"user":"alice","client":"node-red-editor","scope":["*"],"accessToken":"token2","expires":1676376174919},"token3":{"user":"bob","client":"node-red-editor","scope":["*"],"accessToken":"token3","expires":1676376174919}}', + ProjectId: p1.id + }) + const s2 = await app.db.models.StorageSession.create({ + sessions: '{"token4":{"user":"bob","client":"node-red-editor","scope":["*"],"accessToken":"token3","expires":1676376174919}}', + ProjectId: p2.id + }) + + await app.db.controllers.StorageSession.removeUserFromTeamSessions(app.TestObjects.userAlice, app.TestObjects.team1) + + await Promise.all([ + s1.reload(), + s2.reload() + ]) + + const s1Sessions = JSON.parse(s1.sessions) + const s2Sessions = JSON.parse(s2.sessions) + s1Sessions.should.not.have.property('token1') + s1Sessions.should.not.have.property('token2') + s1Sessions.should.have.property('token3') + s2Sessions.should.have.property('token4') + }) + }) }) diff --git a/test/unit/forge/db/setup.js b/test/unit/forge/db/setup.js index 36d5acd280..cce8511287 100644 --- a/test/unit/forge/db/setup.js +++ b/test/unit/forge/db/setup.js @@ -31,6 +31,7 @@ module.exports = async function (config = {}) { await team2.addUser(userBob, { through: { role: Roles.Owner } }) await team2.addUser(userAlice, { through: { role: Roles.Owner } }) await team3.addUser(userAlice, { through: { role: Roles.Owner } }) + forge.TestObjects = { defaultTeamType, userAlice, diff --git a/test/unit/forge/routes/api/project_spec.js b/test/unit/forge/routes/api/project_spec.js index e4b62aee45..813bb74632 100644 --- a/test/unit/forge/routes/api/project_spec.js +++ b/test/unit/forge/routes/api/project_spec.js @@ -10,7 +10,7 @@ const setup = require('../setup') const FF_UTIL = require('flowforge-test-utils') const { Roles } = FF_UTIL.require('forge/lib/roles') -const { KEY_HOSTNAME } = FF_UTIL.require('forge/db/models/ProjectSettings') +const { KEY_HOSTNAME, KEY_HEALTH_CHECK_INTERVAL } = FF_UTIL.require('forge/db/models/ProjectSettings') const { START_DELAY, STOP_DELAY } = FF_UTIL.require('forge/containers/stub/index.js') describe('Project API', function () { @@ -1654,6 +1654,85 @@ describe('Project API', function () { { name: 'two', value: '2' } ]) // should be unchanged }) + it('Change launcher health check interval - owner', async function () { + // Setup some flows/credentials + await addFlowsToProject(app, + TestObjects.project1.id, + TestObjects.tokens.project, + TestObjects.tokens.alice, + [{ id: 'node1' }], + { testCreds: 'abc' }, + 'key1', + {} + ) + // call "Update a project" with new httpAdminRoot + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${TestObjects.project1.id}`, + payload: { + launcherSettings: { + healthCheckInterval: 9876 + } + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + + const newValue = await TestObjects.project1.getSetting(KEY_HEALTH_CHECK_INTERVAL) + should(newValue).equal(9876) + }) + it('Change launcher health check interval bad value - owner', async function () { + // Setup some flows/credentials + await addFlowsToProject(app, + TestObjects.project1.id, + TestObjects.tokens.project, + TestObjects.tokens.alice, + [{ id: 'node1' }], + { testCreds: 'abc' }, + 'key1', + {} + ) + // call "Update a project" with new httpAdminRoot + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${TestObjects.project1.id}`, + payload: { + launcherSettings: { + healthCheckInterval: 999 // 999 is below the 5000 is the minimum + } + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(400) + const result = response.json() + result.should.have.property('code', 'invalid_heathCheckInterval') + }) + it('Change launcher health check interval - member', async function () { + // Setup some flows/credentials + // app, id, token, userToken, flows, creds, key, settings + await addFlowsToProject( + app, + TestObjects.project1.id, + TestObjects.tokens.project, + TestObjects.tokens.alice, + [{ id: 'node1' }], + { testCreds: 'abc' }, + 'key1', + {} + ) + // call "Update a project" with new httpAdminRoot + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${TestObjects.project1.id}`, + payload: { + launcherSettings: { + healthCheckInterval: 9876 + } + }, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(403) + }) describe('Update hostname', function () { it('Changes the projects hostname', async function () {