Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Viewer role #1005

Merged
merged 3 commits into from
Sep 22, 2022
Merged
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
7 changes: 4 additions & 3 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions forge/lib/roles.js
Original file line number Diff line number Diff line change
@@ -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
]
Expand Down
4 changes: 1 addition & 3 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion forge/routes/api/projectDevices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions forge/routes/api/teamMembers.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { Roles } = require('../../lib/roles.js')
const { TeamRoles } = require('../../lib/roles.js')

/**
* Team Membership api routes
Expand Down Expand Up @@ -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) {
Expand Down
41 changes: 36 additions & 5 deletions forge/routes/auth/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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-<version>`.
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)
Expand Down Expand Up @@ -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') {
Expand All @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`)
Expand Down Expand Up @@ -47,6 +54,7 @@ module.exports = fp(async function (app, opts, done) {
}
}

app.decorate('hasPermission', hasPermission)
app.decorate('needsPermission', needsPermission)
done()
})
11 changes: 9 additions & 2 deletions frontend/src/pages/project/Settings/Environment.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<template>
<form class="space-y-6">
<TemplateSettingsEnvironment v-model="editable" :editTemplate="false" />
<div class="space-x-4 whitespace-nowrap">
<TemplateSettingsEnvironment :readOnly="!hasPermission('device:edit-env')" v-model="editable" :editTemplate="false" />
<div v-if="hasPermission('device:edit-env')" class="space-x-4 whitespace-nowrap">
<ff-button size="small" :disabled="!unsavedChanges" @click="saveSettings()">Save settings</ff-button>
</div>
</form>
</template>

<script>
import { mapState } from 'vuex'

import alerts from '@/services/alerts'
import permissionsMixin from '@/mixins/Permissions'

import projectApi from '@/api/project'
import TemplateSettingsEnvironment from '../../admin/Template/sections/Environment'
Expand All @@ -18,6 +21,7 @@ import {

export default {
name: 'ProjectSettingsEnvironment',
mixins: [permissionsMixin],
data () {
return {
unsavedChanges: false,
Expand All @@ -38,6 +42,9 @@ export default {
}
},
props: ['project'],
computed: {
...mapState('account', ['teamMembership'])
},
watch: {
project: 'getSettings',
'editable.settings.env': {
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/pages/project/Snapshots/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
<template v-slot:actions v-if="hasPermission('project:snapshot:create')">
<ff-button kind="primary" @click="showCreateSnapshotDialog" data-action="create-snapshot"><template v-slot:icon-left><PlusSmIcon /></template>Create Snapshot</ff-button>
</template>
<template v-slot:context-menu="{row}">
<ff-list-item label="Rollback" @click="showRollbackDialog(row)" />
<ff-list-item v-if="features.devices" label="Set as Device Target" @click="showDeviceTargetDialog(row)"/>
<template v-if="showContextMenu" v-slot:context-menu="{row}">
<ff-list-item v-if="hasPermission('project:snapshot:rollback')" label="Rollback" @click="showRollbackDialog(row)" />
<ff-list-item v-if="features.devices && hasPermission('project:snapshot:set-target')" label="Set as Device Target" @click="showDeviceTargetDialog(row)"/>
<ff-list-item v-if="hasPermission('project:snapshot:delete')" label="Delete Snapshot" kind="danger" @click="showDeleteSnapshotDialog(row)"/>
</template>
</ff-data-table>
Expand Down Expand Up @@ -131,6 +131,9 @@ export default {
},
computed: {
...mapState('account', ['features', 'teamMembership']),
showContextMenu: function () {
return this.hasPermission('project:snapshot:rollback') || this.hasPermission('project:snapshot:set-target') || this.hasPermission('project:snapshot:delete')
},
columns: function () {
const devicesEnabled = this.features.devices
const targetSnapshot = this.features.devices && this.project.deviceSettings?.targetSnapshot
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/team/dialogs/ChangeTeamRoleDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
<FormRow id="role-member" :value="Roles.Member" v-model="input.role" type="radio">Member
<template v-slot:description>Members can access the team projects</template>
</FormRow>
<FormRow id="role-member" :value="Roles.Viewer" v-model="input.role" type="radio">Viewer
<template v-slot:description>Viewers can access the team projects, but not make any changes</template>
</FormRow>
</template>
</div>
</form>
Expand Down