UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

600 lines (578 loc) • 24.5 kB
const crypto = require('crypto') const schemaApi = require('./schema') module.exports = async function (app) { app.addHook('preHandler', async (request, reply) => { if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { // let teamId = request.params.teamId if (request.params.teamSlug) { // If :teamSlug is provided, need to lookup the team to get // its id for subsequent checks request.team = await app.db.models.Team.bySlug(request.params.teamSlug) if (!request.team) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) return } // teamId = request.team.hashid } if (!request.team) { // For a :teamId route, we can now lookup the full team object request.team = await app.db.models.Team.byId(request.params.teamId) if (!request.team) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) return } await request.team.ensureTeamTypeExists() if (!request.team.getFeatureProperty('teamBroker', false)) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) return // eslint-disable-line no-useless-return } } } if (request.session.User) { request.sessionUser = true request.instanceTokenReq = false if (!request.teamMembership) { request.teamMembership = await request.session.User.getTeamMembership(request.team.id) } } else if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { // this is a request from a project or device request.sessionUserReq = false request.instanceTokenReq = true } else { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) throw new Error('Unauthorized') } }) await schemaApi(app) /** * Get the Teams MQTT Clients * @name /api/v1/teams/:teamId/broker/clients * @static * @memberof forge.routes.api.team.broker */ app.get('/clients', { preHandler: app.needsPermission('broker:clients:list'), schema: { summary: 'List MQTT clients for the team', tags: ['MQTT Broker'], query: { $ref: 'PaginationParams' }, params: { type: 'object', properties: { teamId: { type: 'string' } } }, response: { 200: { type: 'object', properties: { clients: { type: 'array' }, meta: { $ref: 'PaginationMeta' }, count: { type: 'integer' } }, additionalProperties: true }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { const paginationOptions = app.getPaginationOptions(request) const clients = await app.db.models.TeamBrokerClient.byTeam(request.team.hashid, paginationOptions) const applicationRBACEnabled = app.config.features.enabled('rbacApplication') && request.team?.getFeatureProperty('rbacApplication', false) if (applicationRBACEnabled && !request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications) { // Ideally, we'd do this on the query side so that `limits` can be properly honoured. Currently, we don't use the limits // query param with this endpoint, so making do with post-query filtering. clients.clients = clients.clients.filter(client => { const applicationId = client.Device?.Project?.ApplicationId || client.Device?.ApplicationId || client.Project?.ApplicationId if (applicationId) { if (!app.hasPermission(request.teamMembership, 'broker:clients:list', { applicationId: app.db.models.Application.encodeHashid(applicationId) })) { clients.count-- return false } } return true }) } reply.send(app.db.views.TeamBrokerClient.users(clients)) }) /** * Creates a new MQTT Client * @name /api/v1/teams/:teamId/broker/client * @static * @memberof forge.routes.api.team.broker */ app.post('/client', { preHandler: app.needsPermission('broker:clients:create'), schema: { summary: 'Create new MQTT client for the team', tags: ['MQTT Broker'], params: { type: 'object', properties: { teamId: { type: 'string' } } }, body: { type: 'object', properties: { acls: { type: 'array' }, username: { type: 'string' }, password: { type: 'string' } } }, response: { 201: { type: 'object', properties: { id: { type: 'string' }, username: { type: 'string' }, acls: { type: 'array' } } }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { if (await app.db.models.TeamBrokerClient.byUsername(request.body.username, request.team.hashid)) { return reply .code(409) .send({ code: 'client_already_exists', error: 'Client Already exists with username' }) } try { const newUser = request.body newUser.acls = JSON.stringify(newUser.acls) await request.team.checkTeamBrokerClientCreateAllowed() const user = await app.db.models.TeamBrokerClient.create({ ...request.body, TeamId: request.team.id }) reply.status(201).send(app.db.views.TeamBrokerClient.user(user)) } catch (err) { return reply .code(err.statusCode || 400) .send({ code: err.code || 'unknown_error', error: err.error || err.message }) } }) /** * Get a specific MQTT Client * @name /api/v1/teams/:teamId/broker/client/:username * @static * @memberof forge.routes.api.team.broker */ app.get('/client/:username', { preHandler: app.needsPermission('broker:clients:list'), schema: { summary: 'Get details about a specific MQTT client', tags: ['MQTT Broker'], params: { type: 'object', properties: { teamId: { type: 'string' }, username: { type: 'string' } } }, response: { 200: { type: 'object', properties: { id: { type: 'string' }, username: { type: 'string' }, acls: { type: 'array' }, owner: { anyOf: [ { type: 'null' }, { type: 'object', properties: { instanceType: { type: 'string', enum: ['instance', 'device'] }, id: { type: 'string' }, name: { type: 'string' } } } ] } } }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { let client = await app.db.models.TeamBrokerClient.byUsername(request.params.username, request.team.hashid, true, true) const applicationRBACEnabled = app.config.features.enabled('rbacApplication') && request.team?.getFeatureProperty('rbacApplication', false) if (applicationRBACEnabled && !request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications) { const applicationId = client?.Device?.Project?.ApplicationId || client?.Device?.ApplicationId || client?.Project?.ApplicationId if (applicationId) { if (!app.hasPermission(request.teamMembership, 'broker:clients:list', { applicationId: app.db.models.Application.encodeHashid(applicationId) })) { client = null } } } if (client) { reply.send(app.db.views.TeamBrokerClient.user(client)) } else { reply.status(404).send({}) } }) /** * Update a specific MQTT Client * @name /api/v1/teams/:teamId/broker/client/:username * @static * @memberof forge.routes.api.team.broker */ app.put('/client/:username', { preHandler: app.needsPermission('broker:clients:edit'), schema: { summary: 'Modify a MQTT Client', tags: ['MQTT Broker'], params: { type: 'object', properties: { teamId: { type: 'string' }, username: { type: 'string' } } }, body: { anyOf: [ { type: 'object', properties: { password: { type: 'string' }, acls: { type: 'array' } } }, { type: 'object', properties: { password: { type: 'string' } } }, { type: 'object', properties: { acls: { type: 'array' } } } ] }, response: { 201: { type: 'object', properties: { id: { type: 'string' }, username: { type: 'string' }, acls: { type: 'array' } } }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { try { let client = await app.db.models.TeamBrokerClient.byUsername(request.params.username, request.team.hashid, true, true) const applicationRBACEnabled = app.config.features.enabled('rbacApplication') && request.team?.getFeatureProperty('rbacApplication', false) if (applicationRBACEnabled && !request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications) { const applicationId = client?.Device?.Project?.ApplicationId || client?.Device?.ApplicationId || client?.Project?.ApplicationId if (applicationId) { if (!app.hasPermission(request.teamMembership, 'broker:clients:edit', { applicationId: app.db.models.Application.encodeHashid(applicationId) })) { client = null } } } if (!client) { return reply.status(404).send({}) } if (request.body.password) { client.password = request.body.password } if (request.body.acls) { client.acls = JSON.stringify(request.body.acls) } await client.save() reply.status(201).send(app.db.views.TeamBrokerClient.user(client)) } catch (err) { return reply .code(err.statusCode || 400) .send({ code: err.code || 'unknown_error', error: err.error || err.message }) } }) /** * Delete a MQTT Clients * @name /api/v1/teams/:teamId/broker/client/:username * @static * @memberof forge.routes.api.team.broker */ app.delete('/client/:username', { preHandler: app.needsPermission('broker:clients:delete'), schema: { summary: 'Delete a MQTT client', tags: ['MQTT Broker'], params: { type: 'object', properties: { teamId: { type: 'string' }, username: { type: 'string' } } }, response: { 200: { $ref: 'APIStatus' }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { let client = await app.db.models.TeamBrokerClient.byUsername(request.params.username, request.team.hashid) const applicationRBACEnabled = app.config.features.enabled('rbacApplication') && request.team?.getFeatureProperty('rbacApplication', false) if (applicationRBACEnabled && !request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications) { const applicationId = client?.Device?.Project?.ApplicationId || client?.Device?.ApplicationId || client?.Project?.ApplicationId if (applicationId) { if (!app.hasPermission(request.teamMembership, 'broker:clients:delete', { applicationId: app.db.models.Application.encodeHashid(applicationId) })) { client = null } } } if (client) { await client.destroy() reply.send({ status: 'okay' }) } else { reply.status(404).send({}) } }) /** * Link a MQTT Client to a device or project * @name /api/v1/teams/:teamId/broker/client/:username/link * @static * @memberof forge.routes.api.team.broker */ app.post('/client/:username/link', { preHandler: app.needsPermission('broker:clients:link'), schema: { summary: 'Link a MQTT client to a device or project', tags: ['MQTT Broker'], params: { type: 'object', properties: { teamId: { type: 'string' }, username: { type: 'string' } } }, body: { type: 'object', oneOf: [ // Case 1: ownerType and ownerId are obtained from the token { required: ['password'], properties: { password: { type: 'string' } }, additionalProperties: false // Ensures no other properties are allowed for this case }, // Case 2: Body has ownerType and ownerId { required: ['ownerType', 'ownerId'], properties: { ownerType: { type: 'string', enum: ['device', 'instance'] }, ownerId: { type: 'string' }, password: { type: 'string' } }, additionalProperties: false // Ensures only these properties are allowed for this case } ], // Define properties at the top level so their types are known by the validator // regardless of which oneOf matches (if a body is present). properties: { ownerType: { type: 'string', enum: ['device', 'instance'] }, ownerId: { type: 'string' }, password: { type: 'string' } } }, response: { 200: { type: 'object', properties: { id: { type: 'string' }, username: { type: 'string' }, acls: { type: 'array' }, owner: { type: 'object', properties: { type: { type: 'string', enum: ['instance', 'device'] }, id: { type: 'string' }, name: { type: 'string' } } } } }, 201: { type: 'object', properties: { id: { type: 'string' }, username: { type: 'string' }, acls: { type: 'array' }, owner: { type: 'object', properties: { instanceType: { type: 'string', enum: ['instance', 'device'] }, id: { type: 'string' }, name: { type: 'string' } } } } }, '4xx': { $ref: 'APIError' }, 500: { $ref: 'APIError' } } } }, async (request, reply) => { // validate request.params.username format matches instance||device:instanceIdStr if (!/^(instance|device):([a-zA-Z0-9_-]+)$/.test(request.params.username)) { return reply.status(400).send({ code: 'invalid_username', error: 'Invalid username format. Expected format: instance|device:instanceIdStr' }) } // ensure password is provided and is not too long if (!request.body.password || request.body.password.length > 128) { return reply.status(400).send({ code: 'invalid_password', error: 'Invalid password' }) } // Extract instance type and instance ID from the proposed username and later, // verify the match the session information. const usernameParts = request.params.username.split(':') // expects format: instance|device:instanceIdStr const usernameOwnerType = usernameParts[0] === 'instance' ? 'project' : usernameParts[0] const usernameOwnerId = usernameParts[1] let sessionOwnerId = request.body.ownerId let sessionOwnerType = request.body.ownerType === 'instance' ? 'project' : request.body.ownerType if (request.instanceTokenReq) { sessionOwnerId = request.session.ownerId sessionOwnerType = request.session.ownerType if (sessionOwnerType === 'device') { sessionOwnerId = +sessionOwnerId // ID is a number for devices } } if (sessionOwnerType !== usernameOwnerType) { return reply.status(404).send({ error: 'not_found', message: 'not found' }) } if (sessionOwnerType === 'device') { const device = await app.db.models.Device.byId(sessionOwnerId) if (device.hashid !== usernameOwnerId) { return reply.status(404).send({ error: 'not_found', message: 'not found' }) } if (device.Team.hashid !== request.params.teamId) { return reply.status(404).send({ error: 'not_found', message: 'not found' }) } sessionOwnerId = device.id } else if (sessionOwnerType === 'project') { const instance = await app.db.models.Project.byId(sessionOwnerId) if (instance.id !== usernameOwnerId) { return reply.status(404).send({ error: 'not_found', message: 'not found' }) } if (instance.Team.hashid !== request.params.teamId) { return reply.status(404).send({ error: 'not_found', message: 'not found' }) } } else { // should never reach here return reply.status(404).send({ error: 'not_found', message: 'not found' }) } let statusCode = 200 let user = await app.db.models.TeamBrokerClient.byUsername(request.params.username, request.team.hashid) if (!user) { // No user found - create a new one try { await request.team.checkTeamBrokerClientCreateAllowed() } catch (error) { if (error.code === 'broker_client_limit_reached') { return reply .code(403) .send({ code: 'broker_client_limit_reached', error: 'Team Broker client limit reached' }) } } // mvp for generating default acls const generateUuid = (length = 6) => { return Array.from(crypto.getRandomValues(new Uint8Array(6)), (byte) => byte.toString(36).padStart(2, '0') ).join('').substring(0, length) } const acls = [ { id: generateUuid(), action: 'subscribe', // by default allow subscribe only pattern: '#' } ] const newUser = request.body newUser.acls = JSON.stringify(acls) newUser.username = request.params.username newUser.ownerType = sessionOwnerType newUser.ownerId = sessionOwnerId if (sessionOwnerType === 'device') { newUser.ownerId = +sessionOwnerId // ID is a number for devices } newUser.password = request.body.password user = await app.db.models.TeamBrokerClient.create({ ...newUser, TeamId: request.team.id }) statusCode = 201 } else { // User found - check: if type/id exists, it must match, otherwise return an error let checkOwnerId = user.ownerId || sessionOwnerId const checkOwnerType = user.ownerType || sessionOwnerType if (checkOwnerType === 'device') { checkOwnerId = +checkOwnerId } if (checkOwnerType !== sessionOwnerType || checkOwnerId !== sessionOwnerId) { return reply.status(400).send({ code: 'client_already_linked', error: 'Client is linked to different instance' }) } // update the user with the new ownerType and ownerId user.ownerType = sessionOwnerType user.ownerId = sessionOwnerId user.password = request.body.password await user.save() } // reload the user with the team and instance models const updatedUser = await app.db.models.TeamBrokerClient.byUsername(request.params.username, request.team.hashid, true, true) const userView = app.db.views.TeamBrokerClient.user(updatedUser) reply.status(statusCode).send({ ...userView }) }) }