Skip to content

Commit bc4e189

Browse files
authored
Scoped PATs: clean up team scope entries on member removal (#7778)
1 parent eb45ce3 commit bc4e189

11 files changed

Lines changed: 473 additions & 50 deletions

File tree

forge/db/controllers/Team.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,37 @@ module.exports = {
134134
}
135135
await userRole.destroy()
136136

137+
// Clean up scoped PAT entries that reference this team.
138+
// Find which tokens had scopes for this team, remove those
139+
// entries, then delete any tokens left with zero team scopes
140+
// (they were scoped exclusively to the removed team and would
141+
// otherwise silently escalate to team-global access).
142+
const affectedScopes = await app.db.models.AccessTokenTeamScope.findAll({
143+
where: { UserId: user.id, TeamId: team.id },
144+
attributes: ['AccessTokenId']
145+
})
146+
const affectedTokenIds = affectedScopes.map(s => s.AccessTokenId)
147+
if (affectedTokenIds.length > 0) {
148+
await app.db.sequelize.transaction(async (t) => {
149+
await app.db.models.AccessTokenTeamScope.destroy({
150+
where: { UserId: user.id, TeamId: team.id },
151+
transaction: t
152+
})
153+
for (const tokenId of affectedTokenIds) {
154+
const remaining = await app.db.models.AccessTokenTeamScope.count({
155+
where: { AccessTokenId: tokenId },
156+
transaction: t
157+
})
158+
if (remaining === 0) {
159+
await app.db.models.AccessToken.destroy({
160+
where: { id: tokenId },
161+
transaction: t
162+
})
163+
}
164+
}
165+
})
166+
}
167+
137168
await app.db.controllers.StorageSession.removeUserFromTeamSessions(user, team)
138169

139170
return true

forge/routes/api/admin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ module.exports = async function (app) {
401401
})
402402

403403
app.post('/expert-agent-creds', {
404-
preHandler: app.needsPermission('platform:expert-agent:creds'),
404+
preHandler: [app.blockPAT, app.needsPermission('platform:expert-agent:creds')],
405405
schema: {
406406
summary: 'Regenerate expert agent credentials - admin-only',
407407
tags: ['Platform'],

forge/routes/api/device.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ module.exports = async function (app) {
875875
* Start device logging
876876
*/
877877
app.post('/:deviceId/logs', {
878-
preHandler: app.needsPermission('device:read'),
878+
preHandler: [app.blockPAT, app.needsPermission('device:read')],
879879
schema: {
880880
summary: 'Start device logging',
881881
tags: ['Devices'],
@@ -909,10 +909,10 @@ module.exports = async function (app) {
909909
})
910910

911911
/**
912-
* Start device resouce stream
912+
* Start a device resource stream
913913
*/
914914
app.post('/:deviceId/resources', {
915-
preHandler: app.needsPermission('device:read'),
915+
preHandler: [app.blockPAT, app.needsPermission('device:read')],
916916
schema: {
917917
summary: 'Start device logging',
918918
tags: ['Devices'],

forge/routes/api/team.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -952,7 +952,7 @@ module.exports = async function (app) {
952952
* @name /api/v1/teams/:teamId/comms-credentials
953953
*/
954954
app.post('/:teamId/comms-credentials', {
955-
preHandler: app.needsPermission('team:read'),
955+
preHandler: [app.blockPAT, app.needsPermission('team:read')],
956956
schema: {
957957
summary: 'Issue team-channel broker credentials for the current user/session',
958958
tags: ['Teams'],

forge/routes/api/user.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ module.exports = async function (app) {
435435
* Initialize expert chat
436436
*/
437437
app.post('/expert-creds', {
438-
// preHandler: app.needsPermission('xxx'), // all users can start an expert chat, but we might want to add a permission later
438+
preHandler: app.blockPAT,
439439
schema: {
440440
summary: 'Initialize expert chat',
441441
tags: ['User'],

frontend/src/api/user.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,17 @@ const getPersonalAccessTokens = async () => {
204204
}
205205

206206
/**
207-
* Create new User Personal Access Token
207+
* Create a new User Personal Access Token
208208
* See [routes/api/user.js](../../../forge/routes/api/user.js)
209209
* @param {string} name
210210
* @param {string} scope
211211
* @param {number} expiresAt
212+
* @param {boolean} readOnly
213+
* @param {boolean} adminOptIn
214+
* @param {Array<string>} teamIds
212215
*/
213-
const createPersonalAccessToken = async (name, scope, expiresAt) => {
214-
return client.post('/api/v1/user/tokens', { name, scope, expiresAt }).then(res => res.data)
216+
const createPersonalAccessToken = async (name, scope, expiresAt, { readOnly, adminOptIn, teamIds } = {}) => {
217+
return client.post('/api/v1/user/tokens', { name, scope, expiresAt, readOnly, adminOptIn, teamIds }).then(res => res.data)
215218
}
216219

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

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

233244
const enableMFA = async () => {

frontend/src/pages/account/Security/Tokens.vue

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<template #actions>
1010
<ff-button data-action="new-token" @click="newToken()">
1111
<template #icon-left>
12-
<PlusSmallIcon />
12+
<PlusIcon />
1313
</template>
1414
Add Token
1515
</ff-button>
@@ -29,7 +29,7 @@
2929
</template>
3030

3131
<script>
32-
import { PlusSmallIcon } from '@heroicons/vue/24/outline'
32+
import { PlusIcon } from '@heroicons/vue/24/outline'
3333
import { markRaw } from 'vue'
3434
3535
import userApi from '../../../api/user.js'
@@ -40,29 +40,89 @@ import ExpiryCell from '../components/ExpiryCell.vue'
4040
import TokenCreated from './dialogs/TokenCreated.vue'
4141
import TokenDialog from './dialogs/TokenDialog.vue'
4242
43+
import { pluralize } from '@/composables/strings/String.js'
44+
import { useAccountAuthStore } from '@/stores/account-auth.js'
45+
4346
export default {
4447
name: 'PersonalAccessTokens',
4548
components: {
46-
PlusSmallIcon,
49+
PlusIcon,
4750
SectionTopMenu,
4851
TokenDialog,
4952
TokenCreated
5053
},
5154
data () {
5255
return {
5356
loading: false,
54-
tokens: [],
55-
columns: [
57+
tokens: []
58+
}
59+
},
60+
computed: {
61+
isAdmin () {
62+
return useAccountAuthStore().isAdminUser
63+
},
64+
columns () {
65+
return [
5666
{ label: 'Name', key: 'name', sortable: true },
57-
// { label: 'Scope', key: 'scope' },
67+
{
68+
label: 'Teams',
69+
key: 'teams',
70+
sortable: false,
71+
component: {
72+
is: markRaw({
73+
name: 'TeamsCell',
74+
props: ['teams'],
75+
template: '<span :title="tooltip" style="cursor:help">{{ label }}</span>',
76+
computed: {
77+
label () {
78+
if (!this.teams || this.teams.length === 0) {
79+
return 'All Teams'
80+
}
81+
return 'Team Scoped'
82+
},
83+
tooltip () {
84+
if (!this.teams || this.teams.length === 0) {
85+
return 'This token has access to all teams in your account'
86+
}
87+
return `This Token is scoped to the following ${pluralize('team', this.teams.length)}: \n${this.teams.map(t => t.name).join('\n')}`
88+
}
89+
}
90+
})
91+
}
92+
},
93+
{
94+
label: 'Read Only',
95+
key: 'readOnly',
96+
sortable: false,
97+
component: {
98+
is: markRaw({
99+
name: 'ReadOnlyCell',
100+
props: ['readOnly'],
101+
template: '<span v-if="readOnly" class="ff-badge ff-badge--info">Read Only</span><span v-else></span>'
102+
})
103+
}
104+
},
105+
{
106+
label: 'Admin Access',
107+
key: 'adminOptIn',
108+
sortable: false,
109+
hidden: !this.isAdmin,
110+
component: {
111+
is: markRaw({
112+
name: 'AdminOptInCell',
113+
props: ['adminOptIn'],
114+
template: '<span v-if="adminOptIn" class="text-green-500">&#x2714;</span><span v-else class="text-red-500">&#x2718;</span>'
115+
})
116+
}
117+
},
58118
{
59119
label: 'Expires',
60120
key: 'expiresAt',
61121
component: {
62122
is: markRaw(ExpiryCell)
63123
}
64124
}
65-
]
125+
].filter(col => !col.hidden)
66126
}
67127
},
68128
mounted () {

0 commit comments

Comments
 (0)