diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 4fd7539d4a..b6887f0d3a 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -19,14 +19,15 @@ module.exports = { 'project:change-status': { description: 'Start/Stop Project', role: Roles.Owner }, 'project:edit': { description: 'Edit Project Settings', role: Roles.Owner }, 'project:edit-env': { description: 'Edit Project Environment Variables', role: Roles.Member }, - 'project:log': { description: 'Access Project Log', role: Roles.Member }, - 'project:audit-log': { description: 'Access Project Audit Log', role: Roles.Member }, + 'project:log': { description: 'Access Project Log', role: Roles.Viewer }, + 'project:audit-log': { description: 'Access Project Audit Log', role: Roles.Viewer }, // Project Editor - 'project:flows:view': { description: 'View Project Flows', role: Roles.Member }, + 'project:flows:view': { description: 'View Project Flows', role: Roles.Viewer }, 'project:flows:edit': { description: 'Edit Project Flows', role: Roles.Member }, 'project:snapshot:create': { description: 'Create Project Snapshot', role: Roles.Member }, 'project:snapshot:delete': { description: 'Delete Project Snapshot', role: Roles.Owner }, 'project:snapshot:rollback': { description: 'Rollback Project Snapshot', role: Roles.Member }, + 'project:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member }, // Templates 'template:create': { description: 'Create a Template', role: Roles.Admin }, 'template:delete': { description: 'Delete a Template', role: Roles.Admin }, diff --git a/forge/lib/roles.js b/forge/lib/roles.js index 22f9872d42..f7097579e0 100644 --- a/forge/lib/roles.js +++ b/forge/lib/roles.js @@ -1,17 +1,20 @@ const Roles = { None: 0, + Viewer: 10, Member: 30, Owner: 50, Admin: 99 } const RoleNames = { [Roles.None]: 'none', + [Roles.Viewer]: 'viewer', [Roles.Member]: 'member', [Roles.Owner]: 'owner', [Roles.Admin]: 'admin' } const TeamRoles = [ + Roles.Viewer, Roles.Member, Roles.Owner ] diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index eff6e5f096..8fef81797e 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -321,9 +321,7 @@ module.exports = async function (app) { reply.send({ status: 'okay' }) }) - app.get('/:deviceId/settings', { - preHandler: app.needsPermission('device:edit-env') - }, async (request, reply) => { + app.get('/:deviceId/settings', async (request, reply) => { const settings = await request.device.getAllSettings() if (request.teamMembership?.role === Roles.Owner) { reply.send(settings) diff --git a/forge/routes/api/projectDevices.js b/forge/routes/api/projectDevices.js index a5265929fe..eca2115126 100644 --- a/forge/routes/api/projectDevices.js +++ b/forge/routes/api/projectDevices.js @@ -36,7 +36,7 @@ module.exports = async function (app) { reply.send(deviceSettings) }) - app.post('/settings', async (request, reply) => { + app.post('/settings', { preHandler: app.needsPermission('project:snapshot:set-target') }, async (request, reply) => { if (request.body.targetSnapshot) { // We currently only have `targetSnapshot` under deviceSettings. // For now, only care about that - when we add other device settings, this diff --git a/forge/routes/api/teamMembers.js b/forge/routes/api/teamMembers.js index ccfa1c3024..c37e9d7d16 100644 --- a/forge/routes/api/teamMembers.js +++ b/forge/routes/api/teamMembers.js @@ -1,4 +1,4 @@ -const { Roles } = require('../../lib/roles.js') +const { TeamRoles } = require('../../lib/roles.js') /** * Team Membership api routes @@ -93,7 +93,7 @@ module.exports = async function (app) { */ app.put('/:userId', { preHandler: app.needsPermission('team:user:change-role') }, async (request, reply) => { const newRole = parseInt(request.body.role) - if (newRole === Roles.Owner || newRole === Roles.Member) { + if (TeamRoles.includes(newRole)) { try { const result = await app.db.controllers.Team.changeUserRole(request.params.teamId, request.params.userId, newRole) if (result.oldRole !== result.role) { diff --git a/forge/routes/auth/oauth.js b/forge/routes/auth/oauth.js index eb1a40027a..8d088bb505 100644 --- a/forge/routes/auth/oauth.js +++ b/forge/routes/auth/oauth.js @@ -97,8 +97,8 @@ module.exports = async function (app) { if (code_challenge_method !== 'S256') { return redirectInvalidRequest(reply, redirect_uri, 'invalid_request', "Invalid code_challenge_method. Only 'S256' is supported", state) } - if (!/^editor$/.test(scope)) { - return redirectInvalidRequest(reply, redirect_uri, 'invalid_request', "Invalid scope '" + scope + "'. Only 'editor' is supported", state) + if (!/^editor($|-)/.test(scope)) { + return redirectInvalidRequest(reply, redirect_uri, 'invalid_request', "Invalid scope '" + scope + "'. Only 'editor[-version]' is supported", state) } const requestObject = { @@ -146,6 +146,18 @@ module.exports = async function (app) { if (!teamMembership) { return redirectInvalidRequest(reply, requestObject.redirect_uri, 'access_denied', 'Access Denied', requestObject.state) } + const canReadFlows = app.hasPermission(teamMembership, 'project:flows:view') + const canWriteFlows = app.hasPermission(teamMembership, 'project:flows:edit') + if (!canReadFlows && !canWriteFlows) { + return redirectInvalidRequest(reply, requestObject.redirect_uri, 'access_denied', 'Access Denied', requestObject.state) + } + if (!canWriteFlows && requestObject.scope === 'editor') { + // Older versions of nr-auth do not know how to apply read-only + // access. We know it is an older version because it set scope to `editor`. + // Versions that support viewer will have a scope of `editor-`. + reply.code(400).send('Please ask the team owner to update this project to the latest stack to support viewer access') + return + } requestObject.username = request.session.User.username requestObject.code = base64URLEncode(crypto.randomBytes(32)) requestCache.set(requestObject.code, requestObject) @@ -233,11 +245,27 @@ module.exports = async function (app) { const sessionTokens = await app.db.controllers.Session.createTokenSession(requestObject.username) + const project = await app.db.models.Project.byId(authClient.ownerId) + const teamMembership = await app.db.models.TeamMember.findOne({ where: { TeamId: project.TeamId, UserId: sessionTokens.UserId } }) + const canReadFlows = app.hasPermission(teamMembership, 'project:flows:view') + const canWriteFlows = app.hasPermission(teamMembership, 'project:flows:edit') + + if (!canReadFlows && !canWriteFlows) { + await sessionTokens.destroy() + return badRequest(reply, 'access_denied', 'Access Denied') + } + + let scope = '*' + if (!canWriteFlows) { + scope = 'read' + } + const response = { access_token: sessionTokens.sid, expires_in: Math.floor((sessionTokens.expiresAt - Date.now()) / 1000), refresh_token: sessionTokens.refreshToken, - state: requestObject.state + state: requestObject.state, + scope } reply.send(response) } else if (grant_type === 'refresh_token') { @@ -249,15 +277,18 @@ module.exports = async function (app) { // this client is owned by const project = await app.db.models.Project.byId(authClient.ownerId) const teamMembership = await app.db.models.TeamMember.findOne({ where: { TeamId: project.TeamId, UserId: existingSession.UserId } }) - if (!teamMembership) { + const canReadFlows = app.hasPermission(teamMembership, 'project:flows:view') + const canWriteFlows = app.hasPermission(teamMembership, 'project:flows:edit') + + if (!canReadFlows && !canWriteFlows) { return badRequest(reply, 'access_denied', 'Access Denied') } - const sessionTokens = await app.db.controllers.Session.refreshTokenSession(refresh_token) if (!sessionTokens) { badRequest(reply, 'invalid_request', 'Invalid refresh_token') return } + const response = { access_token: sessionTokens.sid, expires_in: Math.floor((sessionTokens.expiresAt - Date.now()) / 1000), diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 4fd2bfa1b7..ed4a45d2a7 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -2,6 +2,13 @@ const fp = require('fastify-plugin') const { Permissions } = require('../../lib/permissions') module.exports = fp(async function (app, opts, done) { + function hasPermission (teamMembership, scope) { + if (!teamMembership) { + return false + } + const permission = Permissions[scope] + return teamMembership.role >= permission.role + } function needsPermission (scope) { if (!Permissions[scope]) { throw new Error(`Unrecognised scope requested: '${scope}'`) @@ -47,6 +54,7 @@ module.exports = fp(async function (app, opts, done) { } } + app.decorate('hasPermission', hasPermission) app.decorate('needsPermission', needsPermission) done() }) diff --git a/frontend/src/pages/project/Settings/Environment.vue b/frontend/src/pages/project/Settings/Environment.vue index 598d005556..14fefd8d86 100644 --- a/frontend/src/pages/project/Settings/Environment.vue +++ b/frontend/src/pages/project/Settings/Environment.vue @@ -1,14 +1,17 @@