diff --git a/forge/auditLog/device.js b/forge/auditLog/device.js index 038735286a..4a084375a9 100644 --- a/forge/auditLog/device.js +++ b/forge/auditLog/device.js @@ -107,7 +107,19 @@ module.exports = { async targetSet (actionedBy, error, device, snapshot) { await log('device.snapshot.target-set', actionedBy, device?.id, generateBody({ error, device, snapshot })) } + }, + httpToken: { + async created (actionedBy, error, device, token) { + await log('device.httpToken.created', actionedBy, device?.id, generateBody({ error, device, token })) + }, + async updated (actionedBy, error, device, updates) { + await log('device.httpToken.updated', actionedBy, device?.id, generateBody({ error, device, updates })) + }, + async deleted (actionedBy, error, device, token) { + await log('device.httpToken.deleted', actionedBy, device?.id, generateBody({ error, device, token })) + } } + } const log = async (event, actionedBy, deviceId, body) => { diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index 8141845e7c..bc2bb3cd9a 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -300,25 +300,42 @@ module.exports = { }, // Should these only get added via forge/ee/lib/httpTokens? - createHTTPNodeToken: async function (app, project, name, scope = [''], expiresAt) { - const projectId = (project && typeof project === 'object') ? project.id : project + createHTTPNodeToken: async function (app, owner, name, scope = [''], expiresAt) { + // Ensure a string + const ownerId = '' + owner.id + let ownerType + if (owner.constructor.name === 'Project') { + ownerType = 'http' + } else if (owner.constructor.name === 'Device') { + ownerType = 'http:device' + } else { + throw new Error('Invalid owner type for HTTP Node Token: ' + owner.constructor.name) + } const token = generateToken(32, 'ffhttp') const tok = await app.db.models.AccessToken.create({ token, expiresAt, name, scope, - ownerId: projectId, - ownerType: 'http' + ownerId, + ownerType }) // Overwrite the hashed token with the plain value const result = app.db.views.AccessToken.instanceHTTPTokenSummary(tok) result.token = token return result }, - updateHTTPNodeToken: async function (app, project, tokenId, scope = [''], expiresAt) { - const projectId = (project && typeof project === 'object') ? project.id : project - const token = await app.db.models.AccessToken.byId(tokenId, 'http', projectId) + updateHTTPNodeToken: async function (app, owner, tokenId, scope = [''], expiresAt) { + const ownerId = '' + owner.id + let ownerType + if (owner.constructor.name === 'Project') { + ownerType = 'http' + } else if (owner.constructor.name === 'Device') { + ownerType = 'http:device' + } else { + throw new Error('Invalid owner type for HTTP Node Token: ' + owner.constructor.name) + } + const token = await app.db.models.AccessToken.byId(tokenId, ownerType, ownerId) if (token) { token.scope = scope if (expiresAt === undefined) { diff --git a/forge/db/models/AccessToken.js b/forge/db/models/AccessToken.js index 5d4aea7f4a..bd43f63a52 100644 --- a/forge/db/models/AccessToken.js +++ b/forge/db/models/AccessToken.js @@ -126,6 +126,17 @@ module.exports = { attributes: ['id', 'name', 'scope', 'expiresAt'] }) return tokens + }, + getDeviceHTTPTokens: async (device) => { + const tokens = this.findAll({ + where: { + ownerType: 'http:device', + ownerId: '' + device.id + }, + order: [['id', 'ASC']], + attributes: ['id', 'name', 'scope', 'expiresAt'] + }) + return tokens } }, instance: { diff --git a/forge/ee/routes/httpTokens/index.js b/forge/ee/routes/httpTokens/index.js index f4b5dfb9e0..ec7ad93d2f 100644 --- a/forge/ee/routes/httpTokens/index.js +++ b/forge/ee/routes/httpTokens/index.js @@ -3,6 +3,8 @@ module.exports = async function (app) { app.addHook('preHandler', app.verifySession) app.addHook('preHandler', async (request, reply) => { + // This route is either under `/projects/:projectId/httpTokens' or '/devices/:deviceId/httpTokens' + // The preHandler needs to handle both cases. if (request.params.projectId !== undefined) { if (request.params.projectId) { try { @@ -34,12 +36,42 @@ module.exports = async function (app) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } + if (request.params.deviceId !== undefined) { + if (request.params.deviceId) { + try { + request.device = await app.db.models.Device.byId(request.params.deviceId) + if (!request.device) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return + } + await request.device.Team.ensureTeamTypeExists() + if (!request.device.Team.getFeatureProperty('teamHttpSecurity', false)) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + if (request.session.User) { + request.teamMembership = await request.session.User.getTeamMembership(request.device.Team.id) + if (!request.teamMembership && !request.session.User.admin) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + } else if (request.session.ownerId !== request.params.deviceId) { + // AccessToken being used - but not owned by this device + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + } catch (err) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } else { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } }) - app.get('/', { - preHandler: app.needsPermission('project:edit') - }, async (request, reply) => { - const tokens = await app.db.models.AccessToken.getProjectHTTPTokens(request.project) + // #region GET /httpTokens + + function getTokens (request, reply, tokens) { // exclude FF-Expert auto generated HTTP MCP tokens from listing const withoutExpertMcpTokens = tokens.filter(token => !isExpertMcpToken(token)) const tokensView = app.db.views.AccessToken.instanceHTTPTokenSummaryList(withoutExpertMcpTokens) @@ -47,42 +79,78 @@ module.exports = async function (app) { tokens: tokensView, count: tokens.length }) - }) - - app.post('/', { + } + app.get('/projects/:projectId/httpTokens', { preHandler: app.needsPermission('project:edit') }, async (request, reply) => { + const tokens = await app.db.models.AccessToken.getProjectHTTPTokens(request.project) + getTokens(request, reply, tokens) + }) + app.get('/devices/:deviceId/httpTokens', { + preHandler: app.needsPermission('device:edit') + }, async (request, reply) => { + const tokens = await app.db.models.AccessToken.getDeviceHTTPTokens(request.device) + getTokens(request, reply, tokens) + }) + + // #region POST /httpTokens + + async function createToken (request, reply) { try { const body = request.body // Prevent creation of Expert MCP Access Tokens via this route if (isExpertMcpToken({ scope: body.scope })) { throw new Error('Cannot create Expert MCP Access Token via this route') } - const token = await app.db.controllers.AccessToken.createHTTPNodeToken(request.project, body.name, [''], body.expiresAt) - // token has already been sanitised via views.AccessToken.instanceHTTPTokenSummary - await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) + const token = await app.db.controllers.AccessToken.createHTTPNodeToken(request.project || request.device, body.name, [''], body.expiresAt) + if (request.project) { + await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.created(request.session.User, null, request.device, body) + } reply.send(token || {}) } catch (err) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + app.post('/projects/:projectId/httpTokens', { + preHandler: app.needsPermission('project:edit') + }, async (request, reply) => { + return createToken(request, reply) }) - - app.put('/:id', { - preHandler: app.needsPermission('project:edit', true) + app.post('/devices/:deviceId/httpTokens', { + preHandler: app.needsPermission('device:edit') }, async (request, reply) => { + return createToken(request, reply) + }) + + // #region PUT /httpTokens/:id + + async function updateToken (request, reply) { try { - const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'http', request.project.id) + const owner = request.project || request.device + // Ensure id is a string as the AccessToken.ownerId is stored as a string in the database + const ownerId = '' + owner.id + let ownerType = 'http' + if (request.device) { + ownerType = 'http:device' + } + const oldToken = await app.db.models.AccessToken.byId(request.params.id, ownerType, ownerId) if (oldToken) { // Prevent modification of Expert MCP Access Tokens via this route if (isExpertMcpToken(oldToken)) { throw new Error('Cannot modify Expert MCP Access Token') } const body = request.body - const token = await app.db.controllers.AccessToken.updateHTTPNodeToken(request.project, request.params.id, [''], body.expiresAt) + const token = await app.db.controllers.AccessToken.updateHTTPNodeToken(owner, request.params.id, [''], body.expiresAt) const updates = new app.auditLog.formatters.UpdatesCollection() updates.pushDifferences({ expiresAt: oldToken.expiresAt, scope: oldToken.scope.join(',') }, { expiresAt: body.expiresAt, scope: body.scope }) - await app.auditLog.Project.project.httpToken.updated(request.session.User, null, request.project, updates) + if (request.project) { + await app.auditLog.Project.project.httpToken.updated(request.session.User, null, request.project, updates) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.updated(request.session.User, null, request.device, updates) + } reply.send(app.db.views.AccessToken.instanceHTTPTokenSummary(token)) return } @@ -91,16 +159,40 @@ module.exports = async function (app) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + + app.put('/projects/:projectId/httpTokens/:id', { + preHandler: app.needsPermission('project:edit', true) + }, async (request, reply) => { + return updateToken(request, reply) }) - app.delete('/:id', { - preHandler: app.needsPermission('project:edit') + app.put('/devices/:deviceId/httpTokens/:id', { + preHandler: app.needsPermission('device:edit', true) }, async (request, reply) => { + return updateToken(request, reply) + }) + + // #region DELETE /httpTokens/:id + + async function deleteToken (request, reply) { try { - const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'http', request.project.id) + const owner = request.project || request.device + // Ensure id is a string as the AccessToken.ownerId is stored as a string in the database + const ownerId = '' + owner.id + let ownerType = 'http' + if (request.device) { + ownerType = 'http:device' + } + + const oldToken = await app.db.models.AccessToken.byId(request.params.id, ownerType, ownerId) if (oldToken) { await oldToken.destroy() - await app.auditLog.Project.project.httpToken.deleted(request.session.User, null, request.project, { name: oldToken.name }) + if (request.project) { + await app.auditLog.Project.project.httpToken.deleted(request.session.User, null, request.project, { name: oldToken.name }) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.deleted(request.session.User, null, request.device, { name: oldToken.name }) + } reply.code(201).send() return } @@ -109,8 +201,21 @@ module.exports = async function (app) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + + app.delete('/projects/:projectId/httpTokens/:id', { + preHandler: app.needsPermission('project:edit') + }, async (request, reply) => { + return deleteToken(request, reply) + }) + app.delete('/devices/:deviceId/httpTokens/:id', { + preHandler: app.needsPermission('device:edit') + }, async (request, reply) => { + return deleteToken(request, reply) }) + // #region Utility functions + function isExpertMcpToken (token) { if (!token || !token.scope) { return false diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 421b3b1545..ba9f1b6f98 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -24,7 +24,7 @@ module.exports = async function (app) { await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http }) await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http }) - await app.register(require('./httpTokens'), { prefix: '/api/v1/projects/:projectId/httpTokens', logLevel: app.config.logging.http }) + await app.register(require('./httpTokens'), { prefix: '/api/v1', logLevel: app.config.logging.http }) await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http }) await app.register(require('./staticAssets'), { prefix: '/api/v1/projects/:projectId/files', logLevel: app.config.logging.http }) await app.register(require('./projectHistory'), { prefix: '/api/v1/projects/:instanceId/history', logLevel: app.config.logging.http }) diff --git a/forge/routes/auth/oauth.js b/forge/routes/auth/oauth.js index 19e3eb5d4c..df128ee715 100644 --- a/forge/routes/auth/oauth.js +++ b/forge/routes/auth/oauth.js @@ -509,6 +509,8 @@ module.exports = async function (app) { // allow lowercase usernames for npm when publishing nodes to Team Library if (request.session.ownerType === 'npm' && request.session.scope.includes('team:packages:manage')) { sesOwnerId = sesOwnerId.toLowerCase() + } else if (request.session.ownerType === 'http:device') { + sesOwnerId = app.db.models.Device.encodeHashid(sesOwnerId) } if (request.params.ownerType === request.session.ownerType && request.params.ownerId === sesOwnerId) { let response diff --git a/frontend/src/api/devices.js b/frontend/src/api/devices.js index f697fbcc7e..ae540423ab 100644 --- a/frontend/src/api/devices.js +++ b/frontend/src/api/devices.js @@ -246,6 +246,31 @@ const getDeviceEditorProxy = async (editorUrl) => { return client.get(editorUrl) } +const getHTTPTokens = async (deviceId) => { + return client.get(`/api/v1/devices/${deviceId}/httpTokens`).then(res => res.data) +} + +const createHTTPToken = async (deviceId, name, scope, expiresAt) => { + const data = { + name, + scope, + expiresAt + } + return client.post(`/api/v1/devices/${deviceId}/httpTokens`, data).then(res => res.data) +} + +const updateHTTPToken = async (deviceId, tokenId, scope, expiresAt) => { + const data = { + scope, + expiresAt + } + return client.put(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`, data).then(res => res.data) +} + +const deleteHTTPToken = async (deviceId, tokenId) => { + return client.delete(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`) +} + export default { create, getDevice, @@ -271,5 +296,9 @@ export default { restartDevice, startDevice, generateSnapshotDescription, - getDeviceEditorProxy + getDeviceEditorProxy, + getHTTPTokens, + createHTTPToken, + updateHTTPToken, + deleteHTTPToken } diff --git a/frontend/src/pages/device/Settings/Security.vue b/frontend/src/pages/device/Settings/Security.vue index 78a87a80ad..9a3dfcd278 100644 --- a/frontend/src/pages/device/Settings/Security.vue +++ b/frontend/src/pages/device/Settings/Security.vue @@ -13,34 +13,84 @@ :device="device" :team="team" /> +