Skip to content

Commit 7d0b7ea

Browse files
authored
Merge branch 'main' into sso-granular-rbac
2 parents 44a4172 + cababe3 commit 7d0b7ea

17 files changed

Lines changed: 1234 additions & 1023 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Add readOnly and adminOptIn columns to AccessTokens
3+
*/
4+
5+
const { DataTypes } = require('sequelize')
6+
7+
module.exports = {
8+
/**
9+
* upgrade database
10+
* @param {QueryInterface} context Sequelize.QueryInterface
11+
*/
12+
up: async (context, Sequelize) => {
13+
await context.addColumn('AccessTokens', 'readOnly', {
14+
type: DataTypes.BOOLEAN,
15+
defaultValue: false,
16+
allowNull: false
17+
})
18+
await context.addColumn('AccessTokens', 'adminOptIn', {
19+
type: DataTypes.BOOLEAN,
20+
defaultValue: false,
21+
allowNull: false
22+
})
23+
},
24+
down: async (context) => {}
25+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Create AccessTokenTeamScopes join table for PAT team scoping
3+
*/
4+
5+
const { DataTypes } = require('sequelize')
6+
7+
module.exports = {
8+
/**
9+
* upgrade database
10+
* @param {QueryInterface} context Sequelize.QueryInterface
11+
*/
12+
up: async (context, Sequelize) => {
13+
await context.createTable('AccessTokenTeamScopes', {
14+
id: {
15+
type: DataTypes.INTEGER,
16+
primaryKey: true,
17+
autoIncrement: true
18+
},
19+
AccessTokenId: {
20+
type: DataTypes.INTEGER,
21+
references: { model: 'AccessTokens', key: 'id' },
22+
onDelete: 'CASCADE',
23+
onUpdate: 'CASCADE',
24+
allowNull: false
25+
},
26+
TeamId: {
27+
type: DataTypes.INTEGER,
28+
references: { model: 'Teams', key: 'id' },
29+
onDelete: 'CASCADE',
30+
onUpdate: 'CASCADE',
31+
allowNull: false
32+
},
33+
UserId: {
34+
type: DataTypes.INTEGER,
35+
references: { model: 'Users', key: 'id' },
36+
onDelete: 'CASCADE',
37+
onUpdate: 'CASCADE',
38+
allowNull: false
39+
},
40+
createdAt: {
41+
type: DataTypes.DATE,
42+
allowNull: false
43+
},
44+
updatedAt: {
45+
type: DataTypes.DATE,
46+
allowNull: false
47+
}
48+
})
49+
50+
await context.addIndex('AccessTokenTeamScopes', {
51+
name: 'access_token_team_scope_unique',
52+
fields: ['AccessTokenId', 'TeamId'],
53+
unique: true
54+
})
55+
await context.addIndex('AccessTokenTeamScopes', {
56+
name: 'access_token_team_scope_user_team',
57+
fields: ['UserId', 'TeamId']
58+
})
59+
},
60+
down: async (context) => {}
61+
}

forge/db/models/AccessToken.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ module.exports = {
4545
}
4646
}
4747
},
48-
name: { type: DataTypes.STRING }
48+
name: { type: DataTypes.STRING },
49+
readOnly: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false },
50+
adminOptIn: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false }
4951
},
5052
associations: function (M) {
5153
this.belongsTo(M.Team, { foreignKey: 'ownerId', constraints: false })
5254
this.belongsTo(M.Project, { foreignKey: 'ownerId', constraints: false })
5355
this.belongsTo(M.Device, { foreignKey: 'ownerId', constraints: false })
5456
this.belongsTo(M.User, { foreignKey: 'ownerId', constraints: false })
57+
this.hasMany(M.AccessTokenTeamScope)
5558
},
5659
finders: function (M) {
5760
return {
@@ -112,7 +115,14 @@ module.exports = {
112115
name: { [Op.ne]: null }
113116
},
114117
order: [['id', 'ASC']],
115-
attributes: ['id', 'name', 'scope', 'expiresAt']
118+
attributes: ['id', 'name', 'scope', 'expiresAt', 'readOnly', 'adminOptIn'],
119+
include: [{
120+
model: M.AccessTokenTeamScope,
121+
include: [{
122+
model: M.Team,
123+
attributes: ['id', 'name']
124+
}]
125+
}]
116126
})
117127
return tokens
118128
},
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* AccessTokenTeamScope join table
3+
* Links scoped PATs to the teams they are allowed to access.
4+
*/
5+
const { DataTypes } = require('sequelize')
6+
7+
module.exports = {
8+
name: 'AccessTokenTeamScope',
9+
schema: {
10+
id: {
11+
type: DataTypes.INTEGER,
12+
primaryKey: true,
13+
autoIncrement: true
14+
}
15+
},
16+
meta: {
17+
slug: false,
18+
hashid: false,
19+
links: false
20+
},
21+
indexes: [
22+
{ name: 'access_token_team_scope_unique', fields: ['AccessTokenId', 'TeamId'], unique: true },
23+
{ name: 'access_token_team_scope_user_team', fields: ['UserId', 'TeamId'] }
24+
],
25+
associations: function (M) {
26+
this.belongsTo(M.AccessToken)
27+
this.belongsTo(M.Team)
28+
this.belongsTo(M.User)
29+
}
30+
}

forge/db/models/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const modelTypes = [
6767
'ProjectTemplate',
6868
'ProjectSnapshot',
6969
'AccessToken',
70+
'AccessTokenTeamScope',
7071
'AuthClient',
7172
'Device',
7273
'DeviceGroup',

forge/ee/routes/expert/index.js

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,9 @@ const getDeviceComms = (app) => { return app.comms?.devices }
1616

1717
/**
1818
* Maps a platform automation tool's wire definition into a catalog entry for the
19-
* Expert permissions UI (#421). Platform tools carry standard MCP annotations
20-
* (readOnlyHint / destructiveHint), which give the read/write/delete class. They
21-
* run on the platform, not in Node-RED, so they have no nr-assistant version window
22-
* (no minVersion/maxVersion — the UI treats their absence as always-available).
23-
* `group: 'platform'` routes them to the FlowFuse Platform Tools section (groupOf()
24-
* in the product-assistant store). The friendly label is the tool's own `title`; if a
25-
* tool ever lacks one, fall back to deriving it from the name (strip the platform_
26-
* prefix and title-case the rest).
19+
* Expert permissions UI. The read/write/delete class comes from the MCP annotations
20+
* (readOnlyHint / destructiveHint), and `group: 'platform'` routes it to the platform
21+
* section. The label is the tool's own `title`, falling back to a name-derived label.
2722
*/
2823
const curatePlatformTool = (def) => {
2924
const annotations = def.annotations || {}
@@ -586,16 +581,10 @@ module.exports = async function (app) {
586581
})
587582

588583
/**
589-
* Retrieve the curated tool catalog for the Expert's human-in-the-loop permissions UI
590-
* (#421). Returns the merged catalog for both sections the UI shows:
591-
* - flow-building tools, proxied from the agent service's /mcp/flow-tools endpoint
592-
* (friendly catalog entries only — raw MCP identifiers never leave the backend);
593-
* - FlowFuse platform tools, curated here from the platform automation handler
594-
* (app.comms.platformAutomation) and tagged group:'platform'.
595-
* A `hash` fingerprint of the flow-building catalog rides along so the browser refetches
596-
* only when it changes. Team access + feature gating are enforced by the shared
597-
* preHandler above; read/write classification on each entry is what the client uses to
598-
* decide which tools a role may enable.
584+
* Returns the merged tool catalog for the Expert permissions UI: flow-building tools
585+
* proxied from the agent's /mcp/flow-tools endpoint, plus curated platform tools. A
586+
* `hash` of the flow-building catalog rides along so the browser refetches only when
587+
* it changes. Team access and feature gating are enforced by the shared preHandler.
599588
*/
600589
app.get('/mcp/tools', {
601590
schema: {
@@ -656,11 +645,10 @@ module.exports = async function (app) {
656645

657646
reply.send({ catalog, hash: response.data?.hash || null })
658647
} catch (error) {
659-
// TODO: decide with the team whether this belongs on the branch. The tool catalog
660-
// is a non-fatal enhancement (the client swallows failures and gates safely with
661-
// defaults). Never forward an upstream auth failure as our own 401 — the SPA's
662-
// axios interceptor treats any 401 as session-expiry and logs the user out, which
663-
// an unrelated expert-service token rejection must not trigger.
648+
// The tool catalog is a non-fatal enhancement (the client swallows failures and
649+
// gates safely with defaults). Never forward an upstream auth failure as our own
650+
// 401. The SPA's axios interceptor treats any 401 as session-expiry and logs the
651+
// user out, which an unrelated expert-service token rejection must not trigger.
664652
const upstreamStatus = error.response?.status
665653
app.log.warn(`[expert/mcp/tools] upstream tool-catalog fetch failed: status=${upstreamStatus} msg=${error.message}`)
666654
if (upstreamStatus === 401 || upstreamStatus === 403) {

forge/forge.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const cookie = require('@fastify/cookie')
22
const csrf = require('@fastify/csrf-protection')
33
const helmet = require('@fastify/helmet')
4+
const { fastifyRequestContext } = require('@fastify/request-context')
45
const Sentry = require('@sentry/node')
56
const fastify = require('fastify')
67

@@ -212,6 +213,9 @@ module.exports = async (options = {}) => {
212213
})
213214
await server.register(csrf, { cookieOpts: { _signed: true, _httpOnly: true } })
214215

216+
// Request Context: per-request store
217+
await server.register(fastifyRequestContext)
218+
215219
let contentSecurityPolicy = false
216220
if (runtimeConfig.content_security_policy?.enabled) {
217221
if (!runtimeConfig.content_security_policy.directives) {

0 commit comments

Comments
 (0)