diff --git a/forge/db/migrations/20260622-01-add-pat-scope-fields.js b/forge/db/migrations/20260622-01-add-pat-scope-fields.js new file mode 100644 index 0000000000..11f28aaf64 --- /dev/null +++ b/forge/db/migrations/20260622-01-add-pat-scope-fields.js @@ -0,0 +1,25 @@ +/** + * Add readOnly and adminOptIn columns to AccessTokens + */ + +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.addColumn('AccessTokens', 'readOnly', { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + await context.addColumn('AccessTokens', 'adminOptIn', { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + }, + down: async (context) => {} +} diff --git a/forge/db/migrations/20260622-02-add-access-token-team-scope.js b/forge/db/migrations/20260622-02-add-access-token-team-scope.js new file mode 100644 index 0000000000..036103fa01 --- /dev/null +++ b/forge/db/migrations/20260622-02-add-access-token-team-scope.js @@ -0,0 +1,61 @@ +/** + * Create AccessTokenTeamScopes join table for PAT team scoping + */ + +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.createTable('AccessTokenTeamScopes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + AccessTokenId: { + type: DataTypes.INTEGER, + references: { model: 'AccessTokens', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false + }, + TeamId: { + type: DataTypes.INTEGER, + references: { model: 'Teams', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false + }, + UserId: { + type: DataTypes.INTEGER, + references: { model: 'Users', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }) + + await context.addIndex('AccessTokenTeamScopes', { + name: 'access_token_team_scope_unique', + fields: ['AccessTokenId', 'TeamId'], + unique: true + }) + await context.addIndex('AccessTokenTeamScopes', { + name: 'access_token_team_scope_user_team', + fields: ['UserId', 'TeamId'] + }) + }, + down: async (context) => {} +} diff --git a/forge/db/models/AccessToken.js b/forge/db/models/AccessToken.js index 5d4aea7f4a..2a0c70ce41 100644 --- a/forge/db/models/AccessToken.js +++ b/forge/db/models/AccessToken.js @@ -45,13 +45,16 @@ module.exports = { } } }, - name: { type: DataTypes.STRING } + name: { type: DataTypes.STRING }, + readOnly: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false }, + adminOptIn: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false } }, associations: function (M) { this.belongsTo(M.Team, { foreignKey: 'ownerId', constraints: false }) this.belongsTo(M.Project, { foreignKey: 'ownerId', constraints: false }) this.belongsTo(M.Device, { foreignKey: 'ownerId', constraints: false }) this.belongsTo(M.User, { foreignKey: 'ownerId', constraints: false }) + this.hasMany(M.AccessTokenTeamScope) }, finders: function (M) { return { @@ -112,7 +115,14 @@ module.exports = { name: { [Op.ne]: null } }, order: [['id', 'ASC']], - attributes: ['id', 'name', 'scope', 'expiresAt'] + attributes: ['id', 'name', 'scope', 'expiresAt', 'readOnly', 'adminOptIn'], + include: [{ + model: M.AccessTokenTeamScope, + include: [{ + model: M.Team, + attributes: ['id', 'name'] + }] + }] }) return tokens }, diff --git a/forge/db/models/AccessTokenTeamScope.js b/forge/db/models/AccessTokenTeamScope.js new file mode 100644 index 0000000000..d4b7d7e0e7 --- /dev/null +++ b/forge/db/models/AccessTokenTeamScope.js @@ -0,0 +1,30 @@ +/** + * AccessTokenTeamScope join table + * Links scoped PATs to the teams they are allowed to access. + */ +const { DataTypes } = require('sequelize') + +module.exports = { + name: 'AccessTokenTeamScope', + schema: { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + } + }, + meta: { + slug: false, + hashid: false, + links: false + }, + indexes: [ + { name: 'access_token_team_scope_unique', fields: ['AccessTokenId', 'TeamId'], unique: true }, + { name: 'access_token_team_scope_user_team', fields: ['UserId', 'TeamId'] } + ], + associations: function (M) { + this.belongsTo(M.AccessToken) + this.belongsTo(M.Team) + this.belongsTo(M.User) + } +} diff --git a/forge/db/models/index.js b/forge/db/models/index.js index d5f502d120..acb7f596ce 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -67,6 +67,7 @@ const modelTypes = [ 'ProjectTemplate', 'ProjectSnapshot', 'AccessToken', + 'AccessTokenTeamScope', 'AuthClient', 'Device', 'DeviceGroup', diff --git a/forge/forge.js b/forge/forge.js index aae8ef5d61..5bb5895739 100644 --- a/forge/forge.js +++ b/forge/forge.js @@ -1,6 +1,7 @@ const cookie = require('@fastify/cookie') const csrf = require('@fastify/csrf-protection') const helmet = require('@fastify/helmet') +const { fastifyRequestContext } = require('@fastify/request-context') const Sentry = require('@sentry/node') const fastify = require('fastify') @@ -212,6 +213,9 @@ module.exports = async (options = {}) => { }) await server.register(csrf, { cookieOpts: { _signed: true, _httpOnly: true } }) + // Request Context: per-request store + await server.register(fastifyRequestContext) + let contentSecurityPolicy = false if (runtimeConfig.content_security_policy?.enabled) { if (!runtimeConfig.content_security_policy.directives) { diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 696d9e5690..20765930e8 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -4,236 +4,236 @@ const Permissions = { * OS Permissions */ // User Actions - 'user:create': { description: 'Create User', role: Roles.Admin }, - 'user:list': { description: 'List platform users', role: Roles.Admin }, - 'user:read': { description: 'View user Information', role: Roles.Admin, self: true }, - 'user:edit': { description: 'Edit User Information', role: Roles.Admin, self: true }, - 'user:delete': { description: 'Delete User', role: Roles.Admin, self: true }, - 'user:team:list': { description: 'List a Users teams', role: Roles.Admin, self: true }, - 'user:announcements:manage': { description: 'Manage platform wide announcements', role: Roles.Admin }, + 'user:create': { description: 'Create User', role: Roles.Admin, access: 'write' }, + 'user:list': { description: 'List platform users', role: Roles.Admin, access: 'read' }, + 'user:read': { description: 'View user Information', role: Roles.Admin, self: true, access: 'read' }, + 'user:edit': { description: 'Edit User Information', role: Roles.Admin, self: true, access: 'write' }, + 'user:delete': { description: 'Delete User', role: Roles.Admin, self: true, access: 'write' }, + 'user:team:list': { description: 'List a Users teams', role: Roles.Admin, self: true, access: 'read' }, + 'user:announcements:manage': { description: 'Manage platform wide announcements', role: Roles.Admin, access: 'write' }, // Team Scoped Actions - 'team:create': { description: 'Create Team' }, - 'team:list': { description: 'List Teams', role: Roles.Admin }, - 'team:read': { description: 'View a Team', role: Roles.Dashboard }, - 'team:edit': { description: 'Edit Team', role: Roles.Owner }, - 'team:delete': { description: 'Delete Team', role: Roles.Owner }, - 'team:audit-log': { description: 'Access Team Audit Log', role: Roles.Owner }, - 'team:device:bulk-delete': { description: 'Delete Devices', role: Roles.Owner }, - 'team:device:bulk-edit': { description: 'Edit Devices', role: Roles.Owner }, + 'team:create': { description: 'Create Team', access: 'write' }, + 'team:list': { description: 'List Teams', role: Roles.Admin, access: 'read' }, + 'team:read': { description: 'View a Team', role: Roles.Dashboard, access: 'read' }, + 'team:edit': { description: 'Edit Team', role: Roles.Owner, access: 'write' }, + 'team:delete': { description: 'Delete Team', role: Roles.Owner, access: 'write' }, + 'team:audit-log': { description: 'Access Team Audit Log', role: Roles.Owner, access: 'read' }, + 'team:device:bulk-delete': { description: 'Delete Devices', role: Roles.Owner, access: 'write' }, + 'team:device:bulk-edit': { description: 'Edit Devices', role: Roles.Owner, access: 'write' }, // Team Auto Device Provisioning Tokens - 'team:device:provisioning-token:create': { description: 'Create a Team Auto Device Provisioning Token', role: Roles.Owner }, - 'team:device:provisioning-token:edit': { description: 'Edit a Team Auto Device Provisioning Token', role: Roles.Owner }, - 'team:device:provisioning-token:list': { description: 'List Team Auto Device Provisioning Tokens', role: Roles.Owner }, - 'team:device:provisioning-token:delete': { description: 'Delete a Team Auto Device Provisioning Token', role: Roles.Owner }, + 'team:device:provisioning-token:create': { description: 'Create a Team Auto Device Provisioning Token', role: Roles.Owner, access: 'write' }, + 'team:device:provisioning-token:edit': { description: 'Edit a Team Auto Device Provisioning Token', role: Roles.Owner, access: 'write' }, + 'team:device:provisioning-token:list': { description: 'List Team Auto Device Provisioning Tokens', role: Roles.Owner, access: 'read' }, + 'team:device:provisioning-token:delete': { description: 'Delete a Team Auto Device Provisioning Token', role: Roles.Owner, access: 'write' }, // Team Members - 'team:user:add': { description: 'Add Members', role: Roles.Admin }, - 'team:user:list': { description: 'List Team Members', role: Roles.Viewer }, - 'team:user:invite': { description: 'Invite Members', role: Roles.Owner }, - 'team:user:remove': { description: 'Remove Member', role: Roles.Owner, self: true }, - 'team:user:change-role': { description: 'Modify Member role', role: Roles.Owner }, + 'team:user:add': { description: 'Add Members', role: Roles.Admin, access: 'write' }, + 'team:user:list': { description: 'List Team Members', role: Roles.Viewer, access: 'read' }, + 'team:user:invite': { description: 'Invite Members', role: Roles.Owner, access: 'write' }, + 'team:user:remove': { description: 'Remove Member', role: Roles.Owner, self: true, access: 'write' }, + 'team:user:change-role': { description: 'Modify Member role', role: Roles.Owner, access: 'write' }, - 'team:search': { description: 'Search a Teams resources', role: Roles.Viewer }, + 'team:search': { description: 'Search a Teams resources', role: Roles.Viewer, access: 'read' }, // Applications - 'application:audit-log': { description: 'Access Application Audit Log', role: Roles.Owner }, - 'application:access-control': { description: 'Update Application-level RBAC rules', role: Roles.Owner }, + 'application:audit-log': { description: 'Access Application Audit Log', role: Roles.Owner, access: 'read' }, + 'application:access-control': { description: 'Update Application-level RBAC rules', role: Roles.Owner, access: 'write' }, // Projects - 'team:projects:list': { description: 'List Team Projects', role: Roles.Viewer }, - 'team:projects:list-dashboards': { description: 'List Team Projects', role: Roles.Dashboard }, - 'project:create': { description: 'Create Project', role: Roles.Owner }, - 'project:delete': { description: 'Delete Project', role: Roles.Owner }, - 'project:read': { description: 'View a Project', role: Roles.Viewer }, - 'project:status': { description: 'View a Project', role: Roles.Dashboard }, - 'project:transfer': { description: 'Transfer Project', role: Roles.Owner }, - 'project:change-status': { description: 'Start/Stop Project', role: Roles.Owner }, - 'project:edit': { description: 'Edit Project Settings', role: Roles.Owner }, - 'project:edit-env': { description: 'Edit Project Environment Variables', role: Roles.Member }, - 'project:log': { description: 'Access Project Log', role: Roles.Viewer }, - 'project:audit-log': { description: 'Access Project Audit Log', role: Roles.Viewer }, + 'team:projects:list': { description: 'List Team Projects', role: Roles.Viewer, access: 'read' }, + 'team:projects:list-dashboards': { description: 'List Team Projects', role: Roles.Dashboard, access: 'read' }, + 'project:create': { description: 'Create Project', role: Roles.Owner, access: 'write' }, + 'project:delete': { description: 'Delete Project', role: Roles.Owner, access: 'write' }, + 'project:read': { description: 'View a Project', role: Roles.Viewer, access: 'read' }, + 'project:status': { description: 'View a Project', role: Roles.Dashboard, access: 'read' }, + 'project:transfer': { description: 'Transfer Project', role: Roles.Owner, access: 'write' }, + 'project:change-status': { description: 'Start/Stop Project', role: Roles.Owner, access: 'write' }, + 'project:edit': { description: 'Edit Project Settings', role: Roles.Owner, access: 'write' }, + 'project:edit-env': { description: 'Edit Project Environment Variables', role: Roles.Member, access: 'write' }, + 'project:log': { description: 'Access Project Log', role: Roles.Viewer, access: 'read' }, + 'project:audit-log': { description: 'Access Project Audit Log', role: Roles.Viewer, access: 'read' }, // Project Editor - 'project:flows:view': { description: 'View Project Flows', role: Roles.Viewer }, - 'project:flows:edit': { description: 'Edit Project Flows', role: Roles.Member }, - 'project:flows:http': { description: 'Access http endpoints of an Instance', role: Roles.Dashboard }, + 'project:flows:view': { description: 'View Project Flows', role: Roles.Viewer, access: 'read' }, + 'project:flows:edit': { description: 'Edit Project Flows', role: Roles.Member, access: 'write' }, + 'project:flows:http': { description: 'Access http endpoints of an Instance', role: Roles.Dashboard, access: 'read' }, // Snapshots - 'project:snapshot:create': { description: 'Create Project Snapshot', role: Roles.Member }, - 'project:snapshot:list': { description: 'List Project Snapshots', role: Roles.Viewer }, - 'project:snapshot:read': { description: 'View a Project Snapshot', role: Roles.Viewer }, - 'project:snapshot:delete': { description: 'Delete Project Snapshot', role: Roles.Owner }, - 'project:snapshot:rollback': { description: 'Rollback Project Snapshot', role: Roles.Member }, - 'project:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member }, - 'project:snapshot:export': { description: 'Export Project Snapshot', role: Roles.Member }, + 'project:snapshot:create': { description: 'Create Project Snapshot', role: Roles.Member, access: 'write' }, + 'project:snapshot:list': { description: 'List Project Snapshots', role: Roles.Viewer, access: 'read' }, + 'project:snapshot:read': { description: 'View a Project Snapshot', role: Roles.Viewer, access: 'read' }, + 'project:snapshot:delete': { description: 'Delete Project Snapshot', role: Roles.Owner, access: 'write' }, + 'project:snapshot:rollback': { description: 'Rollback Project Snapshot', role: Roles.Member, access: 'write' }, + 'project:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member, access: 'write' }, + 'project:snapshot:export': { description: 'Export Project Snapshot', role: Roles.Member, access: 'write' }, // Templates - 'template:create': { description: 'Create a Template', role: Roles.Admin }, - 'template:list': { description: 'List all Templates' }, - 'template:read': { description: 'View a Template' }, - 'template:delete': { description: 'Delete a Template', role: Roles.Admin }, - 'template:edit': { description: 'Edit a Template', role: Roles.Admin }, + 'template:create': { description: 'Create a Template', role: Roles.Admin, access: 'write' }, + 'template:list': { description: 'List all Templates', access: 'read' }, + 'template:read': { description: 'View a Template', access: 'read' }, + 'template:delete': { description: 'Delete a Template', role: Roles.Admin, access: 'write' }, + 'template:edit': { description: 'Edit a Template', role: Roles.Admin, access: 'write' }, // Stacks - 'stack:create': { description: 'Create a Stack', role: Roles.Admin }, - 'stack:list': { description: 'List all Stacks' }, - 'stack:read': { description: 'View a Stack' }, - 'stack:delete': { description: 'Delete a Stack', role: Roles.Admin }, - 'stack:edit': { description: 'Edit a Stack', role: Roles.Admin }, + 'stack:create': { description: 'Create a Stack', role: Roles.Admin, access: 'write' }, + 'stack:list': { description: 'List all Stacks', access: 'read' }, + 'stack:read': { description: 'View a Stack', access: 'read' }, + 'stack:delete': { description: 'Delete a Stack', role: Roles.Admin, access: 'write' }, + 'stack:edit': { description: 'Edit a Stack', role: Roles.Admin, access: 'write' }, // Devices - 'team:device:list': { description: 'List Team Devices', role: Roles.Viewer }, - 'device:list': { description: 'List Devices', role: Roles.Admin }, - 'device:create': { description: 'Create a Device', role: Roles.Owner }, - 'device:provision': { description: 'Provision a Device', role: null }, - 'device:read': { description: 'View a Device', role: Roles.Viewer }, - 'device:delete': { description: 'Delete a Device', role: Roles.Owner }, - 'device:edit': { description: 'Edit a Device', role: Roles.Owner }, - 'device:edit-env': { description: 'Edit Device Environment Variables', role: Roles.Member }, - 'device:change-status': { description: 'Start/Stop a Device', role: Roles.Owner }, - 'device:snapshot:create': { description: 'Create Device Snapshot', role: Roles.Member }, - 'device:snapshot:list': { description: 'List Device Snapshots', role: Roles.Viewer }, - 'device:snapshot:read': { description: 'View a Device Snapshot', role: Roles.Viewer }, - 'device:snapshot:delete': { description: 'Delete Device Snapshot', role: Roles.Owner }, - 'device:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member }, - 'device:audit-log': { description: 'View a Device Audit Log', role: Roles.Viewer }, + 'team:device:list': { description: 'List Team Devices', role: Roles.Viewer, access: 'read' }, + 'device:list': { description: 'List Devices', role: Roles.Admin, access: 'read' }, + 'device:create': { description: 'Create a Device', role: Roles.Owner, access: 'write' }, + 'device:provision': { description: 'Provision a Device', role: null, access: 'write' }, + 'device:read': { description: 'View a Device', role: Roles.Viewer, access: 'read' }, + 'device:delete': { description: 'Delete a Device', role: Roles.Owner, access: 'write' }, + 'device:edit': { description: 'Edit a Device', role: Roles.Owner, access: 'write' }, + 'device:edit-env': { description: 'Edit Device Environment Variables', role: Roles.Member, access: 'write' }, + 'device:change-status': { description: 'Start/Stop a Device', role: Roles.Owner, access: 'write' }, + 'device:snapshot:create': { description: 'Create Device Snapshot', role: Roles.Member, access: 'write' }, + 'device:snapshot:list': { description: 'List Device Snapshots', role: Roles.Viewer, access: 'read' }, + 'device:snapshot:read': { description: 'View a Device Snapshot', role: Roles.Viewer, access: 'read' }, + 'device:snapshot:delete': { description: 'Delete Device Snapshot', role: Roles.Owner, access: 'write' }, + 'device:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member, access: 'write' }, + 'device:audit-log': { description: 'View a Device Audit Log', role: Roles.Viewer, access: 'read' }, // Snapshots (common) - 'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer }, - 'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member }, - 'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member }, - 'snapshot:edit': { description: 'Edit a Snapshot', role: Roles.Owner }, - 'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner }, - 'snapshot:import': { description: 'Import a Snapshot', role: Roles.Owner }, + 'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer, access: 'read' }, + 'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member, access: 'read' }, + 'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member, access: 'write' }, + 'snapshot:edit': { description: 'Edit a Snapshot', role: Roles.Owner, access: 'write' }, + 'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner, access: 'write' }, + 'snapshot:import': { description: 'Import a Snapshot', role: Roles.Owner, access: 'write' }, // Project Types - 'project-type:create': { description: 'Create a ProjectType', role: Roles.Admin }, - 'project-type:list': { description: 'List all ProjectTypes' }, - 'project-type:read': { description: 'View a ProjectType' }, - 'project-type:delete': { description: 'Delete a ProjectType', role: Roles.Admin }, - 'project-type:edit': { description: 'Edit a ProjectType', role: Roles.Admin }, + 'project-type:create': { description: 'Create a ProjectType', role: Roles.Admin, access: 'write' }, + 'project-type:list': { description: 'List all ProjectTypes', access: 'read' }, + 'project-type:read': { description: 'View a ProjectType', access: 'read' }, + 'project-type:delete': { description: 'Delete a ProjectType', role: Roles.Admin, access: 'write' }, + 'project-type:edit': { description: 'Edit a ProjectType', role: Roles.Admin, access: 'write' }, // Team Types - 'team-type:create': { description: 'Create a TeamType', role: Roles.Admin }, - 'team-type:list': { description: 'List all TeamTypes' }, - 'team-type:read': { description: 'View a TeamType' }, - 'team-type:delete': { description: 'Delete a TeamType', role: Roles.Admin }, - 'team-type:edit': { description: 'Edit a TeamType', role: Roles.Admin }, + 'team-type:create': { description: 'Create a TeamType', role: Roles.Admin, access: 'write' }, + 'team-type:list': { description: 'List all TeamTypes', access: 'read' }, + 'team-type:read': { description: 'View a TeamType', access: 'read' }, + 'team-type:delete': { description: 'Delete a TeamType', role: Roles.Admin, access: 'write' }, + 'team-type:edit': { description: 'Edit a TeamType', role: Roles.Admin, access: 'write' }, - 'settings:edit': { description: 'Edit platform settings', role: Roles.Admin }, - 'license:read': { description: 'View license information', role: Roles.Admin }, - 'license:edit': { description: 'Edit license information', role: Roles.Admin }, + 'settings:edit': { description: 'Edit platform settings', role: Roles.Admin, access: 'write' }, + 'license:read': { description: 'View license information', role: Roles.Admin, access: 'read' }, + 'license:edit': { description: 'Edit license information', role: Roles.Admin, access: 'write' }, - 'invitation:list': { description: 'List all invitations', role: Roles.Admin }, + 'invitation:list': { description: 'List all invitations', role: Roles.Admin, access: 'read' }, - 'platform:debug': { description: 'View platform debug information', role: Roles.Admin }, - 'platform:stats': { description: 'View platform stats information', role: Roles.Admin }, - 'platform:stats:token': { description: 'Create/Delete platform stats token', role: Roles.Admin }, - 'platform:expert-agent:creds': { description: 'Create/Delete expert agent credentials', role: Roles.Admin }, - 'platform:audit-log': { description: 'View platform audit log', role: Roles.Admin }, + 'platform:debug': { description: 'View platform debug information', role: Roles.Admin, access: 'read' }, + 'platform:stats': { description: 'View platform stats information', role: Roles.Admin, access: 'read' }, + 'platform:stats:token': { description: 'Create/Delete platform stats token', role: Roles.Admin, access: 'write' }, + 'platform:expert-agent:creds': { description: 'Create/Delete expert agent credentials', role: Roles.Admin, access: 'write' }, + 'platform:audit-log': { description: 'View platform audit log', role: Roles.Admin, access: 'read' }, /** * EE Permissions */ // Projects - 'project:history': { description: 'View Hosted Instances project history', role: Roles.Viewer }, + 'project:history': { description: 'View Hosted Instances project history', role: Roles.Viewer, access: 'read' }, // Application - 'application:bom': { description: 'Get the Application Bill of Materials', role: Roles.Owner }, + 'application:bom': { description: 'Get the Application Bill of Materials', role: Roles.Owner, access: 'read' }, // Team - 'team:bom': { description: 'Get the Team Bill of Materials', role: Roles.Owner }, - 'team:device-group:list': { description: 'List Team device groups', role: Roles.Member }, + 'team:bom': { description: 'Get the Team Bill of Materials', role: Roles.Owner, access: 'read' }, + 'team:device-group:list': { description: 'List Team device groups', role: Roles.Member, access: 'read' }, // Device Groups - 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner }, - 'application:device-group:list': { description: 'List device groups', role: Roles.Member }, - 'application:device-group:update': { description: 'Update a device group', role: Roles.Owner }, - 'application:device-group:delete': { description: 'Delete a device group', role: Roles.Owner }, - 'application:device-group:read': { description: 'View a device group', role: Roles.Member }, - 'application:device-group:membership:update': { description: 'Update a device group membership', role: Roles.Owner }, + 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner, access: 'write' }, + 'application:device-group:list': { description: 'List device groups', role: Roles.Member, access: 'read' }, + 'application:device-group:update': { description: 'Update a device group', role: Roles.Owner, access: 'write' }, + 'application:device-group:delete': { description: 'Delete a device group', role: Roles.Owner, access: 'write' }, + 'application:device-group:read': { description: 'View a device group', role: Roles.Member, access: 'read' }, + 'application:device-group:membership:update': { description: 'Update a device group membership', role: Roles.Owner, access: 'write' }, // Device Editor - 'device:editor': { description: 'Access the Device Editor', role: Roles.Member }, + 'device:editor': { description: 'Access the Device Editor', role: Roles.Member, access: 'write' }, // Team Billing - 'team:billing:manual': { description: 'Setups up manual billing on a team', role: Roles.Admin }, - 'team:billing:trial': { description: 'Modify team trial settings', role: Roles.Admin }, + 'team:billing:manual': { description: 'Setups up manual billing on a team', role: Roles.Admin, access: 'write' }, + 'team:billing:trial': { description: 'Modify team trial settings', role: Roles.Admin, access: 'write' }, // Flow Blueprints - 'flow-blueprint:create': { description: 'Create a Flow Blueprint', role: Roles.Admin }, - 'flow-blueprint:list': { description: 'List all Flow Blueprints' }, - 'flow-blueprint:read': { description: 'View a Flow Blueprint' }, - 'flow-blueprint:delete': { description: 'Delete a Flow Blueprint', role: Roles.Admin }, - 'flow-blueprint:edit': { description: 'Edit a Flow Blueprint', role: Roles.Admin }, + 'flow-blueprint:create': { description: 'Create a Flow Blueprint', role: Roles.Admin, access: 'write' }, + 'flow-blueprint:list': { description: 'List all Flow Blueprints', access: 'read' }, + 'flow-blueprint:read': { description: 'View a Flow Blueprint', access: 'read' }, + 'flow-blueprint:delete': { description: 'Delete a Flow Blueprint', role: Roles.Admin, access: 'write' }, + 'flow-blueprint:edit': { description: 'Edit a Flow Blueprint', role: Roles.Admin, access: 'write' }, // Library - 'library:entry:create': { description: 'Create entries in a team library', role: Roles.Member }, - 'library:entry:list': { description: 'List entries in a team library', role: Roles.Member }, - 'library:entry:delete': { description: 'Delete an entry in a team library', role: Roles.Member }, + 'library:entry:create': { description: 'Create entries in a team library', role: Roles.Member, access: 'write' }, + 'library:entry:list': { description: 'List entries in a team library', role: Roles.Member, access: 'read' }, + 'library:entry:delete': { description: 'Delete an entry in a team library', role: Roles.Member, access: 'write' }, // Pipeline - 'pipeline:read': { description: 'View a pipeline', role: Roles.Member }, - 'pipeline:create': { description: 'Create a pipeline', role: Roles.Owner }, - 'pipeline:edit': { description: 'Edit a pipeline', role: Roles.Owner }, - 'pipeline:delete': { description: 'Delete a pipeline', role: Roles.Owner }, - 'application:pipeline:list': { description: 'List pipelines within an application', role: Roles.Member }, - 'team:pipeline:list': { description: 'List pipelines within a team', role: Roles.Member }, + 'pipeline:read': { description: 'View a pipeline', role: Roles.Member, access: 'read' }, + 'pipeline:create': { description: 'Create a pipeline', role: Roles.Owner, access: 'write' }, + 'pipeline:edit': { description: 'Edit a pipeline', role: Roles.Owner, access: 'write' }, + 'pipeline:delete': { description: 'Delete a pipeline', role: Roles.Owner, access: 'write' }, + 'application:pipeline:list': { description: 'List pipelines within an application', role: Roles.Member, access: 'read' }, + 'team:pipeline:list': { description: 'List pipelines within a team', role: Roles.Member, access: 'read' }, // SAML - 'saml-provider:create': { description: 'Create a SAML Provider', role: Roles.Admin }, - 'saml-provider:list': { description: 'List all SAML Providers', role: Roles.Admin }, - 'saml-provider:read': { description: 'View a SAML Provider', role: Roles.Admin }, - 'saml-provider:delete': { description: 'Delete a SAML Provider', role: Roles.Admin }, - 'saml-provider:edit': { description: 'Edit a SAML Provider', role: Roles.Admin }, + 'saml-provider:create': { description: 'Create a SAML Provider', role: Roles.Admin, access: 'write' }, + 'saml-provider:list': { description: 'List all SAML Providers', role: Roles.Admin, access: 'read' }, + 'saml-provider:read': { description: 'View a SAML Provider', role: Roles.Admin, access: 'read' }, + 'saml-provider:delete': { description: 'Delete a SAML Provider', role: Roles.Admin, access: 'write' }, + 'saml-provider:edit': { description: 'Edit a SAML Provider', role: Roles.Admin, access: 'write' }, // Static Assets - 'project:files:list': { description: 'List files under a project', role: Roles.Member }, - 'project:files:create': { description: 'Upload files to a project', role: Roles.Member }, - 'project:files:edit': { description: 'Modify files in a project', role: Roles.Member }, - 'project:files:delete': { description: 'Delete files in a project', role: Roles.Member }, + 'project:files:list': { description: 'List files under a project', role: Roles.Member, access: 'read' }, + 'project:files:create': { description: 'Upload files to a project', role: Roles.Member, access: 'write' }, + 'project:files:edit': { description: 'Modify files in a project', role: Roles.Member, access: 'write' }, + 'project:files:delete': { description: 'Delete files in a project', role: Roles.Member, access: 'write' }, // Team Broker - 'broker:clients:list': { description: 'List Team Broker clients', role: Roles.Member }, - 'broker:clients:create': { description: 'Create Team Broker clients', role: Roles.Owner }, - 'broker:clients:link': { description: 'Link Team Broker clients', role: Roles.Owner }, - 'broker:clients:edit': { description: 'Edit Team Broker clients', role: Roles.Owner }, - 'broker:clients:delete': { description: 'Delete Team Broker clients', role: Roles.Owner }, - 'broker:topics:list': { description: 'List active Team Broker topics', role: Roles.Member }, - 'broker:topics:write': { description: 'Edit Topic metadata', role: Roles.Owner }, + 'broker:clients:list': { description: 'List Team Broker clients', role: Roles.Member, access: 'read' }, + 'broker:clients:create': { description: 'Create Team Broker clients', role: Roles.Owner, access: 'write' }, + 'broker:clients:link': { description: 'Link Team Broker clients', role: Roles.Owner, access: 'write' }, + 'broker:clients:edit': { description: 'Edit Team Broker clients', role: Roles.Owner, access: 'write' }, + 'broker:clients:delete': { description: 'Delete Team Broker clients', role: Roles.Owner, access: 'write' }, + 'broker:topics:list': { description: 'List active Team Broker topics', role: Roles.Member, access: 'read' }, + 'broker:topics:write': { description: 'Edit Topic metadata', role: Roles.Owner, access: 'write' }, // 3rd Party Broker - 'broker:credentials:list': { description: 'List 3rd Party Broker credentials', role: Roles.Owner }, - 'broker:credentials:create': { description: 'Create new Broker credentials', role: Roles.Owner }, - 'broker:credentials:edit': { description: 'Edit Broker Credentials', role: Roles.Owner }, - 'broker:credentials:delete': { description: 'Delete Broker Credentials', role: Roles.Owner }, + 'broker:credentials:list': { description: 'List 3rd Party Broker credentials', role: Roles.Owner, access: 'read' }, + 'broker:credentials:create': { description: 'Create new Broker credentials', role: Roles.Owner, access: 'write' }, + 'broker:credentials:edit': { description: 'Edit Broker Credentials', role: Roles.Owner, access: 'write' }, + 'broker:credentials:delete': { description: 'Delete Broker Credentials', role: Roles.Owner, access: 'write' }, // Team Packages - 'team:packages:read': { description: 'List Teams Private Packages', role: Roles.Member }, - 'team:packages:manage': { description: 'Manage Teams Private Packages', role: Roles.Owner }, + 'team:packages:read': { description: 'List Teams Private Packages', role: Roles.Member, access: 'read' }, + 'team:packages:manage': { description: 'Manage Teams Private Packages', role: Roles.Owner, access: 'write' }, // Team Git Tokens - 'team:git:tokens:list': { description: 'List Teams Git Tokens', role: Roles.Owner }, - 'team:git:tokens:create': { description: 'List Teams Git Tokens', role: Roles.Owner }, - 'team:git:tokens:edit': { description: 'Edit Teams Git Tokens', role: Roles.Owner }, - 'team:git:tokens:delete': { description: 'Edit Teams Git Tokens', role: Roles.Owner }, + 'team:git:tokens:list': { description: 'List Teams Git Tokens', role: Roles.Owner, access: 'read' }, + 'team:git:tokens:create': { description: 'List Teams Git Tokens', role: Roles.Owner, access: 'write' }, + 'team:git:tokens:edit': { description: 'Edit Teams Git Tokens', role: Roles.Owner, access: 'write' }, + 'team:git:tokens:delete': { description: 'Edit Teams Git Tokens', role: Roles.Owner, access: 'write' }, // Team Tables - 'team:database:create': { description: 'Create a new database for the team', role: Roles.Owner }, - 'team:database:delete': { description: 'Delete the team database', role: Roles.Owner }, - 'team:database:list': { description: 'List the team databases', role: Roles.Member }, + 'team:database:create': { description: 'Create a new database for the team', role: Roles.Owner, access: 'write' }, + 'team:database:delete': { description: 'Delete the team database', role: Roles.Owner, access: 'write' }, + 'team:database:list': { description: 'List the team databases', role: Roles.Member, access: 'read' }, // MCP - 'team:mcp:list': { description: 'List the team MCP endpoints', role: Roles.Member }, + 'team:mcp:list': { description: 'List the team MCP endpoints', role: Roles.Member, access: 'read' }, - 'assistant:call': { description: 'Call the Assistant service' }, + 'assistant:call': { description: 'Call the Assistant service', access: 'write' }, // FF Expert // MCP RBACs - 'expert:insights:mcp:allow': { description: 'Can use the MCP', role: Roles.Viewer }, - 'expert:insights:mcp:prompt:allow': { description: 'Can use MCP Prompts', role: Roles.Viewer }, // FUTURE - ff expert MCP prompts not yet implemented - 'expert:insights:mcp:resource:allow': { description: 'Can use MCP Resources', role: Roles.Viewer }, - 'expert:insights:mcp:resourcetemplate:allow': { description: 'Can use MCP Resource Templates', role: Roles.Viewer }, - 'expert:insights:mcp:tool:allow': { description: 'Can use readonly MCP Tools', role: Roles.Viewer }, // By default, viewer can use readonly tools,non-destructive, non-open-world tools - 'expert:insights:mcp:tool:write': { description: 'Can use readonly MCP Tools', role: Roles.Member }, // readonly=false: implies it may modify data (though not necessarily destructive) - 'expert:insights:mcp:tool:destructive': { description: 'Can use destructive MCP Tools', role: Roles.Owner }, // destructive true implies it may perform destructive actions - 'expert:insights:mcp:tool:open-world': { description: 'Can use open-world MCP Tools', role: Roles.Member }, // open-world true implies it interacts with external entities - 'expert:insights:mcp:tool:non-idempotent': { description: 'Can use non-idempotent MCP Tools', role: Roles.Member } // non-idempotent true implies it can NOT be safely called multiple times without side-effects. Only matters if readonly is false or destructive is true + 'expert:insights:mcp:allow': { description: 'Can use the MCP', role: Roles.Viewer, access: 'read' }, + 'expert:insights:mcp:prompt:allow': { description: 'Can use MCP Prompts', role: Roles.Viewer, access: 'read' }, // FUTURE - ff expert MCP prompts not yet implemented + 'expert:insights:mcp:resource:allow': { description: 'Can use MCP Resources', role: Roles.Viewer, access: 'read' }, + 'expert:insights:mcp:resourcetemplate:allow': { description: 'Can use MCP Resource Templates', role: Roles.Viewer, access: 'read' }, + 'expert:insights:mcp:tool:allow': { description: 'Can use readonly MCP Tools', role: Roles.Viewer, access: 'read' }, // By default, viewer can use readonly tools,non-destructive, non-open-world tools + 'expert:insights:mcp:tool:write': { description: 'Can use readonly MCP Tools', role: Roles.Member, access: 'write' }, // readonly=false: implies it may modify data (though not necessarily destructive) + 'expert:insights:mcp:tool:destructive': { description: 'Can use destructive MCP Tools', role: Roles.Owner, access: 'write' }, // destructive true implies it may perform destructive actions + 'expert:insights:mcp:tool:open-world': { description: 'Can use open-world MCP Tools', role: Roles.Member, access: 'write' }, // open-world true implies it interacts with external entities + 'expert:insights:mcp:tool:non-idempotent': { description: 'Can use non-idempotent MCP Tools', role: Roles.Member, access: 'write' } // non-idempotent true implies it can NOT be safely called multiple times without side-effects. Only matters if readonly is false or destructive is true } module.exports = { diff --git a/package-lock.json b/package-lock.json index 641a043690..06eea10fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/request-context": "^7.0.0", "@fastify/routes": "^6.0.2", "@fastify/static": "^9.1.2", "@fastify/swagger": "^9.6.1", @@ -4314,6 +4315,25 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/request-context": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-7.0.0.tgz", + "integrity": "sha512-SMnQ4VWa1p20qOSONvi+yG32vqyHoZE6LPD4xrIl3RVE11dLrBwiQ7VmK4aTCS7/jB5Tx6fI9JotTNErClPS5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/response-validation": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@fastify/response-validation/-/response-validation-3.0.3.tgz", @@ -29492,6 +29512,14 @@ "toad-cache": "^3.7.0" } }, + "@fastify/request-context": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-7.0.0.tgz", + "integrity": "sha512-SMnQ4VWa1p20qOSONvi+yG32vqyHoZE6LPD4xrIl3RVE11dLrBwiQ7VmK4aTCS7/jB5Tx6fI9JotTNErClPS5A==", + "requires": { + "fastify-plugin": "^5.0.0" + } + }, "@fastify/response-validation": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@fastify/response-validation/-/response-validation-3.0.3.tgz", diff --git a/package.json b/package.json index f08c79c6d6..395e6f3e5c 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/request-context": "^7.0.0", "@fastify/routes": "^6.0.2", "@fastify/static": "^9.1.2", "@fastify/swagger": "^9.6.1", diff --git a/test/unit/forge/db/models/AccessTokenTeamScope_spec.js b/test/unit/forge/db/models/AccessTokenTeamScope_spec.js new file mode 100644 index 0000000000..44548c15aa --- /dev/null +++ b/test/unit/forge/db/models/AccessTokenTeamScope_spec.js @@ -0,0 +1,310 @@ +const should = require('should') // eslint-disable-line +const setup = require('../setup') + +const FF_UTIL = require('flowforge-test-utils') +const { Permissions } = FF_UTIL.require('forge/lib/permissions') + +describe('AccessTokenTeamScope model', function () { + let app + let TestObjects + + before(async function () { + app = await setup() + TestObjects = app.TestObjects + }) + + after(async function () { + await app.close() + }) + + afterEach(async function () { + await app.db.models.AccessTokenTeamScope.destroy({ where: {} }) + await app.db.models.AccessToken.destroy({ where: {} }) + }) + + describe('New AccessToken columns', function () { + it('defaults readOnly to false', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-token-readonly-default', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + token.should.have.property('readOnly', false) + }) + + it('defaults adminOptIn to false', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-token-admin-default', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + token.should.have.property('adminOptIn', false) + }) + + it('persists readOnly and adminOptIn values', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-token-persist', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token', + readOnly: true, + adminOptIn: true + }) + token.should.have.property('readOnly', true) + token.should.have.property('adminOptIn', true) + + const reloaded = await app.db.models.AccessToken.findOne({ where: { id: token.id } }) + reloaded.should.have.property('readOnly', true) + reloaded.should.have.property('adminOptIn', true) + }) + }) + + describe('CRUD operations', function () { + it('creates a scope entry linking a token, team, and user', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-scope-create', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + const scope = await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + + scope.should.have.property('AccessTokenId', token.id) + scope.should.have.property('TeamId', TestObjects.team1.id) + scope.should.have.property('UserId', TestObjects.userAlice.id) + }) + + it('reads scope entries for a token', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-scope-read', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team3.id, + UserId: TestObjects.userAlice.id + }) + + const scopes = await app.db.models.AccessTokenTeamScope.findAll({ + where: { AccessTokenId: token.id } + }) + scopes.should.have.length(2) + }) + + it('deletes a scope entry', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-scope-delete', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + const scope = await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + + await scope.destroy() + const remaining = await app.db.models.AccessTokenTeamScope.findAll({ + where: { AccessTokenId: token.id } + }) + remaining.should.have.length(0) + }) + + it('enforces unique constraint on (AccessTokenId, TeamId)', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-scope-unique', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + + try { + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + should.fail('Expected unique constraint error') + } catch (err) { + err.name.should.match(/SequelizeUniqueConstraintError/) + } + }) + }) + + describe('CASCADE behavior', function () { + it('deleting an AccessToken removes its scope entries', async function () { + const token = await app.db.models.AccessToken.create({ + token: 'test-cascade-token', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team3.id, + UserId: TestObjects.userAlice.id + }) + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { AccessTokenId: token.id } })).should.equal(2) + + await token.destroy() + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { AccessTokenId: token.id } })).should.equal(0) + }) + + it('deleting a Team removes scope entries referencing it', async function () { + // Create a temporary team to delete + const tempTeam = await app.db.models.Team.create({ + name: 'TempTeamCascade', + TeamTypeId: TestObjects.defaultTeamType.id + }) + + const token = await app.db.models.AccessToken.create({ + token: 'test-cascade-team', + scope: 'user', + ownerId: '' + TestObjects.userAlice.id, + ownerType: 'user', + name: 'test-token' + }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: tempTeam.id, + UserId: TestObjects.userAlice.id + }) + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { TeamId: tempTeam.id } })).should.equal(1) + + await tempTeam.destroy() + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { TeamId: tempTeam.id } })).should.equal(0) + + // Token itself should still exist + const reloadedToken = await app.db.models.AccessToken.findOne({ where: { id: token.id } }) + should.exist(reloadedToken) + }) + + it('deleting a User removes scope entries referencing them', async function () { + // Create a temporary user to delete + const tempUser = await app.db.models.User.create({ + username: 'temp-cascade-user', + name: 'Temp User', + email: 'temp-cascade@example.com', + password: 'ttPassword', + email_verified: true + }) + + const token = await app.db.models.AccessToken.create({ + token: 'test-cascade-user', + scope: 'user', + ownerId: '' + tempUser.id, + ownerType: 'user', + name: 'test-token' + }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: token.id, + TeamId: TestObjects.team1.id, + UserId: tempUser.id + }) + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { UserId: tempUser.id } })).should.equal(1) + + await tempUser.destroy() + + ;(await app.db.models.AccessTokenTeamScope.count({ where: { UserId: tempUser.id } })).should.equal(0) + }) + }) + + describe('getPersonalAccessTokens', function () { + it('returns readOnly and adminOptIn fields', async function () { + await app.db.controllers.AccessToken.createPersonalAccessToken( + TestObjects.userAlice, ['user:read'], null, 'scoped-token-1' + ) + + const tokens = await app.db.models.AccessToken.getPersonalAccessTokens(TestObjects.userAlice) + tokens.should.have.length(1) + tokens[0].should.have.property('readOnly', false) + tokens[0].should.have.property('adminOptIn', false) + }) + + it('returns eager-loaded team scopes', async function () { + const result = await app.db.controllers.AccessToken.createPersonalAccessToken( + TestObjects.userAlice, ['user:read'], null, 'scoped-token-2' + ) + + // Decode the hashid to get the real token id + const tokenId = app.db.models.AccessToken.decodeHashid(result.id) + const dbToken = await app.db.models.AccessToken.findOne({ where: { id: tokenId } }) + + await app.db.models.AccessTokenTeamScope.create({ + AccessTokenId: dbToken.id, + TeamId: TestObjects.team1.id, + UserId: TestObjects.userAlice.id + }) + + const tokens = await app.db.models.AccessToken.getPersonalAccessTokens(TestObjects.userAlice) + tokens.should.have.length(1) + tokens[0].AccessTokenTeamScopes.should.have.length(1) + tokens[0].AccessTokenTeamScopes[0].Team.should.have.property('name', 'ATeam') + }) + + it('returns empty team scopes array for unscoped tokens', async function () { + await app.db.controllers.AccessToken.createPersonalAccessToken( + TestObjects.userAlice, ['user:read'], null, 'unscoped-token' + ) + + const tokens = await app.db.models.AccessToken.getPersonalAccessTokens(TestObjects.userAlice) + tokens.should.have.length(1) + tokens[0].AccessTokenTeamScopes.should.have.length(0) + }) + }) + + describe('Permissions access tagging', function () { + it('every permission has an access field set to read or write', function () { + const keys = Object.keys(Permissions) + keys.length.should.be.above(0) + for (const key of keys) { + Permissions[key].should.have.property('access') + ;['read', 'write'].should.containEql(Permissions[key].access) + } + }) + }) +})