From ba095b260667dfe10df80f69c497a12df0f7b983 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 7 Jul 2026 12:28:04 +0300 Subject: [PATCH 1/9] Add support for scoped personal access tokens with readOnly and team-based permissions --- forge/db/controllers/AccessToken.js | 73 ++++++++-- forge/db/views/AccessToken.js | 22 ++- forge/routes/api/user.js | 59 +++++++- forge/routes/auth/index.js | 6 + test/unit/forge/routes/api/user_spec.js | 179 ++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 19 deletions(-) diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index 6c7567c24f..6448a7342e 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -265,23 +265,43 @@ module.exports = { await app.settings.set('platform:stats:token', false) }, - createPersonalAccessToken: async function (app, user, scope, expiresAt, name) { + createPersonalAccessToken: async function (app, user, scope, expiresAt, name, { readOnly = false, adminOptIn = false, teamIds = [] } = {}) { const userId = typeof user === 'number' ? user : user.id const token = generateToken(32, 'ffpat') - const tok = await app.db.models.AccessToken.create({ - name, - token, - scope, - expiresAt, - ownerId: '' + userId, - ownerType: 'user' + let tokId + await app.db.sequelize.transaction(async (t) => { + const tok = await app.db.models.AccessToken.create({ + name, + token, + scope, + expiresAt, + readOnly, + adminOptIn, + ownerId: '' + userId, + ownerType: 'user' + }, { transaction: t }) + tokId = tok.id + if (teamIds.length > 0) { + const scopes = teamIds.map(teamId => ({ + AccessTokenId: tok.id, + TeamId: app.db.models.Team.decodeHashid(teamId), + UserId: userId + })) + await app.db.models.AccessTokenTeamScope.bulkCreate(scopes, { transaction: t }) + } }) - // Overwrite the hashed token with the plain value - const result = app.db.views.AccessToken.personalAccessTokenSummary(tok) + const reloaded = await app.db.models.AccessToken.findOne({ + where: { id: tokId }, + include: [{ + model: app.db.models.AccessTokenTeamScope, + include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }] + }] + }) + const result = app.db.views.AccessToken.personalAccessTokenSummary(reloaded) result.token = token return result }, - updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt) { + updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt, { readOnly, adminOptIn, teamIds } = {}) { const userId = typeof user === 'number' ? user : user.id const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId) if (token) { @@ -291,12 +311,41 @@ module.exports = { } else { token.expiresAt = expiresAt } + if (readOnly !== undefined) { + token.readOnly = readOnly + } + if (adminOptIn !== undefined) { + token.adminOptIn = adminOptIn + } await token.save() + if (teamIds !== undefined) { + await app.db.sequelize.transaction(async (t) => { + await app.db.models.AccessTokenTeamScope.destroy({ + where: { AccessTokenId: token.id }, + transaction: t + }) + if (teamIds.length > 0) { + const scopes = teamIds.map(teamId => ({ + AccessTokenId: token.id, + TeamId: app.db.models.Team.decodeHashid(teamId), + UserId: userId + })) + await app.db.models.AccessTokenTeamScope.bulkCreate(scopes, { transaction: t }) + } + }) + } + const reloaded = await app.db.models.AccessToken.findOne({ + where: { id: token.id }, + include: [{ + model: app.db.models.AccessTokenTeamScope, + include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }] + }] + }) + return reloaded } else { // should throw unknown token error throw new Error('Not Found') } - return token }, // Should these only get added via forge/ee/lib/httpTokens? diff --git a/forge/db/views/AccessToken.js b/forge/db/views/AccessToken.js index d0da1c3853..3d4b588a23 100644 --- a/forge/db/views/AccessToken.js +++ b/forge/db/views/AccessToken.js @@ -63,7 +63,19 @@ module.exports = function (app) { id: { type: 'string' }, name: { type: 'string' }, // scope: { type: 'string', nullable: true }, - expiresAt: { type: 'string', nullable: true } + expiresAt: { type: 'string', nullable: true }, + readOnly: { type: 'boolean' }, + adminOptIn: { type: 'boolean' }, + teams: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string', nullable: true } + } + } + } }, required: ['id', 'name', 'expiresAt'] }) @@ -81,7 +93,13 @@ module.exports = function (app) { const tokenSummary = { id: token.hashid, name: token.name, - expiresAt: token.expiresAt ?? null + expiresAt: token.expiresAt ?? null, + readOnly: token.readOnly ?? false, + adminOptIn: token.adminOptIn ?? false, + teams: (token.AccessTokenTeamScopes ?? []).map(s => ({ + id: app.db.models.Team.encodeHashid(s.TeamId), + name: s.Team?.name ?? null + })) } return tokenSummary } diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index dd5618b148..a1539af427 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -263,7 +263,10 @@ module.exports = async function (app) { properties: { scope: { type: 'string' }, expiresAt: { type: 'number' }, - name: { type: 'string' } + name: { type: 'string' }, + readOnly: { type: 'boolean' }, + adminOptIn: { type: 'boolean' }, + teamIds: { type: 'array', items: { type: 'string' } } } }, response: { @@ -276,11 +279,32 @@ module.exports = async function (app) { } } }, async (request, reply) => { + if (request.session.isPAT) { + reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' }) + return + } const updates = new app.auditLog.formatters.UpdatesCollection() try { const body = request.body - const token = await app.db.controllers.AccessToken.createPersonalAccessToken(request.session.User, body.scope, body.expiresAt, body.name) - // token has already been sanitised via views.AccessToken.personalAccessToken + const teamIds = body.teamIds ?? [] + if (teamIds.length > 0) { + for (const teamId of teamIds) { + const decodedId = app.db.models.Team.decodeHashid(teamId) + const membership = decodedId ? await app.db.models.TeamMember.findOne({ where: { UserId: request.session.User.id, TeamId: decodedId } }) : null + if (!membership) { + reply.code(400).send({ code: 'invalid_team', error: `Not a member of team: ${teamId}` }) + return + } + } + } + if (body.adminOptIn === true && !request.session.User.admin) { + reply.code(403).send({ code: 'unauthorized', error: 'Admin access required to set adminOptIn' }) + return + } + const token = await app.db.controllers.AccessToken.createPersonalAccessToken( + request.session.User, body.scope, body.expiresAt, body.name, + { readOnly: body.readOnly, adminOptIn: body.adminOptIn, teamIds } + ) updates.push('id', token.id) updates.push('name', token.name) updates.push('scope', body.scope) @@ -355,7 +379,10 @@ module.exports = async function (app) { type: 'object', properties: { scope: { type: 'string' }, - expiresAt: { type: 'number' } + expiresAt: { type: 'number' }, + readOnly: { type: 'boolean' }, + adminOptIn: { type: 'boolean' }, + teamIds: { type: 'array', items: { type: 'string' } } } }, response: { @@ -368,12 +395,34 @@ module.exports = async function (app) { } } }, async (request, reply) => { + if (request.session.isPAT) { + reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' }) + return + } const updates = new app.auditLog.formatters.UpdatesCollection() try { const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id) if (oldToken) { const body = request.body - const newToken = await app.db.controllers.AccessToken.updatePersonalAccessToken(request.session.User, request.params.id, body.scope, body.expiresAt) + const teamIds = body.teamIds + if (teamIds !== undefined && teamIds.length > 0) { + for (const teamId of teamIds) { + const decodedId = app.db.models.Team.decodeHashid(teamId) + const membership = decodedId ? await app.db.models.TeamMember.findOne({ where: { UserId: request.session.User.id, TeamId: decodedId } }) : null + if (!membership) { + reply.code(400).send({ code: 'invalid_team', error: `Not a member of team: ${teamId}` }) + return + } + } + } + if (body.adminOptIn === true && !request.session.User.admin) { + reply.code(403).send({ code: 'unauthorized', error: 'Admin access required to set adminOptIn' }) + return + } + const newToken = await app.db.controllers.AccessToken.updatePersonalAccessToken( + request.session.User, request.params.id, body.scope, body.expiresAt, + { readOnly: body.readOnly, adminOptIn: body.adminOptIn, teamIds } + ) updates.pushDifferences(oldToken, newToken) await app.auditLog.User.user.pat.updated(request.session.User, null, updates) reply.send(app.db.views.AccessToken.personalAccessTokenSummary(newToken)) diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index 2f1f0a9db3..cb6b1f77bc 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -121,6 +121,12 @@ async function init (app, opts) { } Sentry.setUser({ id: request.session.User.hashid, username: request.session.User.username, email: request.session.User.email, name: request.session.User.name }) if (accessToken.name) { + request.session.isPAT = true + request.session.pat = { + id: accessToken.id, + readOnly: accessToken.readOnly, + adminOptIn: accessToken.adminOptIn + } // Temp hack to give token full user scope delete request.session.scope } diff --git a/test/unit/forge/routes/api/user_spec.js b/test/unit/forge/routes/api/user_spec.js index 08620632f6..1f29061f8d 100644 --- a/test/unit/forge/routes/api/user_spec.js +++ b/test/unit/forge/routes/api/user_spec.js @@ -1041,6 +1041,185 @@ describe('User API', async function () { const tokens2 = await app.db.models.AccessToken.getPersonalAccessTokens({ id: userId }) tokens2.should.have.length(0) }) + + it('Create a PAT with readOnly and teamIds returns scoped fields', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Scoped Token', + scope: '', + readOnly: true, + adminOptIn: false, + teamIds: [TestObjects.ATeam.hashid] + } + }) + response.statusCode.should.equal(200) + const json = response.json() + json.should.have.property('readOnly', true) + json.should.have.property('adminOptIn', false) + json.should.have.property('teams').which.is.an.Array() + json.teams.should.have.length(1) + json.teams[0].should.have.property('id', TestObjects.ATeam.hashid) + json.teams[0].should.have.property('name', 'ATeam') + }) + + it('Non-admin cannot set adminOptIn: true', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.elvis }, + payload: { + name: 'Admin Token', + scope: '', + adminOptIn: true + } + }) + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + + it('teamIds referencing teams user does not belong to are rejected', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Bad Team Token', + scope: '', + teamIds: [TestObjects.BTeam.hashid] + } + }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_team') + }) + + it('Updating replaces team scopes', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Team Replace Token', + scope: '', + teamIds: [TestObjects.ATeam.hashid] + } + }) + createResponse.statusCode.should.equal(200) + const created = createResponse.json() + created.teams.should.have.length(1) + created.teams[0].id.should.equal(TestObjects.ATeam.hashid) + + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/v1/user/tokens/' + created.id, + cookies: { sid: TestObjects.tokens.alice }, + payload: { + scope: '', + teamIds: [] + } + }) + updateResponse.statusCode.should.equal(200) + const updated = updateResponse.json() + updated.should.have.property('teams').which.is.an.Array() + updated.teams.should.have.length(0) + }) + + it('GET /tokens returns readOnly, adminOptIn and teams', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const json = response.json() + for (const tok of json.tokens) { + tok.should.have.property('readOnly') + tok.should.have.property('adminOptIn') + tok.should.have.property('teams') + } + }) + + it('Create/update without new fields still works (backwards compat)', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'Compat Token', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const created = createResponse.json() + created.should.have.property('readOnly', false) + created.should.have.property('adminOptIn', false) + created.teams.should.have.length(0) + + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/v1/user/tokens/' + created.id, + cookies: { sid: TestObjects.tokens.alice }, + payload: { scope: '' } + }) + updateResponse.statusCode.should.equal(200) + }) + + it('PAT-authenticated POST /tokens returns 403', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'Bootstrap PAT', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const bootstrapToken = createResponse.json().token + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + headers: { authorization: `Bearer ${bootstrapToken}` }, + payload: { name: 'PAT via PAT', scope: '' } + }) + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'pat_cannot_create_pat') + }) + + it('PAT-authenticated PUT /tokens/:id returns 403', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'PAT for Update Test', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const { id, token: rawToken } = createResponse.json() + + const response = await app.inject({ + method: 'PUT', + url: '/api/v1/user/tokens/' + id, + headers: { authorization: `Bearer ${rawToken}` }, + payload: { scope: '' } + }) + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'pat_cannot_create_pat') + }) + + it('Cookie session can create tokens with readOnly and adminOptIn', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Admin Scoped Token', + scope: '', + readOnly: false, + adminOptIn: true + } + }) + response.statusCode.should.equal(200) + const json = response.json() + json.should.have.property('readOnly', false) + json.should.have.property('adminOptIn', true) + }) }) describe('User invites', async function () { From 220c609a5c15145411e1a9cd7504c9397839a5d7 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 7 Jul 2026 12:30:26 +0300 Subject: [PATCH 2/9] Add `blockPAT` preHandler to restrict PAT usage on specific routes and refactor duplicate PAT checks --- forge/routes/api/user.js | 10 ++-------- forge/routes/auth/index.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index a1539af427..a58e9d36af 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -252,6 +252,7 @@ module.exports = async function (app) { * /api/v1/user/pat */ app.post('/tokens', { + preHandler: app.blockPAT, config: { rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false }, @@ -279,10 +280,6 @@ module.exports = async function (app) { } } }, async (request, reply) => { - if (request.session.isPAT) { - reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' }) - return - } const updates = new app.auditLog.formatters.UpdatesCollection() try { const body = request.body @@ -366,6 +363,7 @@ module.exports = async function (app) { * /api/v1/user/tokens/:id */ app.put('/tokens/:id', { + preHandler: app.blockPAT, schema: { summary: 'Update users Personal Access Token', tags: ['Tokens'], @@ -395,10 +393,6 @@ module.exports = async function (app) { } } }, async (request, reply) => { - if (request.session.isPAT) { - reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' }) - return - } const updates = new app.auditLog.formatters.UpdatesCollection() try { const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id) diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index cb6b1f77bc..f14b6db16b 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -201,6 +201,20 @@ async function init (app, opts) { return new Error() }) + /** + * preHandler function that blocks requests authenticated via a Personal Access Token. + * Use on routes that must not be callable by PATs (e.g. creating or updating PATs). + * + * @name blockPAT + * @static + * @memberof forge + */ + app.decorate('blockPAT', async (request, reply) => { + if (request.session?.isPAT) { + reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' }) + } + }) + app.decorateRequest('session', null) app.decorateRequest('sid', null) From 63065a181ba94482ec4b57ad5c996c230f87cf3d Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 7 Jul 2026 14:20:20 +0300 Subject: [PATCH 3/9] Set `isPAT` and `pat` in request context during auth flow --- forge/routes/auth/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index f14b6db16b..416408f3a7 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -127,6 +127,8 @@ async function init (app, opts) { readOnly: accessToken.readOnly, adminOptIn: accessToken.adminOptIn } + request.requestContext.set('isPAT', true) + request.requestContext.set('pat', request.session.pat) // Temp hack to give token full user scope delete request.session.scope } From 0b76b1fd05353279dfd4fab63f22db0c299129b0 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 7 Jul 2026 14:34:28 +0300 Subject: [PATCH 4/9] Add `readOnly`, `adminOptIn`, and `teamIds` fields to generated types for PAT --- frontend/src/types/generated.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 322e7eed80..2103716288 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -863,6 +863,9 @@ export interface paths { scope?: string; expiresAt?: number; name?: string; + readOnly?: boolean; + adminOptIn?: boolean; + teamIds?: string[]; }; }; }; @@ -916,6 +919,9 @@ export interface paths { "application/json": { scope?: string; expiresAt?: number; + readOnly?: boolean; + adminOptIn?: boolean; + teamIds?: string[]; }; }; }; @@ -10235,6 +10241,12 @@ export interface components { id: string; name: string; expiresAt: string | null; + readOnly?: boolean; + adminOptIn?: boolean; + teams?: { + id?: string; + name?: string | null; + }[]; }; /** PersonalAccessToken */ PersonalAccessToken: { From 2461f7c77d5bea0032719a86afac54eb61457c6b Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 7 Jul 2026 18:45:46 +0300 Subject: [PATCH 5/9] Update PAT summary handling and add missing login test step --- forge/routes/api/user.js | 6 ++++-- test/unit/forge/routes/api/user_spec.js | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index a58e9d36af..212e09d52a 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -397,6 +397,7 @@ module.exports = async function (app) { try { const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id) if (oldToken) { + const oldSummary = app.db.views.AccessToken.personalAccessTokenSummary(oldToken) const body = request.body const teamIds = body.teamIds if (teamIds !== undefined && teamIds.length > 0) { @@ -417,9 +418,10 @@ module.exports = async function (app) { request.session.User, request.params.id, body.scope, body.expiresAt, { readOnly: body.readOnly, adminOptIn: body.adminOptIn, teamIds } ) - updates.pushDifferences(oldToken, newToken) + const newSummary = app.db.views.AccessToken.personalAccessTokenSummary(newToken) + updates.pushDifferences(oldSummary, newSummary) await app.auditLog.User.user.pat.updated(request.session.User, null, updates) - reply.send(app.db.views.AccessToken.personalAccessTokenSummary(newToken)) + reply.send(newSummary) return } reply.code(404).send({ code: 'not_found', error: 'Not Found' }) diff --git a/test/unit/forge/routes/api/user_spec.js b/test/unit/forge/routes/api/user_spec.js index 1f29061f8d..da40147f68 100644 --- a/test/unit/forge/routes/api/user_spec.js +++ b/test/unit/forge/routes/api/user_spec.js @@ -1066,6 +1066,7 @@ describe('User API', async function () { }) it('Non-admin cannot set adminOptIn: true', async function () { + await login('elvis', 'eePassword') const response = await app.inject({ method: 'POST', url: '/api/v1/user/tokens', From 2e248657973d0302a3473aa1b2bb1ee875b1bf65 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 8 Jul 2026 12:03:07 +0300 Subject: [PATCH 6/9] Add tests for PAT metadata, team scopes, and update auth session handling --- forge/db/controllers/AccessToken.js | 6 +- forge/routes/auth/index.js | 30 +++++++- test/unit/forge/routes/api/user_spec.js | 98 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index 6448a7342e..38111550a4 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -426,7 +426,11 @@ module.exports = { scope: { [Op.notIn]: ['password:reset', 'email:verify'] } - } + }, + include: [{ + model: app.db.models.AccessTokenTeamScope, + include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }] + }] }) if (accessToken) { if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) { diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index 416408f3a7..35f6bae4bd 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -121,14 +121,35 @@ async function init (app, opts) { } Sentry.setUser({ id: request.session.User.hashid, username: request.session.User.username, email: request.session.User.email, name: request.session.User.name }) if (accessToken.name) { - request.session.isPAT = true - request.session.pat = { + const scopes = accessToken.AccessTokenTeamScopes ?? [] + let teamScopes = null + + if (scopes.length > 0) { + const teamIds = scopes.map(s => s.TeamId) + const memberships = await app.db.models.TeamMember.findAll({ + where: { UserId: request.session.User.id, TeamId: teamIds } + }) + const roleByTeamId = {} + for (const m of memberships) { + roleByTeamId[m.TeamId] = m.role + } + teamScopes = scopes.map(s => ({ + [app.db.models.Team.encodeHashid(s.TeamId)]: roleByTeamId[s.TeamId] ?? null + })) + } + + const patMetadata = { id: accessToken.id, readOnly: accessToken.readOnly, - adminOptIn: accessToken.adminOptIn + adminOptIn: accessToken.adminOptIn, + teamScopes } + + request.session.isPAT = true + request.session.pat = patMetadata request.requestContext.set('isPAT', true) - request.requestContext.set('pat', request.session.pat) + request.requestContext.set('pat', patMetadata) + // Temp hack to give token full user scope delete request.session.scope } @@ -185,6 +206,7 @@ async function init (app, opts) { } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) } + app.decorate('verifySession', verifySession) /** diff --git a/test/unit/forge/routes/api/user_spec.js b/test/unit/forge/routes/api/user_spec.js index da40147f68..14491e48a2 100644 --- a/test/unit/forge/routes/api/user_spec.js +++ b/test/unit/forge/routes/api/user_spec.js @@ -1221,6 +1221,104 @@ describe('User API', async function () { json.should.have.property('readOnly', false) json.should.have.property('adminOptIn', true) }) + + describe('PAT auth metadata', async function () { + it('getOrExpire eager-loads AccessTokenTeamScopes for PATs', async function () { + // Create a PAT with team scopes + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Eager Load PAT', + scope: '', + teamIds: [TestObjects.ATeam.hashid] + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Call getOrExpire directly and verify includes + const accessToken = await app.db.controllers.AccessToken.getOrExpire(patToken) + accessToken.should.have.property('name', 'Eager Load PAT') + accessToken.should.have.property('readOnly') + accessToken.should.have.property('adminOptIn') + accessToken.should.have.property('AccessTokenTeamScopes') + accessToken.AccessTokenTeamScopes.should.be.an.Array() + accessToken.AccessTokenTeamScopes.should.have.length(1) + accessToken.AccessTokenTeamScopes[0].should.have.property('TeamId') + accessToken.AccessTokenTeamScopes[0].should.have.property('Team') + accessToken.AccessTokenTeamScopes[0].Team.should.have.property('name', 'ATeam') + }) + + it('getOrExpire returns empty AccessTokenTeamScopes for PATs without team scopes', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'No Scope PAT', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + const accessToken = await app.db.controllers.AccessToken.getOrExpire(patToken) + accessToken.should.have.property('name', 'No Scope PAT') + accessToken.AccessTokenTeamScopes.should.be.an.Array() + accessToken.AccessTokenTeamScopes.should.have.length(0) + }) + + it('PAT-authenticated request is identified as PAT (blockPAT rejects)', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'Block Test PAT', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // PAT can access normal routes + const userResponse = await app.inject({ + method: 'GET', + url: '/api/v1/user', + headers: { authorization: `Bearer ${patToken}` } + }) + userResponse.statusCode.should.equal(200) + + // But is blocked by blockPAT preHandler + const blockedResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + headers: { authorization: `Bearer ${patToken}` }, + payload: { name: 'Should Fail', scope: '' } + }) + blockedResponse.statusCode.should.equal(403) + blockedResponse.json().should.have.property('code', 'pat_cannot_create_pat') + }) + + it('cookie session is not identified as PAT', async function () { + // Cookie sessions should not be blocked by blockPAT + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'Cookie Session PAT', scope: '' } + }) + createResponse.statusCode.should.equal(200) + }) + + it('non-PAT bearer tokens are not identified as PAT', async function () { + const device = await app.factory.createDevice({ name: 'pat-test-device' }, TestObjects.ATeam, null, TestObjects.application) + const deviceToken = await app.db.controllers.AccessToken.createTokenForDevice(device) + + // Verify via getOrExpire that device tokens don't get AccessTokenTeamScopes + const accessToken = await app.db.controllers.AccessToken.getOrExpire(deviceToken.token) + should(accessToken.name).be.null() + // Device tokens have empty scopes array (no team scope entries) + accessToken.AccessTokenTeamScopes.should.be.an.Array() + accessToken.AccessTokenTeamScopes.should.have.length(0) + }) + }) }) describe('User invites', async function () { From 8c86ce1d47252f77abfc1d2555163df648417393 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 8 Jul 2026 15:34:24 +0300 Subject: [PATCH 7/9] Add tests for PAT scope enforcement and implement corresponding permission checks --- forge/routes/auth/index.js | 14 ++ forge/routes/auth/permissions.js | 74 +++++++- test/unit/forge/routes/api/user_spec.js | 228 +++++++++++++++++++++++- 3 files changed, 312 insertions(+), 4 deletions(-) diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index 35f6bae4bd..220c7cd15a 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -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 } diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 15a713be0c..f44d0514d1 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -1,7 +1,35 @@ const fp = require('fastify-plugin') +const { requestContext } = require('@fastify/request-context') 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) @@ -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 @@ -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 @@ -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 diff --git a/test/unit/forge/routes/api/user_spec.js b/test/unit/forge/routes/api/user_spec.js index 14491e48a2..e0f37de398 100644 --- a/test/unit/forge/routes/api/user_spec.js +++ b/test/unit/forge/routes/api/user_spec.js @@ -973,14 +973,14 @@ describe('User API', async function () { const response2 = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.BTeam.hashid}`, + url: `/api/v1/teams/${TestObjects.ATeam.hashid}`, headers: { authorization: `Bearer ${token}` } }) response2.statusCode.should.equal(200) const team = response2.json() - team.name.should.equal('BTeam') + team.name.should.equal('ATeam') }) it('User cannot modify/delete a token they do not own', async function () { await login('bob', 'bbPassword') @@ -1319,6 +1319,230 @@ describe('User API', async function () { accessToken.AccessTokenTeamScopes.should.have.length(0) }) }) + + describe('PAT scope enforcement', async function () { + // alice: admin, ATeam owner + // bob: admin, ATeam owner, BTeam owner + + it('team-scoped PAT can access allowed team', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'ATeam Scoped', + scope: '', + teamIds: [TestObjects.ATeam.hashid] + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('name', 'ATeam') + }) + + it('team-scoped PAT cannot access a team outside its scope', async function () { + await login('bob', 'bbPassword') + // bob is member of both ATeam and BTeam, but scope the PAT to ATeam only + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.bob }, + payload: { + name: 'ATeam Only', + scope: '', + teamIds: [TestObjects.ATeam.hashid] + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Accessing BTeam should fail + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + // 404 from the shared team preHandler nullifying membership, + // or 403 from needsPermission team scope check + response.statusCode.should.be.oneOf([403, 404]) + }) + + it('team-agnostic PAT (no teamScopes) can access any team the user belongs to', async function () { + await login('bob', 'bbPassword') + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.bob }, + payload: { name: 'No Scope', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Can access both ATeam and BTeam + const aResponse = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + aResponse.statusCode.should.equal(200) + + const bResponse = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + bResponse.statusCode.should.equal(200) + }) + + it('read-only PAT can call read endpoints', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'ReadOnly PAT', + scope: '', + readOnly: true + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // GET team (read) should succeed + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + response.statusCode.should.equal(200) + }) + + it('read-only PAT cannot call write endpoints', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'ReadOnly Write Test', + scope: '', + readOnly: true + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // PUT user (write) should be rejected + const response = await app.inject({ + method: 'PUT', + url: '/api/v1/user', + headers: { authorization: `Bearer ${patToken}` }, + payload: { name: 'Should Not Change' } + }) + response.statusCode.should.equal(403) + }) + + it('adminOptIn: false strips admin privileges', async function () { + // alice is admin but PAT has adminOptIn: false (default) + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'No Admin PAT', scope: '' } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Admin-only endpoint (e.g. list all users) should be rejected + const response = await app.inject({ + method: 'GET', + url: '/api/v1/admin/users', + headers: { authorization: `Bearer ${patToken}` } + }) + response.statusCode.should.not.equal(200) + }) + + it('adminOptIn: true preserves admin privileges', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Admin PAT', + scope: '', + adminOptIn: true + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Admin can access a team they are not a member of + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + response.statusCode.should.equal(200) + }) + + it('adminOptIn: true + readOnly still enforces read-only', async function () { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/v1/user/tokens', + cookies: { sid: TestObjects.tokens.alice }, + payload: { + name: 'Admin ReadOnly', + scope: '', + adminOptIn: true, + readOnly: true + } + }) + createResponse.statusCode.should.equal(200) + const patToken = createResponse.json().token + + // Read should work (admin bypass + read-only allows reads) + const readResponse = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.hashid}`, + headers: { authorization: `Bearer ${patToken}` } + }) + readResponse.statusCode.should.equal(200) + + // Write should be blocked by read-only even though admin + const writeResponse = await app.inject({ + method: 'PUT', + url: '/api/v1/user', + headers: { authorization: `Bearer ${patToken}` }, + payload: { name: 'Should Not Change' } + }) + writeResponse.statusCode.should.equal(403) + }) + + it('cookie session is unaffected by PAT enforcement', async function () { + // Cookie session should still have full access + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.hashid}`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + + // Write should also work + const writeResponse = await app.inject({ + method: 'PUT', + url: '/api/v1/user', + cookies: { sid: TestObjects.tokens.alice }, + payload: { name: 'Alice Skywalker' } + }) + writeResponse.statusCode.should.equal(200) + }) + }) }) describe('User invites', async function () { From ddafda4deb7ea323cddca55b0f745a1e9c06171c Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 8 Jul 2026 16:13:14 +0300 Subject: [PATCH 8/9] fix lint --- forge/routes/auth/permissions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index f44d0514d1..ab22033e51 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -1,5 +1,5 @@ -const fp = require('fastify-plugin') const { requestContext } = require('@fastify/request-context') +const fp = require('fastify-plugin') const { Permissions } = require('../../lib/permissions') const { Roles } = require('../../lib/roles.js') From bc4e1895374a89a86fa2a67ae57fe8e45f291fc9 Mon Sep 17 00:00:00 2001 From: Costin Serban Date: Fri, 10 Jul 2026 18:03:27 +0300 Subject: [PATCH 9/9] Scoped PATs: clean up team scope entries on member removal (#7778) --- forge/db/controllers/Team.js | 31 +++++ forge/routes/api/admin.js | 2 +- forge/routes/api/device.js | 6 +- forge/routes/api/team.js | 2 +- forge/routes/api/user.js | 2 +- frontend/src/api/user.js | 23 +++- .../src/pages/account/Security/Tokens.vue | 74 +++++++++-- .../account/Security/dialogs/TokenDialog.vue | 119 +++++++++++++----- .../cypress/tests/account/tokens.spec.js | 89 ++++++++++++- .../unit/forge/routes/api/teamMembers_spec.js | 105 ++++++++++++++++ test/unit/forge/routes/api/user_spec.js | 70 +++++++++++ 11 files changed, 473 insertions(+), 50 deletions(-) diff --git a/forge/db/controllers/Team.js b/forge/db/controllers/Team.js index 51b54095ad..0406896c6e 100644 --- a/forge/db/controllers/Team.js +++ b/forge/db/controllers/Team.js @@ -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 diff --git a/forge/routes/api/admin.js b/forge/routes/api/admin.js index 137644c44d..cbeeb383a3 100644 --- a/forge/routes/api/admin.js +++ b/forge/routes/api/admin.js @@ -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'], diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index d1775b2be4..a5bfbed67a 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -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'], @@ -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'], diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js index 1bb5067e15..12c11c7b15 100644 --- a/forge/routes/api/team.js +++ b/forge/routes/api/team.js @@ -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'], diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index 212e09d52a..8e0cdd7f7e 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -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'], diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js index 3f1c21c37d..90abf96994 100644 --- a/frontend/src/api/user.js +++ b/frontend/src/api/user.js @@ -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} 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) } /** @@ -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} 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 () => { diff --git a/frontend/src/pages/account/Security/Tokens.vue b/frontend/src/pages/account/Security/Tokens.vue index 560f50f451..03e97813a9 100644 --- a/frontend/src/pages/account/Security/Tokens.vue +++ b/frontend/src/pages/account/Security/Tokens.vue @@ -9,7 +9,7 @@