Skip to content

Commit 1356200

Browse files
committed
Clean up team-scoped PAT entries and tokens on member removal, add tests to verify behavior
1 parent ddafda4 commit 1356200

2 files changed

Lines changed: 136 additions & 0 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

test/unit/forge/routes/api/teamMembers_spec.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,4 +637,109 @@ describe('Team Members API', function () {
637637
secondResponse.statusCode.should.equal(200)
638638
})
639639
})
640+
641+
describe('PAT team scope cleanup on member removal', function () {
642+
before(async function () {
643+
if (app) await app.close()
644+
app = await setup()
645+
TestObjects.ATeam = await app.db.models.Team.byName('ATeam')
646+
TestObjects.BTeam = await app.db.models.Team.create({ name: 'BTeam', TeamTypeId: app.defaultTeamType.id })
647+
TestObjects.CTeam = await app.db.models.Team.create({ name: 'CTeam', TeamTypeId: app.defaultTeamType.id })
648+
delete TestObjects.DTeam
649+
})
650+
after(async function () {
651+
await app.close()
652+
})
653+
654+
it('removing user from team deletes AccessTokenTeamScope entries for that team', async function () {
655+
await setupUsers()
656+
// bob is member of ATeam and BTeam. Create a PAT scoped to both.
657+
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(
658+
TestObjects.bob, '', null, 'Multi Team PAT',
659+
{ teamIds: [TestObjects.ATeam.hashid, TestObjects.BTeam.hashid] }
660+
)
661+
662+
// Verify both scopes exist
663+
let scopes = await app.db.models.AccessTokenTeamScope.findAll({
664+
where: { UserId: TestObjects.bob.id }
665+
})
666+
scopes.should.have.length(2)
667+
668+
// Remove bob from ATeam
669+
await app.db.controllers.Team.removeUser(TestObjects.ATeam, TestObjects.bob)
670+
671+
// ATeam scope should be gone, BTeam scope should remain
672+
scopes = await app.db.models.AccessTokenTeamScope.findAll({
673+
where: { UserId: TestObjects.bob.id }
674+
})
675+
scopes.should.have.length(1)
676+
scopes[0].TeamId.should.equal(TestObjects.BTeam.id)
677+
678+
// Token itself should still exist
679+
const tokenRecord = await app.db.models.AccessToken.findOne({
680+
where: { id: app.db.models.AccessToken.decodeHashid(token.id) }
681+
})
682+
should(tokenRecord).not.be.null()
683+
})
684+
685+
it('deletes token when last team scope is removed', async function () {
686+
await setupUsers()
687+
// bob is member of ATeam. Create a PAT scoped only to ATeam.
688+
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(
689+
TestObjects.bob, '', null, 'Single Team PAT',
690+
{ teamIds: [TestObjects.ATeam.hashid] }
691+
)
692+
const tokenId = app.db.models.AccessToken.decodeHashid(token.id)
693+
694+
// Remove bob from ATeam: last scope removed, token should be deleted
695+
await app.db.controllers.Team.removeUser(TestObjects.ATeam, TestObjects.bob)
696+
697+
const tokenRecord = await app.db.models.AccessToken.findOne({ where: { id: tokenId } })
698+
should(tokenRecord).be.null()
699+
700+
const scopes = await app.db.models.AccessTokenTeamScope.findAll({
701+
where: { UserId: TestObjects.bob.id }
702+
})
703+
scopes.should.have.length(0)
704+
})
705+
706+
it('team-agnostic tokens are unaffected by membership removal', async function () {
707+
await setupUsers()
708+
// Create a PAT with no team scopes
709+
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(
710+
TestObjects.bob, '', null, 'Global PAT'
711+
)
712+
const tokenId = app.db.models.AccessToken.decodeHashid(token.id)
713+
714+
// Remove bob from ATeam
715+
await app.db.controllers.Team.removeUser(TestObjects.ATeam, TestObjects.bob)
716+
717+
// Token should still exist
718+
const tokenRecord = await app.db.models.AccessToken.findOne({ where: { id: tokenId } })
719+
should(tokenRecord).not.be.null()
720+
})
721+
722+
it('tokens scoped to other teams are unaffected', async function () {
723+
await setupUsers()
724+
// Create a PAT scoped only to BTeam
725+
const token = await app.db.controllers.AccessToken.createPersonalAccessToken(
726+
TestObjects.bob, '', null, 'BTeam Only PAT',
727+
{ teamIds: [TestObjects.BTeam.hashid] }
728+
)
729+
const tokenId = app.db.models.AccessToken.decodeHashid(token.id)
730+
731+
// Remove bob from ATeam (not BTeam)
732+
await app.db.controllers.Team.removeUser(TestObjects.ATeam, TestObjects.bob)
733+
734+
// Token and its BTeam scope should be untouched
735+
const tokenRecord = await app.db.models.AccessToken.findOne({ where: { id: tokenId } })
736+
should(tokenRecord).not.be.null()
737+
738+
const scopes = await app.db.models.AccessTokenTeamScope.findAll({
739+
where: { AccessTokenId: tokenId }
740+
})
741+
scopes.should.have.length(1)
742+
scopes[0].TeamId.should.equal(TestObjects.BTeam.id)
743+
})
744+
})
640745
})

0 commit comments

Comments
 (0)