Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions forge/auditLog/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
31 changes: 24 additions & 7 deletions forge/db/controllers/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions forge/db/models/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
145 changes: 125 additions & 20 deletions forge/ee/routes/httpTokens/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -34,55 +36,121 @@ 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)
reply.send({
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
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion forge/ee/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 2 additions & 0 deletions forge/routes/auth/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/api/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -271,5 +296,9 @@ export default {
restartDevice,
startDevice,
generateSnapshotDescription,
getDeviceEditorProxy
getDeviceEditorProxy,
getHTTPTokens,
createHTTPToken,
updateHTTPToken,
deleteHTTPToken
}
Loading
Loading