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/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..ab22033e51 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -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) @@ -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/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 @@