Skip to content
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
31 changes: 31 additions & 0 deletions forge/db/controllers/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,37 @@ module.exports = {
}
await userRole.destroy()

// Clean up scoped PAT entries that reference this team.
// Find which tokens had scopes for this team, remove those
// entries, then delete any tokens left with zero team scopes
// (they were scoped exclusively to the removed team and would
// otherwise silently escalate to team-global access).
const affectedScopes = await app.db.models.AccessTokenTeamScope.findAll({
where: { UserId: user.id, TeamId: team.id },
attributes: ['AccessTokenId']
})
const affectedTokenIds = affectedScopes.map(s => s.AccessTokenId)
if (affectedTokenIds.length > 0) {
await app.db.sequelize.transaction(async (t) => {
await app.db.models.AccessTokenTeamScope.destroy({
where: { UserId: user.id, TeamId: team.id },
transaction: t
})
for (const tokenId of affectedTokenIds) {
const remaining = await app.db.models.AccessTokenTeamScope.count({
where: { AccessTokenId: tokenId },
transaction: t
})
if (remaining === 0) {
await app.db.models.AccessToken.destroy({
where: { id: tokenId },
transaction: t
})
}
}
})
}

await app.db.controllers.StorageSession.removeUserFromTeamSessions(user, team)

return true
Expand Down
2 changes: 1 addition & 1 deletion forge/routes/api/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ module.exports = async function (app) {
})

app.post('/expert-agent-creds', {
preHandler: app.needsPermission('platform:expert-agent:creds'),
preHandler: [app.blockPAT, app.needsPermission('platform:expert-agent:creds')],
schema: {
summary: 'Regenerate expert agent credentials - admin-only',
tags: ['Platform'],
Expand Down
6 changes: 3 additions & 3 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ module.exports = async function (app) {
* Start device logging
*/
app.post('/:deviceId/logs', {
preHandler: app.needsPermission('device:read'),
preHandler: [app.blockPAT, app.needsPermission('device:read')],
schema: {
summary: 'Start device logging',
tags: ['Devices'],
Expand Down Expand Up @@ -909,10 +909,10 @@ module.exports = async function (app) {
})

/**
* Start device resouce stream
* Start a device resource stream
*/
app.post('/:deviceId/resources', {
preHandler: app.needsPermission('device:read'),
preHandler: [app.blockPAT, app.needsPermission('device:read')],
schema: {
summary: 'Start device logging',
tags: ['Devices'],
Expand Down
2 changes: 1 addition & 1 deletion forge/routes/api/team.js
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,7 @@ module.exports = async function (app) {
* @name /api/v1/teams/:teamId/comms-credentials
*/
app.post('/:teamId/comms-credentials', {
preHandler: app.needsPermission('team:read'),
preHandler: [app.blockPAT, app.needsPermission('team:read')],
schema: {
summary: 'Issue team-channel broker credentials for the current user/session',
tags: ['Teams'],
Expand Down
2 changes: 1 addition & 1 deletion forge/routes/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ module.exports = async function (app) {
* Initialize expert chat
*/
app.post('/expert-creds', {
// preHandler: app.needsPermission('xxx'), // all users can start an expert chat, but we might want to add a permission later
preHandler: app.blockPAT,
schema: {
summary: 'Initialize expert chat',
tags: ['User'],
Expand Down
14 changes: 14 additions & 0 deletions forge/routes/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ async function init (app, opts) {
request.requestContext.set('isPAT', true)
request.requestContext.set('pat', patMetadata)

// When adminOptIn is false, shadow the admin flag on
// the session User so all downstream permission checks
// treat this request as non-admin.
// Using Object.defineProperty instead of direct assignment
// to avoid mutating the Sequelize model's dataValues,
// which would persist to the database if .save() is
// called later in the request lifecycle.
if (request.session.User.admin && !patMetadata.adminOptIn) {
Object.defineProperty(request.session.User, 'admin', {
value: false,
configurable: true
})
}

// Temp hack to give token full user scope
delete request.session.scope
}
Expand Down
74 changes: 72 additions & 2 deletions forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
const { requestContext } = require('@fastify/request-context')
const fp = require('fastify-plugin')

const { Permissions } = require('../../lib/permissions')
const { Roles } = require('../../lib/roles.js')

/**
* Check whether a PAT's teamScopes allow access to the given team.
* @param {object|null} pat - The PAT metadata object (from session or requestContext)
* @param {string} teamHashId - The hashid of the team being accessed
* @returns {boolean} true if access is allowed, false if denied
*/
function patAllowsTeam (pat, teamHashId) {
if (!pat.teamScopes) {
return true
}
return pat.teamScopes.some(scope => Object.prototype.hasOwnProperty.call(scope, teamHashId))
}

/**
* Check whether a PAT's readOnly flag allows the given permission.
* @param {object|null} pat - The PAT metadata object
* @param {string} scope - The permission scope string
* @returns {boolean} true if access is allowed, false if denied
*/
function patAllowsWrite (pat, scope) {
if (!pat.readOnly) {
return true
}
const permission = Permissions[scope]
return !permission || permission.access === 'read'
}
// For device/project tokens, list the scopes they implicitly have.
// This will allow us to add scopes to existing tokens without having to update
// them (as that requires reprovisioning of devices and restaging of projects)
Expand Down Expand Up @@ -61,6 +89,20 @@ module.exports = fp(async function (app, opts) {
if (!teamMembership) {
return false
}

// PAT scope enforcement via requestContext (set during auth).
const isPAT = requestContext.get('isPAT')
if (isPAT) {
const pat = requestContext.get('pat')
if (!patAllowsWrite(pat, scope)) {
return false
}
const teamHashId = app.db.models.Team.encodeHashid(teamMembership.TeamId)
if (!patAllowsTeam(pat, teamHashId)) {
return false
}
}

let userRole = teamMembership.role
// Granular RBAC; if the request provides an application context for the request, check against
// the teamMembership.permissions object
Expand All @@ -80,8 +122,23 @@ module.exports = fp(async function (app, opts) {
return async (request, reply) => {
if (!request.session.scope && request.session.User && request.session.User.admin) {
// Admins get to have all the fun - as long as they are logged in and not
// using an access-token which has a reduced scope
return
// using an access-token which has a reduced scope.
// For PATs without adminOptIn, the admin flag is already stripped
// at the auth layer, but we double-check here as defense in depth.
if (!request.session.pat || request.session.pat.adminOptIn) {
// Even with admin bypass, PAT readOnly and teamScope still apply
if (request.session.pat) {
if (!patAllowsWrite(request.session.pat, scope)) {
reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' })
throw new Error()
}
if (request.team && !patAllowsTeam(request.session.pat, request.team.hashid)) {
reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' })
throw new Error()
}
}
return
}
}
// A user has permission based on:
// - the resource they are accessing
Expand Down Expand Up @@ -138,6 +195,19 @@ module.exports = fp(async function (app, opts) {
}
}
}
// PAT scope enforcement: check team scope and read-only restrictions.
// This runs after the standard permission checks pass, as an
// additional restriction layer for PAT-authenticated requests.
if (request.session.pat) {
if (request.team && !patAllowsTeam(request.session.pat, request.team.hashid)) {
reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' })
throw new Error()
}
if (!patAllowsWrite(request.session.pat, scope)) {
reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' })
throw new Error()
}
}
if (request.session.scope) {
// All things being equal, the user does have this permission
// But they are using an access_token that could be scoped down
Expand Down
23 changes: 17 additions & 6 deletions frontend/src/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,17 @@ const getPersonalAccessTokens = async () => {
}

/**
* Create new User Personal Access Token
* Create a new User Personal Access Token
* See [routes/api/user.js](../../../forge/routes/api/user.js)
* @param {string} name
* @param {string} scope
* @param {number} expiresAt
* @param {boolean} readOnly
* @param {boolean} adminOptIn
* @param {Array<string>} teamIds
*/
const createPersonalAccessToken = async (name, scope, expiresAt) => {
return client.post('/api/v1/user/tokens', { name, scope, expiresAt }).then(res => res.data)
const createPersonalAccessToken = async (name, scope, expiresAt, { readOnly, adminOptIn, teamIds } = {}) => {
return client.post('/api/v1/user/tokens', { name, scope, expiresAt, readOnly, adminOptIn, teamIds }).then(res => res.data)
}

/**
Expand All @@ -224,10 +227,18 @@ const deletePersonalAccessToken = async (id) => {
}

/**
* Update User Personal Token
* Update User Personal Access Token
* See [routes/api/user.js](../../../forge/routes/api/user.js)
* @param {string} id The token ID to update
* @param {string} scope The token scope
* @param {number} expiresAt Expiration timestamp
* @param {Object} options Optional configuration
* @param {boolean} options.readOnly Whether the token is read-only
* @param {boolean} options.adminOptIn Whether admin opt-in is enabled
* @param {Array<string>} options.teamIds Array of team IDs the token is scoped to
*/
const updatePersonalAccessToken = async (id, scope, expiresAt) => {
return client.put('/api/v1/user/tokens/' + id, { scope, expiresAt })
const updatePersonalAccessToken = async (id, scope, expiresAt, { readOnly, adminOptIn, teamIds } = {}) => {
return client.put('/api/v1/user/tokens/' + id, { scope, expiresAt, readOnly, adminOptIn, teamIds }).then(res => res.data)
}

const enableMFA = async () => {
Expand Down
74 changes: 67 additions & 7 deletions frontend/src/pages/account/Security/Tokens.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<template #actions>
<ff-button data-action="new-token" @click="newToken()">
<template #icon-left>
<PlusSmallIcon />
<PlusIcon />
</template>
Add Token
</ff-button>
Expand All @@ -29,7 +29,7 @@
</template>

<script>
import { PlusSmallIcon } from '@heroicons/vue/24/outline'
import { PlusIcon } from '@heroicons/vue/24/outline'
import { markRaw } from 'vue'

import userApi from '../../../api/user.js'
Expand All @@ -40,29 +40,89 @@ import ExpiryCell from '../components/ExpiryCell.vue'
import TokenCreated from './dialogs/TokenCreated.vue'
import TokenDialog from './dialogs/TokenDialog.vue'

import { pluralize } from '@/composables/strings/String.js'
import { useAccountAuthStore } from '@/stores/account-auth.js'

export default {
name: 'PersonalAccessTokens',
components: {
PlusSmallIcon,
PlusIcon,
SectionTopMenu,
TokenDialog,
TokenCreated
},
data () {
return {
loading: false,
tokens: [],
columns: [
tokens: []
}
},
computed: {
isAdmin () {
return useAccountAuthStore().isAdminUser
},
columns () {
return [
{ label: 'Name', key: 'name', sortable: true },
// { label: 'Scope', key: 'scope' },
{
label: 'Teams',
key: 'teams',
sortable: false,
component: {
is: markRaw({
name: 'TeamsCell',
props: ['teams'],
template: '<span :title="tooltip" style="cursor:help">{{ label }}</span>',
computed: {
label () {
if (!this.teams || this.teams.length === 0) {
return 'All Teams'
}
return 'Team Scoped'
},
tooltip () {
if (!this.teams || this.teams.length === 0) {
return 'This token has access to all teams in your account'
}
return `This Token is scoped to the following ${pluralize('team', this.teams.length)}: \n${this.teams.map(t => t.name).join('\n')}`
}
}
})
}
},
{
label: 'Read Only',
key: 'readOnly',
sortable: false,
component: {
is: markRaw({
name: 'ReadOnlyCell',
props: ['readOnly'],
template: '<span v-if="readOnly" class="ff-badge ff-badge--info">Read Only</span><span v-else></span>'
})
}
},
{
label: 'Admin Access',
key: 'adminOptIn',
sortable: false,
hidden: !this.isAdmin,
component: {
is: markRaw({
name: 'AdminOptInCell',
props: ['adminOptIn'],
template: '<span v-if="adminOptIn" class="text-green-500">&#x2714;</span><span v-else class="text-red-500">&#x2718;</span>'
})
}
},
{
label: 'Expires',
key: 'expiresAt',
component: {
is: markRaw(ExpiryCell)
}
}
]
].filter(col => !col.hidden)
}
},
mounted () {
Expand Down
Loading
Loading