Skip to content

Commit 80066ac

Browse files
authored
[7446] Scoped PATs: API routes and controllers (#7766)
1 parent 0d4cf33 commit 80066ac

6 files changed

Lines changed: 472 additions & 22 deletions

File tree

forge/db/controllers/AccessToken.js

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -265,23 +265,43 @@ module.exports = {
265265
await app.settings.set('platform:stats:token', false)
266266
},
267267

268-
createPersonalAccessToken: async function (app, user, scope, expiresAt, name) {
268+
createPersonalAccessToken: async function (app, user, scope, expiresAt, name, { readOnly = false, adminOptIn = false, teamIds = [] } = {}) {
269269
const userId = typeof user === 'number' ? user : user.id
270270
const token = generateToken(32, 'ffpat')
271-
const tok = await app.db.models.AccessToken.create({
272-
name,
273-
token,
274-
scope,
275-
expiresAt,
276-
ownerId: '' + userId,
277-
ownerType: 'user'
271+
let tokId
272+
await app.db.sequelize.transaction(async (t) => {
273+
const tok = await app.db.models.AccessToken.create({
274+
name,
275+
token,
276+
scope,
277+
expiresAt,
278+
readOnly,
279+
adminOptIn,
280+
ownerId: '' + userId,
281+
ownerType: 'user'
282+
}, { transaction: t })
283+
tokId = tok.id
284+
if (teamIds.length > 0) {
285+
const scopes = teamIds.map(teamId => ({
286+
AccessTokenId: tok.id,
287+
TeamId: app.db.models.Team.decodeHashid(teamId),
288+
UserId: userId
289+
}))
290+
await app.db.models.AccessTokenTeamScope.bulkCreate(scopes, { transaction: t })
291+
}
278292
})
279-
// Overwrite the hashed token with the plain value
280-
const result = app.db.views.AccessToken.personalAccessTokenSummary(tok)
293+
const reloaded = await app.db.models.AccessToken.findOne({
294+
where: { id: tokId },
295+
include: [{
296+
model: app.db.models.AccessTokenTeamScope,
297+
include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }]
298+
}]
299+
})
300+
const result = app.db.views.AccessToken.personalAccessTokenSummary(reloaded)
281301
result.token = token
282302
return result
283303
},
284-
updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt) {
304+
updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt, { readOnly, adminOptIn, teamIds } = {}) {
285305
const userId = typeof user === 'number' ? user : user.id
286306
const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId)
287307
if (token) {
@@ -291,12 +311,41 @@ module.exports = {
291311
} else {
292312
token.expiresAt = expiresAt
293313
}
314+
if (readOnly !== undefined) {
315+
token.readOnly = readOnly
316+
}
317+
if (adminOptIn !== undefined) {
318+
token.adminOptIn = adminOptIn
319+
}
294320
await token.save()
321+
if (teamIds !== undefined) {
322+
await app.db.sequelize.transaction(async (t) => {
323+
await app.db.models.AccessTokenTeamScope.destroy({
324+
where: { AccessTokenId: token.id },
325+
transaction: t
326+
})
327+
if (teamIds.length > 0) {
328+
const scopes = teamIds.map(teamId => ({
329+
AccessTokenId: token.id,
330+
TeamId: app.db.models.Team.decodeHashid(teamId),
331+
UserId: userId
332+
}))
333+
await app.db.models.AccessTokenTeamScope.bulkCreate(scopes, { transaction: t })
334+
}
335+
})
336+
}
337+
const reloaded = await app.db.models.AccessToken.findOne({
338+
where: { id: token.id },
339+
include: [{
340+
model: app.db.models.AccessTokenTeamScope,
341+
include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }]
342+
}]
343+
})
344+
return reloaded
295345
} else {
296346
// should throw unknown token error
297347
throw new Error('Not Found')
298348
}
299-
return token
300349
},
301350

302351
// Should these only get added via forge/ee/lib/httpTokens?
@@ -377,7 +426,11 @@ module.exports = {
377426
scope: {
378427
[Op.notIn]: ['password:reset', 'email:verify']
379428
}
380-
}
429+
},
430+
include: [{
431+
model: app.db.models.AccessTokenTeamScope,
432+
include: [{ model: app.db.models.Team, attributes: ['id', 'name'] }]
433+
}]
381434
})
382435
if (accessToken) {
383436
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {

forge/db/views/AccessToken.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,19 @@ module.exports = function (app) {
6363
id: { type: 'string' },
6464
name: { type: 'string' },
6565
// scope: { type: 'string', nullable: true },
66-
expiresAt: { type: 'string', nullable: true }
66+
expiresAt: { type: 'string', nullable: true },
67+
readOnly: { type: 'boolean' },
68+
adminOptIn: { type: 'boolean' },
69+
teams: {
70+
type: 'array',
71+
items: {
72+
type: 'object',
73+
properties: {
74+
id: { type: 'string' },
75+
name: { type: 'string', nullable: true }
76+
}
77+
}
78+
}
6779
},
6880
required: ['id', 'name', 'expiresAt']
6981
})
@@ -81,7 +93,13 @@ module.exports = function (app) {
8193
const tokenSummary = {
8294
id: token.hashid,
8395
name: token.name,
84-
expiresAt: token.expiresAt ?? null
96+
expiresAt: token.expiresAt ?? null,
97+
readOnly: token.readOnly ?? false,
98+
adminOptIn: token.adminOptIn ?? false,
99+
teams: (token.AccessTokenTeamScopes ?? []).map(s => ({
100+
id: app.db.models.Team.encodeHashid(s.TeamId),
101+
name: s.Team?.name ?? null
102+
}))
85103
}
86104
return tokenSummary
87105
}

forge/routes/api/user.js

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ module.exports = async function (app) {
252252
* /api/v1/user/pat
253253
*/
254254
app.post('/tokens', {
255+
preHandler: app.blockPAT,
255256
config: {
256257
rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false
257258
},
@@ -263,7 +264,10 @@ module.exports = async function (app) {
263264
properties: {
264265
scope: { type: 'string' },
265266
expiresAt: { type: 'number' },
266-
name: { type: 'string' }
267+
name: { type: 'string' },
268+
readOnly: { type: 'boolean' },
269+
adminOptIn: { type: 'boolean' },
270+
teamIds: { type: 'array', items: { type: 'string' } }
267271
}
268272
},
269273
response: {
@@ -279,8 +283,25 @@ module.exports = async function (app) {
279283
const updates = new app.auditLog.formatters.UpdatesCollection()
280284
try {
281285
const body = request.body
282-
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(request.session.User, body.scope, body.expiresAt, body.name)
283-
// token has already been sanitised via views.AccessToken.personalAccessToken
286+
const teamIds = body.teamIds ?? []
287+
if (teamIds.length > 0) {
288+
for (const teamId of teamIds) {
289+
const decodedId = app.db.models.Team.decodeHashid(teamId)
290+
const membership = decodedId ? await app.db.models.TeamMember.findOne({ where: { UserId: request.session.User.id, TeamId: decodedId } }) : null
291+
if (!membership) {
292+
reply.code(400).send({ code: 'invalid_team', error: `Not a member of team: ${teamId}` })
293+
return
294+
}
295+
}
296+
}
297+
if (body.adminOptIn === true && !request.session.User.admin) {
298+
reply.code(403).send({ code: 'unauthorized', error: 'Admin access required to set adminOptIn' })
299+
return
300+
}
301+
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(
302+
request.session.User, body.scope, body.expiresAt, body.name,
303+
{ readOnly: body.readOnly, adminOptIn: body.adminOptIn, teamIds }
304+
)
284305
updates.push('id', token.id)
285306
updates.push('name', token.name)
286307
updates.push('scope', body.scope)
@@ -342,6 +363,7 @@ module.exports = async function (app) {
342363
* /api/v1/user/tokens/:id
343364
*/
344365
app.put('/tokens/:id', {
366+
preHandler: app.blockPAT,
345367
schema: {
346368
summary: 'Update users Personal Access Token',
347369
tags: ['Tokens'],
@@ -355,7 +377,10 @@ module.exports = async function (app) {
355377
type: 'object',
356378
properties: {
357379
scope: { type: 'string' },
358-
expiresAt: { type: 'number' }
380+
expiresAt: { type: 'number' },
381+
readOnly: { type: 'boolean' },
382+
adminOptIn: { type: 'boolean' },
383+
teamIds: { type: 'array', items: { type: 'string' } }
359384
}
360385
},
361386
response: {
@@ -372,11 +397,31 @@ module.exports = async function (app) {
372397
try {
373398
const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id)
374399
if (oldToken) {
400+
const oldSummary = app.db.views.AccessToken.personalAccessTokenSummary(oldToken)
375401
const body = request.body
376-
const newToken = await app.db.controllers.AccessToken.updatePersonalAccessToken(request.session.User, request.params.id, body.scope, body.expiresAt)
377-
updates.pushDifferences(oldToken, newToken)
402+
const teamIds = body.teamIds
403+
if (teamIds !== undefined && teamIds.length > 0) {
404+
for (const teamId of teamIds) {
405+
const decodedId = app.db.models.Team.decodeHashid(teamId)
406+
const membership = decodedId ? await app.db.models.TeamMember.findOne({ where: { UserId: request.session.User.id, TeamId: decodedId } }) : null
407+
if (!membership) {
408+
reply.code(400).send({ code: 'invalid_team', error: `Not a member of team: ${teamId}` })
409+
return
410+
}
411+
}
412+
}
413+
if (body.adminOptIn === true && !request.session.User.admin) {
414+
reply.code(403).send({ code: 'unauthorized', error: 'Admin access required to set adminOptIn' })
415+
return
416+
}
417+
const newToken = await app.db.controllers.AccessToken.updatePersonalAccessToken(
418+
request.session.User, request.params.id, body.scope, body.expiresAt,
419+
{ readOnly: body.readOnly, adminOptIn: body.adminOptIn, teamIds }
420+
)
421+
const newSummary = app.db.views.AccessToken.personalAccessTokenSummary(newToken)
422+
updates.pushDifferences(oldSummary, newSummary)
378423
await app.auditLog.User.user.pat.updated(request.session.User, null, updates)
379-
reply.send(app.db.views.AccessToken.personalAccessTokenSummary(newToken))
424+
reply.send(newSummary)
380425
return
381426
}
382427
reply.code(404).send({ code: 'not_found', error: 'Not Found' })

forge/routes/auth/index.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ async function init (app, opts) {
121121
}
122122
Sentry.setUser({ id: request.session.User.hashid, username: request.session.User.username, email: request.session.User.email, name: request.session.User.name })
123123
if (accessToken.name) {
124+
const scopes = accessToken.AccessTokenTeamScopes ?? []
125+
let teamScopes = null
126+
127+
if (scopes.length > 0) {
128+
const teamIds = scopes.map(s => s.TeamId)
129+
const memberships = await app.db.models.TeamMember.findAll({
130+
where: { UserId: request.session.User.id, TeamId: teamIds }
131+
})
132+
const roleByTeamId = {}
133+
for (const m of memberships) {
134+
roleByTeamId[m.TeamId] = m.role
135+
}
136+
teamScopes = scopes.map(s => ({
137+
[app.db.models.Team.encodeHashid(s.TeamId)]: roleByTeamId[s.TeamId] ?? null
138+
}))
139+
}
140+
141+
const patMetadata = {
142+
id: accessToken.id,
143+
readOnly: accessToken.readOnly,
144+
adminOptIn: accessToken.adminOptIn,
145+
teamScopes
146+
}
147+
148+
request.session.isPAT = true
149+
request.session.pat = patMetadata
150+
request.requestContext.set('isPAT', true)
151+
request.requestContext.set('pat', patMetadata)
152+
124153
// Temp hack to give token full user scope
125154
delete request.session.scope
126155
}
@@ -177,6 +206,7 @@ async function init (app, opts) {
177206
}
178207
reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
179208
}
209+
180210
app.decorate('verifySession', verifySession)
181211

182212
/**
@@ -195,6 +225,20 @@ async function init (app, opts) {
195225
return new Error()
196226
})
197227

228+
/**
229+
* preHandler function that blocks requests authenticated via a Personal Access Token.
230+
* Use on routes that must not be callable by PATs (e.g. creating or updating PATs).
231+
*
232+
* @name blockPAT
233+
* @static
234+
* @memberof forge
235+
*/
236+
app.decorate('blockPAT', async (request, reply) => {
237+
if (request.session?.isPAT) {
238+
reply.code(403).send({ code: 'pat_cannot_create_pat', error: 'PATs cannot create other PATs' })
239+
}
240+
})
241+
198242
app.decorateRequest('session', null)
199243
app.decorateRequest('sid', null)
200244

frontend/src/types/generated.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,9 @@ export interface paths {
863863
scope?: string;
864864
expiresAt?: number;
865865
name?: string;
866+
readOnly?: boolean;
867+
adminOptIn?: boolean;
868+
teamIds?: string[];
866869
};
867870
};
868871
};
@@ -916,6 +919,9 @@ export interface paths {
916919
"application/json": {
917920
scope?: string;
918921
expiresAt?: number;
922+
readOnly?: boolean;
923+
adminOptIn?: boolean;
924+
teamIds?: string[];
919925
};
920926
};
921927
};
@@ -10235,6 +10241,12 @@ export interface components {
1023510241
id: string;
1023610242
name: string;
1023710243
expiresAt: string | null;
10244+
readOnly?: boolean;
10245+
adminOptIn?: boolean;
10246+
teams?: {
10247+
id?: string;
10248+
name?: string | null;
10249+
}[];
1023810250
};
1023910251
/** PersonalAccessToken */
1024010252
PersonalAccessToken: {

0 commit comments

Comments
 (0)