From d781cdef0c18e7efe2a18b58473dee67fee0fcab Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 22 Jan 2026 17:52:00 +0530 Subject: [PATCH] Improve documentation --- .github/trivy.yaml | 2 +- .github/workflows/branch-deploy.yaml | 17 +- .github/workflows/docs.yml | 4 +- .github/workflows/install-test.yaml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release-publish.yml | 4 +- .github/workflows/sast-scan.yml | 39 +-- .github/workflows/tests.yml | 4 +- CHANGELOG.md | 40 +++ docs/README.md | 2 +- docs/device-agent/register.md | 20 +- docs/user/concepts.md | 41 ++- forge/db/models/Device.js | 96 +++++- forge/db/models/Project.js | 19 +- forge/ee/db/models/MCPRegistration.js | 4 +- forge/ee/routes/sso/social/google.js | 6 +- forge/lib/permissions.js | 14 +- forge/lib/userTeam.js | 4 +- forge/routes/api/expert.js | 116 ++++++- forge/routes/api/settings.js | 1 + forge/routes/api/team.js | 16 +- forge/routes/api/users.js | 11 +- forge/services/expert.js | 109 ++++++ forge/settings/defaults.js | 2 + frontend/src/api/team.js | 8 +- frontend/src/components/DevicesBrowser.vue | 14 +- frontend/src/components/DropdownMenu.vue | 35 +- frontend/src/components/TeamSelection.vue | 20 +- .../components/CapabilitiesSelector.vue | 18 +- .../resource-cards/FlowResourceCard.vue | 12 +- .../resource-cards/PackageResourceCard.vue | 24 ++ .../components/file-browser/FileBrowser.vue | 6 +- .../timeline/TimelineEvent.vue | 14 +- frontend/src/main.js | 62 ++-- frontend/src/mixins/BoxOptionsMixin.js | 66 ---- frontend/src/mixins/TeleportedMenuMixin.js | 135 ++++++++ .../src/pages/account/Security/Tokens.vue | 4 +- frontend/src/pages/account/Teams/Teams.vue | 2 +- .../src/pages/admin/FlowBlueprints/index.vue | 4 +- .../src/pages/admin/InstanceTypes/index.vue | 4 +- frontend/src/pages/admin/Settings/General.vue | 4 + .../src/pages/admin/Settings/SSO/index.vue | 4 +- frontend/src/pages/admin/Stacks/index.vue | 12 +- frontend/src/pages/admin/TeamTypes/index.vue | 4 +- frontend/src/pages/admin/Templates/index.vue | 4 +- frontend/src/pages/admin/Users/General.vue | 2 +- .../src/pages/admin/Users/Invitations.vue | 2 +- frontend/src/pages/application/Overview.vue | 8 +- .../pages/application/Settings/UserAccess.vue | 8 +- .../src/pages/application/Settings/index.vue | 4 +- frontend/src/pages/application/Snapshots.vue | 12 +- .../Editor/components/EditorWrapper.vue | 18 +- frontend/src/pages/instance/Editor/index.vue | 22 +- .../src/pages/instance/Settings/Security.vue | 4 +- .../components/compact/DeviceTile.vue | 10 +- .../components/compact/InstanceTile.vue | 10 +- frontend/src/pages/team/Billing/index.vue | 5 +- frontend/src/pages/team/Instances.vue | 8 +- .../src/pages/team/Library/TeamLibrary.vue | 2 +- frontend/src/pages/team/Members/General.vue | 21 +- .../components/ApplicationPermissionsRow.vue | 12 +- frontend/src/pages/team/Settings/Devices.vue | 4 +- .../src/pages/team/Settings/Integrations.vue | 2 +- frontend/src/services/bootstrap.service.js | 11 +- frontend/src/services/messaging.service.js | 39 ++- frontend/src/services/service.factory.js | 2 + frontend/src/store/modules/context/index.js | 12 +- .../store/modules/product/assistant/index.js | 129 ++++++++ .../src/store/modules/product/expert/index.js | 8 +- frontend/src/store/modules/product/index.js | 3 +- frontend/src/stylesheets/layouts.scss | 5 - frontend/src/tours/Tours.js | 38 ++- frontend/src/tours/tour-welcome.js | 2 + frontend/src/ui-components/components.js | 7 +- .../ui-components/components/KebabMenu.vue | 109 ------ .../src/ui-components/components/ListItem.vue | 37 --- .../src/ui-components/components/Popover.vue | 36 +- .../components/data-table/DataTableRow.vue | 4 +- .../components/form/ComboBox.vue | 35 +- .../ui-components/components/form/ListBox.vue | 50 +-- .../components/kebab-menu/KebabItem.vue | 49 +++ .../components/kebab-menu/KebabMenu.vue | 63 ++++ .../stylesheets/ff-components.scss | 9 +- .../ui-components/stylesheets/ff-core.scss | 4 +- package-lock.json | 310 +++++++++--------- package.json | 4 +- .../rbac/rbac-member-contextual.spec.js | 12 - .../rbac/rbac-owner-contextual.spec.js | 12 - .../rbac/rbac-viewer-contextual.spec.js | 12 - .../tests/admin/instance-types.spec.js | 2 +- .../cypress/tests/admin/stacks.spec.js | 2 +- .../cypress/tests/admin/team-types.spec.js | 2 +- .../cypress/tests/admin/templates.spec.js | 4 +- .../tests/applications/snapshots.spec.js | 36 +- .../frontend/cypress/tests/devices.spec.js | 2 +- test/unit/forge/db/models/Device_spec.js | 154 ++++++++- test/unit/forge/db/models/Project_spec.js | 79 ++++- test/unit/forge/ee/db/models/mcp_spec.js | 114 +++++++ test/unit/forge/ee/routes/mcp/index_spec.js | 21 +- test/unit/forge/routes/api/expert_spec.js | 260 ++++++++++++++- .../forge/routes/api/projectSnapshots_spec.js | 4 +- .../component/data-table/DataTable.spec.js | 2 +- .../unit/components/ListItem.spec.js | 36 +- 103 files changed, 2087 insertions(+), 818 deletions(-) create mode 100644 forge/services/expert.js delete mode 100644 frontend/src/mixins/BoxOptionsMixin.js create mode 100644 frontend/src/mixins/TeleportedMenuMixin.js create mode 100644 frontend/src/store/modules/product/assistant/index.js delete mode 100644 frontend/src/ui-components/components/KebabMenu.vue delete mode 100644 frontend/src/ui-components/components/ListItem.vue create mode 100644 frontend/src/ui-components/components/kebab-menu/KebabItem.vue create mode 100644 frontend/src/ui-components/components/kebab-menu/KebabMenu.vue create mode 100644 test/unit/forge/ee/db/models/mcp_spec.js diff --git a/.github/trivy.yaml b/.github/trivy.yaml index ff7c92b48a..3f1d5b311b 100644 --- a/.github/trivy.yaml +++ b/.github/trivy.yaml @@ -22,7 +22,7 @@ pkg: types: - os - library - + include-dev-deps: true format: "sarif" ignorefile: ".github/.trivyignore.yaml" diff --git a/.github/workflows/branch-deploy.yaml b/.github/workflows/branch-deploy.yaml index 8d723c88cb..ffdbee1953 100644 --- a/.github/workflows/branch-deploy.yaml +++ b/.github/workflows/branch-deploy.yaml @@ -55,7 +55,8 @@ jobs: runs-on: ubuntu-latest if: | (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && - github.actor != 'dependabot[bot]' + github.actor != 'dependabot[bot]' && + !startsWith(github.head_ref, 'release-') outputs: is_org_member: ${{ steps.validate.outputs.is_member }} steps: @@ -88,7 +89,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && inputs.driver_k8s_branch != 'main' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: driver-k8s publish_package: true @@ -105,7 +106,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && inputs.nr_project_nodes_branch != 'main' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: nr-project-nodes publish_package: true @@ -122,7 +123,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && inputs.nr_file_nodes_branch != 'main' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: nr-file-nodes publish_package: true @@ -139,7 +140,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && inputs.nr_assistant_branch != 'main' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: nr-assistant publish_package: true @@ -156,7 +157,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && inputs.nr_tables_nodes_branch != 'main' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: nr-tables-nodes publish_package: true @@ -178,7 +179,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && (always() && inputs.nr_launcher_branch != 'main') || needs.publish_nr_project_nodes.result == 'success' || needs.publish_nr_file_nodes.result == 'success' || needs.publish_nr_assistant.result == 'success' || needs.publish_nr_tables_nodes.result == 'success' - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: flowfuse-nr-launcher publish_package: true @@ -202,7 +203,7 @@ jobs: needs.validate-user.outputs.is_org_member == 'true' && github.event_name == 'workflow_dispatch' && (always() && needs.publish_nr_launcher.result == 'success') - uses: flowfuse/github-actions-workflows/.github/workflows/build_container_image.yml@v0.45.0 + uses: flowfuse/github-actions-workflows/.github/workflows/build_container_image.yml@v0.46.0 with: image_name: 'node-red' dockerfile_path: Dockerfile diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index deae4b5c6a..1b27052567 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,7 +15,7 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '18' - name: Install Dependencies @@ -53,7 +53,7 @@ jobs: with: key: img-pipeline-cache path: website/_site/img - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: './website/package-lock.json' diff --git a/.github/workflows/install-test.yaml b/.github/workflows/install-test.yaml index 702f3d9c9a..0eabb8ad67 100644 --- a/.github/workflows/install-test.yaml +++ b/.github/workflows/install-test.yaml @@ -9,7 +9,7 @@ jobs: name: Install and test runs-on: ubuntu-latest steps: - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20 - name: install diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f54ab4a019..b6313fd785 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -97,7 +97,7 @@ jobs: if: | needs.check-tests-status.outputs.tests_status == 'success' && github.ref == 'refs/heads/main' needs: check-tests-status - uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.45.0' + uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.46.0' with: package_name: flowfuse build_package: true diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index eab86a0956..c7bb65223e 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -12,12 +12,12 @@ jobs: with: token: ${{ secrets.MAINTENANCE_SYNC_TOKEN }} fetch-depth: 0 - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 18 - run: npm ci - run: npm run build # - run: npm run test - - uses: JS-DevTools/npm-publish@7f8fe47b3bea1be0c3aec2b717c5ec1f3e03410b # v4.1.1 + - uses: JS-DevTools/npm-publish@d2fef917d9aa6e1f0ee5eac28ed023eb4921ce51 # v4.1.3 with: token: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/.github/workflows/sast-scan.yml b/.github/workflows/sast-scan.yml index 71967a4862..63061910ae 100644 --- a/.github/workflows/sast-scan.yml +++ b/.github/workflows/sast-scan.yml @@ -14,39 +14,6 @@ concurrency: cancel-in-progress: true jobs: - trivy-scan: - name: Trivy Security Scan - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - - steps: - - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - name: Cache vulnerability database - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 - with: - path: .cache/trivy - key: ${{ runner.os }}-trivy-db-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-trivy-db- - - - name: Perform SAST scan - uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 - with: - scan-type: 'fs' - scan-ref: '.' - trivy-config: '.github/trivy.yaml' - output: 'trivy-results.sarif' - env: - TRIVY_FAIL_ON_SEVERITY: ${{ vars.TRIVY_FAIL_ON_SEVERITY || 'NONE' }} - - - name: Upload scan results to GitHub - uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 - if: always() - with: - sarif_file: 'trivy-results.sarif' - category: 'trivy-sast' + scan: + name: SAST Scan + uses : flowfuse/github-actions-workflows/.github/workflows/sast_scan.yaml@v0.46.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11041b37c3..2122c168c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies @@ -86,7 +86,7 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup NodeJS ${{ matrix.node-version }} - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 3374d62fda..075b5daa5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +#### 2.26.0: Release + + - Cover development dependecies in SAST scan (#6495) + - Do not create pre-staging on release (#6482) + - Bump JS-DevTools/npm-publish from 4.1.1 to 4.1.3 (#6492) + - Improve menu width calculation based on first child width (#6514) @cstns + - Clean up related database rows upon device and project deletion (#6424) @Steve-Mcl + - Filter MCP features based on granular RBACs (#6494) @Steve-Mcl + - Chore: refactor kebab menu item naming (#6490) @cstns + - ci: Use reusable workflow in the `SAST Scan` pipeline (#6499) @ppawlowski + - Replace kebab menu with headless UI (#6489) @cstns + - Facilitate post message communication between the FF App and the NR Assistant (#6498) @cstns + - Refactor tour cancel logic and add final step handling for hosted instance tours (#6511) @cstns + - Add admin access override to `countByState` checks in projects and devices models (#6500) @cstns + - Expose restricted applications for owners listing when team memberships (#6510) @cstns + - Teleported menus alignment (#6488) @cstns + - Add option to disable provisioning new users via google login (#6485) @knolleary + - Enable trial team creation for admin-created users (#6483) @knolleary + - Fix/members rbac permission (#6486) @cstns + - Allow admins to change grbac roles when he is a member of a team (#6476) @cstns + - Corrected the URL for Getting Started HyperLink (#6471) @Lakshita7 + - ci: Bump slack-github-action to `v2.2.1` in `Publish` and `Tests` (#6464) @ppawlowski + - ci: Bump slack-github-action to `v2.2.1` in `Install Test` (#6463) @ppawlowski + - ci: Bump `slack-github-action` to `v2.2.1` in `Create pre-staging environment` (#6462) @ppawlowski + - Bump sinon from 19.0.2 to 21.0.1 (#6449) @app/dependabot + - Bump actions/cache from 4.3.0 to 5.0.1 (#6455) @app/dependabot + - Bump sass-loader from 16.0.5 to 16.0.6 (#6450) @app/dependabot + - Bump @fastify/static from 8.3.0 to 9.0.0 (#6451) @app/dependabot + - Bump github/codeql-action from 4.31.8 to 4.31.9 (#6454) @app/dependabot + - Bump cypress-io/github-action from 6.10.7 to 6.10.8 (#6456) @app/dependabot + - Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#6452) @app/dependabot + - Bump @immobiliarelabs/fastify-sentry to support Fastify v5 (#6447) @hardillb + - Fastify v5 upgrade (#6442) @hardillb + - Bump actions/download-artifact from 6.0.0 to 7.0.0 (#6446) @app/dependabot + - Bump flowfuse/github-actions-workflows/.github/workflows/build_container_image.yml from 0.43.0 to 0.45.0 (#6445) @app/dependabot + - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml from 0.44.0 to 0.45.0 (#6444) @app/dependabot + - Bump 1password/install-cli-action from 2.0.1 to 2.0.2 (#6443) @app/dependabot + - Expert MCP feature branch (#6436) @cstns + - Add docs on standalone FF Assistant (#6438) @knolleary + #### 2.25.0: Release - Enable schedule instance restart (#6408) @hardillb diff --git a/docs/README.md b/docs/README.md index ecf020cbef..ea2576e24f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,7 +90,7 @@ It covers everything from setup, to usage, and development. All [contributions]( ### Getting Started -Here are some quick reference links to our most popular topics. You can also view the full documentation available for FlowFuse in our [Getting Started](http://localhost:8080/docs/user/introduction) guide. +Here are some quick reference links to our most popular topics. You can also view the full documentation available for FlowFuse in our [Getting Started](https://flowfuse.com/docs/user/introduction/) guide.
diff --git a/docs/device-agent/register.md b/docs/device-agent/register.md index 18f800ad27..345383b33e 100644 --- a/docs/device-agent/register.md +++ b/docs/device-agent/register.md @@ -147,7 +147,6 @@ flowfuse-device-agent You will see the Device Agent start and perform a 'call-home' where it connects back to the platform to check what it should be running. - #### Additional Information If you copy or download a **Device Provisioning Configuration** file to your hardware, @@ -164,12 +163,12 @@ you've followed [Single Device Registration](#single-remote-instance-registratio [Bulk Registration](#bulk-registration) to register your device, it will automatically be assigned to an Application or Instance. -### Applications (Recommended) - -#### Assign to Application +### Applications This step will permit you to push Snapshots to your Remote Instance via [DevOps Pipelines](/docs/user/devops-pipelines.md), or via a [Target Snapshot](/docs/user/snapshots/#application-owned-devices) from the Application. +#### Assign to Application + 1. Go to your teams's **Remote Instances** page. 2. Open the dropdown menu to the right of the Remote Instance you want to assign and select the **Add to Application** option. @@ -206,14 +205,17 @@ NOTE: If you wish to keep the flows currently running on the Remote Instance, it ### Hosted Instances -This method permits you to set a [Target Snapshot](/docs/user/snapshots/#instance-owned-devices) from the Hosted Instance to the Remote Instance. Note though, you can only push nodes and flows that are supported by _both_ Hosted and Remote Instances. The best use case for Remote Instances are generally to be assigned by an Application instead. +This method establishes a deployment relationship where a Hosted Instance becomes the source for snapshot deployments to your Remote Instance(s). + +**Important:** This is a legacy feature; [assigning to Applications](./register.md#applications) is the recommended approach as it provides better fleet management and DevOps Pipeline capabilities. For guidance, see [when to use each approach](/docs/user/concepts.md#when-to-use-instance-assignment-vs-devops-pipelines). #### Assign to Hosted Instance -1. Go to your teams's **Remote Instances** page. -2. Open the dropdown menu to the right of the Remote Instance you want to assign and - select the **Add to Hosted Instance** option. -3. Select the instance in the dialog and click **Add** to continue. +1. Go to your team's **Remote Instances** page. +2. Open the dropdown menu to the right of the Remote Instance you want to assign and select the **Add to Instance** option. +3. Select the Hosted Instance in the dialog and click **Add** to continue. + +**Note:** There are constraints on which instances can be assigned to each other. For detailed information, refer to [Assignment Rules and Constraints](/docs/user/concepts.md#assignment-rules). ### Remove from Hosted Instance diff --git a/docs/user/concepts.md b/docs/user/concepts.md index e01a1249a7..0e591022df 100644 --- a/docs/user/concepts.md +++ b/docs/user/concepts.md @@ -5,7 +5,7 @@ navOrder: 2 # FlowFuse Concepts -FlowFuse makes it easy to create, manage, and scale Node-RED instances. The platform introduces a few core concepts to help you organize and work with it effectively. Throughout the platform, you’ll also see ⓘ icons that you can click on to get pop-up explanations for different features and terms. +FlowFuse makes it easy to create, manage, and scale Node-RED instances. The platform introduces a few core concepts to help you organize and work with it effectively. Throughout the platform, you'll also see ⓘ icons that you can click on to get pop-up explanations for different features and terms. ## Table of Contents @@ -25,7 +25,11 @@ FlowFuse makes it easy to create, manage, and scale Node-RED instances. The plat - [Template](#template) - [Snapshot](#snapshot) - [Hosted Instance Snapshot](#hosted-instance-snapshot) - - [Remote Instances (Device Snapshot)](#remote-instance-snapshot) + - [Remote Instance Snapshot](#remote-instance-snapshot) + - [Assigning Remote Instances to Hosted Instances](#assigning-remote-instances-to-hosted-instances) + - [How It Works](#how-it-works) + - [Assignment Rules](#assignment-rules) + - [When to Use Instance Assignment vs DevOps Pipelines](#when-to-use-instance-assignment-vs-devops-pipelines) - [Device](#device) - [Device Agent](#device-agent) - [Fleet Mode vs Developer Mode](#fleet-mode-vs-developer-mode) @@ -50,7 +54,7 @@ To organize your Node-RED instances, they are grouped within Applications. With Applications provide logical organization of related instances, support for DevOps pipeline workflows, simplified device group management, application-level audit logging, and clear organizational boundaries for managing multiple instances and devices. -With FlowFuse’s Granular RBAC, Applications can now also act as an authorization boundary. This means user roles and permissions can be managed at the application level, providing finer control over access to instances, snapshots, and devices within a given application. +With FlowFuse's Granular RBAC, Applications can now also act as an authorization boundary. This means user roles and permissions can be managed at the application level, providing finer control over access to instances, snapshots, and devices within a given application. ### DevOps Pipeline @@ -118,6 +122,35 @@ A user can create an hosted instance snapshot and then mark it as the *target* s Similar to instance snapshots, a device snapshot is a point-in-time backup of a Node-RED instance running on a remote device. It captures the flows, credentials, and runtime settings. The difference is that local changes made on a device during developer mode are pulled into the FlowFuse platform and stored as a snapshot. The dashboard also allows you to see and manage these snapshots. With devices assigned to an application, a user can create a device snapshot from the remote device. +### Assigning Remote Instances to Hosted Instances + +**Note:** This is a legacy feature that predates DevOps Pipelines. For new deployments, consider using [DevOps Pipelines](#devops-pipeline) instead, which provide more flexible and powerful deployment workflows. + +Instance assignment establishes a parent-child deployment relationship between a hosted instance and remote instances. When assigned, the hosted instance becomes the source of truth and automatically pushes snapshots to the remote instance. You can only push nodes and flows that are supported by both Hosted and Remote Instances. For more information on how this works, see [How Instance Assignment Works](/docs/user/concepts.md#how-it-works). + +#### How It Works + +Instance assignment creates a parent-child deployment relationship with the following behavior: + +- **Hosted Instance (Parent)**: Acts as the deployment controller and source of truth for snapshots +- **Remote Instance (Child)**: Receives and applies snapshot updates from the parent +- **Deployment Flow**: When you set a [Target Snapshot](/docs/user/snapshots/#instance-owned-devices) on the hosted instance, all assigned remote instances automatically restart on that snapshot +- **One-Way Relationship**: Changes flow exclusively from the hosted instance to remote instances, ensuring consistent deployments across your fleet + +#### Assignment Rules + +FlowFuse enforces specific constraints on instance assignment: + +- Remote instances can be assigned to hosted instances +- Remote instances cannot be assigned to other remote instances +- Hosted instances cannot be assigned to any other instances + +#### When to Use Instance Assignment vs DevOps Pipelines + +Instance assignment suits straightforward scenarios where you need to push snapshots from a single hosted instance directly to one or more remote instances. It works well for maintaining existing deployments or when you simply need one hosted instance deploying to multiple remote instances without staging environments. + +DevOps Pipelines are recommended for most deployment workflows. They support staged environments for testing changes in development before promoting to production, enable deployment to device groups for easier fleet management, and offer more flexibility aligned with modern development practices. If you're setting up a new deployment process, use DevOps Pipelines. + ## Device The FlowFuse platform can be used to manage Node-RED applications running on remote devices. A Device is essentially a **Remote Instance** that runs a software agent to connect back to the platform and receive updates. @@ -148,4 +181,4 @@ Device groups allow you to organize your devices into logical groups. These grou Device groups provide logical organization of devices by location, function, environment, or other criteria. They enable simplified mass deployments through DevOps pipelines, allow you to target specific device groups for staged rollouts, and let you manage subsets of devices independently. -Read more [about Device Groups](./device-groups.md). +Read more [about Device Groups](./device-groups.md). \ No newline at end of file diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 1f1afdb809..bc2e669e1f 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -167,6 +167,98 @@ module.exports = { ownerId: '' + device.id } }) + // if MCPRegistration model is available (EE mode), remove any registrations for this device + if (app.db.models.MCPRegistration?.destroy) { + try { + await app.db.models.MCPRegistration.destroy({ + where: { + targetType: 'device', + targetId: '' + device.id + } + }) + } catch (err) { + // The destroy may fail if the DB connection is closed (e.g. during tests)! + // Log the error but proceed as the instance has been deleted anyway + app.log.error(`Error removing MCPRegistrations for deleted device ${device.id}: ${err.message}`) + } + } + }, + afterBulkDestroy: async (options) => { + // Note: options.where may be empty, meaning all records are being deleted + // however, since the bulk device deletion is initiated via a `where in [...]` clause, + // we can assume that if options.where is empty, there are no devices to clean up. + // i.e. only clean up if options.where contains an array `.id` + if (!options.where || !options.where.id || !Array.isArray(options.where.id) || options.where.id.length === 0) { + return + } + const deviceIds = [...options.where.id] + const deviceIdsStrings = deviceIds.map(id => '' + id) + + // clean up related models + await M.AccessToken.destroy({ + where: { + ownerType: 'device', + ownerId: { + [Op.in]: deviceIdsStrings + } + } + }) + await M.AccessToken.destroy({ + where: { + ownerType: 'npm', + ownerId: { + [Op.in]: deviceIds.map(id => `d-${M.Device.encodeHashid(id)}@%`) + } + } + }) + await M.DeviceSettings.destroy({ + where: { + DeviceId: { + [Op.in]: deviceIds + } + } + }) + await M.BrokerClient.destroy({ + where: { + ownerType: 'device', + ownerId: { + [Op.in]: deviceIdsStrings + } + } + }) + await M.AuthClient.destroy({ + where: { + ownerType: 'device', + ownerId: { + [Op.in]: deviceIdsStrings + } + } + }) + await M.TeamBrokerClient.destroy({ + where: { + ownerType: 'device', + ownerId: { + [Op.in]: deviceIdsStrings + } + } + }) + // if MCPRegistration model is available (EE mode), remove any registrations for these devices + if (app.db.models.MCPRegistration?.destroy) { + try { + await app.db.models.MCPRegistration.destroy({ + where: { + targetType: 'device', + targetId: { + [Op.in]: deviceIdsStrings + } + } + }) + } catch (err) { + // The destroy may fail if the DB connection is closed (e.g. during tests)! + // Log the error but proceed as the instance has been deleted anyway + app.log.error(`Error removing MCPRegistrations for deleted devices ${deviceIdsStrings.join(', ')}: ${err.message}`) + } + } } } }, @@ -680,7 +772,7 @@ module.exports = { }) } }, - countByState: async (states, team, applicationId, membership) => { + countByState: async (states, team, applicationId, membership, isAdmin) => { let teamId = team.id if (typeof teamId === 'string') { @@ -735,7 +827,7 @@ module.exports = { findAll.forEach((device) => { const applicationId = device.Application?.hashid ?? device.Project?.Application?.hashid - if (rbacEnabled && applicationId && !app.hasPermission(membership, 'device:read', { applicationId })) { + if (rbacEnabled && applicationId && !app.hasPermission(membership, 'device:read', { applicationId }) && !isAdmin) { // This device is not accessible to this user, do not include in states map return } diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index b2457dd11b..2c9b6ff81c 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -240,6 +240,21 @@ module.exports = { ownerId: project.id } }) + // if MCPRegistration model is available (EE mode), remove any registrations for this instance + if (app.db.models.MCPRegistration?.destroy) { + try { + await app.db.models.MCPRegistration.destroy({ + where: { + targetType: 'instance', + targetId: project.id + } + }) + } catch (err) { + // The destroy may fail if the DB connection is closed (e.g. during tests)! + // Log the error but proceed as the instance has been deleted anyway + app.log.error(`Error removing MCPRegistrations for deleted instance ${project.id}: ${err.message}`) + } + } } } }, @@ -669,7 +684,7 @@ module.exports = { ] }) }, - countByState: async (states, team, applicationId, membership) => { + countByState: async (states, team, applicationId, membership, isAdmin) => { let teamId = team.id if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) @@ -714,7 +729,7 @@ module.exports = { const rbacEnabled = platformRbacEnabled && teamRbacEnabled for (const project of results) { - if (rbacEnabled && !app.hasPermission(membership, 'project:read', { applicationId: project.Application.hashid })) { + if (rbacEnabled && !app.hasPermission(membership, 'project:read', { applicationId: project.Application.hashid }) && !isAdmin) { // This instance is not accessible to this user, do not include in states map continue } diff --git a/forge/ee/db/models/MCPRegistration.js b/forge/ee/db/models/MCPRegistration.js index 9b712f1178..6755c80773 100644 --- a/forge/ee/db/models/MCPRegistration.js +++ b/forge/ee/db/models/MCPRegistration.js @@ -61,13 +61,13 @@ module.exports = { if (includeInstance) { include.push({ model: M.Project, - attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'ApplicationId'], + attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'ApplicationId', 'state'], required: false, on: instanceOwnershipJoin }) include.push({ model: M.Device, - attributes: ['hashid', 'id', 'name', 'type', 'ApplicationId'], + attributes: ['hashid', 'id', 'name', 'type', 'ApplicationId', 'state'], required: false, on: deviceOwnershipJoin, include: { diff --git a/forge/ee/routes/sso/social/google.js b/forge/ee/routes/sso/social/google.js index 7bf01fe8b5..69ebfc8a0f 100644 --- a/forge/ee/routes/sso/social/google.js +++ b/forge/ee/routes/sso/social/google.js @@ -61,7 +61,7 @@ module.exports = fp(async function (app, opts) { reply.send({ url: '/' }) - } else { + } else if (app.settings.get('platform:sso:google:auto-create') === true) { // Create a new user for this email address const userProperties = { name: googleUserInfo.name || googleUserInfo.email.split('@')[0], @@ -101,6 +101,10 @@ module.exports = fp(async function (app, opts) { reply.send({ url: '/' }) + } else { + reply.send({ + url: '/' + }) } } catch (err) { app.log.error(`Google SSO failed: ${err}`) diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 50aeb5f114..32160dca3a 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -220,7 +220,19 @@ const Permissions = { // MCP 'team:mcp:list': { description: 'List the team MCP endpoints', role: Roles.Member }, - 'assistant:call': { description: 'Call the Assistant service' } + 'assistant:call': { description: 'Call the Assistant service' }, + + // 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 } module.exports = { diff --git a/forge/lib/userTeam.js b/forge/lib/userTeam.js index 148698081c..dbaafc4e67 100644 --- a/forge/lib/userTeam.js +++ b/forge/lib/userTeam.js @@ -5,7 +5,7 @@ const crypto = require('crypto') * - creates user team (if `user:team:auto-create` is enabled * - accepts any invitations matching the email */ -async function completeUserSignup (app, user) { +async function completeUserSignup (app, user, { createTeamOverride = false } = {}) { // Process invites first to see if user is in any teams const pendingInvitations = await app.db.models.Invitation.forExternalEmail(user.email) for (let i = 0; i < pendingInvitations.length; i++) { @@ -22,7 +22,7 @@ async function completeUserSignup (app, user) { } let personalTeam - if (app.settings.get('user:team:auto-create')) { + if (createTeamOverride || app.settings.get('user:team:auto-create')) { const teamLimit = app.license.get('teams') const teamCount = await app.db.models.Team.count() if (teamCount >= teamLimit) { diff --git a/forge/routes/api/expert.js b/forge/routes/api/expert.js index b1ed28af25..cf83e94801 100644 --- a/forge/routes/api/expert.js +++ b/forge/routes/api/expert.js @@ -8,6 +8,9 @@ */ const { default: axios } = require('axios') const { v4: uuidv4 } = require('uuid') + +const { filterAccessibleMCPServerFeatures } = require('../../services/expert.js') + module.exports = async function (app) { // Get the assistant service configuration const serviceEnabled = app.config.expert?.enabled === true @@ -52,6 +55,7 @@ module.exports = async function (app) { if (!existingRole) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } + request.teamMembership = existingRole request.team = await app.db.models.Team.byId(teamId) if (!request.team) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) @@ -94,6 +98,32 @@ module.exports = async function (app) { async (request, reply) => { const sessionId = request.headers['x-chat-session-id'] ?? uuidv4() const transactionId = request.headers['x-chat-transaction-id'] + const context = request.body.context || {} + + // If MCP capabilities are provided in the context, filter them based on user access + const selectedCapabilities = context.selectedCapabilities + if (selectedCapabilities && Array.isArray(selectedCapabilities) && selectedCapabilities.length > 0) { + const applications = {} + const mcpServersList = [] + + // first pass - get associated applications for the MCP servers selected by user + for (const server of selectedCapabilities || []) { + const applicationId = server.application + if (!applicationId) { continue } + + if (!Object.hasOwnProperty.call(applications, applicationId)) { + applications[applicationId] = await app.db.models.Application.byId(applicationId) + } + const application = applications[applicationId] + if (application) { + mcpServersList.push({ server, application }) + } + } + // second pass - filter features per MCP server based on user access to features (e.g. a tool with the destructive hint requires extra permission than a read-only tool) + const filteredServers = filterAccessibleMCPServerFeatures(app, mcpServersList, request.team, request.teamMembership) + context.selectedCapabilities = filteredServers?.length > 0 ? filteredServers : undefined + } + let query = request.body.query if (request.body.history) { query = '' @@ -160,6 +190,7 @@ module.exports = async function (app) { type: 'object', properties: { team: { type: 'string' }, + application: { type: 'string' }, instance: { type: 'string' }, instanceType: { type: 'string', enum: ['instance', 'device'] }, instanceName: { type: 'string' }, @@ -198,38 +229,73 @@ module.exports = async function (app) { const runningInstancesWithMCPServer = [] const transactionId = request.headers['x-chat-transaction-id'] const mcpCapabilitiesUrl = `${expertUrl.split('/').slice(0, -1).join('/')}/mcp/features` + + // Get the MCP servers registered for this team const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || [] + // Scan each MCP server and ensure the user has access to the associated application and that the instance is running + // then collect the MCP server info for the running instances MCP servers + // filter out any that the user doesn't have access to + const applicationCache = {} + const instanceToApplicationLookup = {} for (const server of mcpServers) { const { name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server if (TeamId !== request.team.id) { // shouldn't happen due to byTeam filter, but just in case continue } - let owner, ownerId, ownerType + let instance, instanceId, instanceType if (Device) { - ownerType = 'device' - owner = Device - ownerId = Device.hashid + instanceType = 'device' + instance = Device + instanceId = Device.hashid } else if (Project) { - ownerType = 'instance' - owner = Project - ownerId = Project.id + instanceType = 'instance' + instance = Project + instanceId = Project.id } else { continue } - const liveState = await owner.liveState({ omitStorageFlows: true }) + // if instance is not expected to be running, skip it (avoids unnecessary timeouts) + if (instance?.state !== 'running') { + continue + } + + // Ensure an application is linked to this instance + const applicationId = app.db.models.Application.encodeHashid(instance.ApplicationId) + if (!applicationId) { + continue // e.g. skip devices without an application as they can't be validated for access + } + if (!applicationCache[applicationId]) { + const applicationModel = await app.db.models.Application.byId(applicationId) + applicationCache[applicationId] = applicationModel + } + const application = applicationCache[applicationId] + if (!application) { + continue // skip - application not found + } + instanceToApplicationLookup[instanceId] = application + + // Now we have the application & know it is supposed to be running, check user actually has access + // before bothering to check instance live state or calling backend for MCP features! + if (!app.hasPermission(request.teamMembership, 'expert:insights:mcp:allow', { application })) { + continue // user doesn't have access to this instance + } + + // Now we have confirmed access is allowed, double check instance is running before offering MCP features (will avoid timeouts) + const liveState = await instance.liveState({ omitStorageFlows: true }) if (liveState?.meta?.state !== 'running') { continue } runningInstancesWithMCPServer.push({ team: request.team.hashid, - instance: ownerId, - instanceType: ownerType, - instanceName: owner.name, - instanceUrl: owner.url, + application: application.hashid, + instance: instanceId, + instanceType, + instanceName: instance.name, + instanceUrl: instance.url, mcpServerName: name, mcpEndpoint: endpointRoute, mcpProtocol: protocol, @@ -238,9 +304,18 @@ module.exports = async function (app) { description }) } + + // if no running instances with MCP server, return early if (runningInstancesWithMCPServer.length === 0) { return reply.send({ servers: [], transactionId }) } + + // Call to backend to request MCP capabilities from expert service + // For reference - this POST: + // * calls the backend expert service endpoint /mcp/features + // * it connects to each MCP server registered + // * retrieves the prompts/resources/tools + // * adds them to the response along with the MCP server info const response = await axios.post(mcpCapabilitiesUrl, { teamId: request.team.hashid, servers: runningInstancesWithMCPServer @@ -256,6 +331,22 @@ module.exports = async function (app) { if (response.data.transactionId !== transactionId) { throw new Error('Transaction ID mismatch') } + const mcpServersResponse = response.data.servers || [] + const serverList = [] + // load the associate application models so that we can filter features based on user access + for (const serverItem of mcpServersResponse) { + const application = applicationCache[serverItem.application] + if (application) { + // should allays be an application due to prior checks + // skip this as bad data + serverList.push({ + server: serverItem, + application + }) + } + } + // now check tools/resources/prompts access per server based on team membership + response.data.servers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership) reply.send(response.data) } catch (error) { @@ -267,6 +358,7 @@ module.exports = async function (app) { /** * @typedef {Object} MCPServerItem MCP server info for a team * @property {string} team + * @property {string} application * @property {string} instance * @property {string} instanceType * @property {string} instanceName diff --git a/forge/routes/api/settings.js b/forge/routes/api/settings.js index ee873898e5..0ea26071fa 100644 --- a/forge/routes/api/settings.js +++ b/forge/routes/api/settings.js @@ -103,6 +103,7 @@ module.exports = async function (app) { if (app.config.features.enabled('sso') && app.settings.get('platform:sso:google') && app.settings.get('platform:sso:google:clientId')) { response['platform:sso:google'] = true response['platform:sso:google:clientId'] = app.settings.get('platform:sso:google:clientId') + response['platform:sso:google:auto-create'] = app.settings.get('platform:sso:google:auto-create') } if (app.config.features.enabled('sso')) { response['platform:sso:direct'] = app.settings.get('platform:sso:direct') diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js index 11c5039095..58ed1d5700 100644 --- a/forge/routes/api/team.js +++ b/forge/routes/api/team.js @@ -238,7 +238,8 @@ module.exports = async function (app) { properties: { associationsLimit: { type: 'number' }, includeInstances: { type: 'boolean' }, - includeApplicationDevices: { type: 'boolean' } + includeApplicationDevices: { type: 'boolean' }, + excludeOwnerFiltering: { type: 'boolean' } } }, params: { @@ -278,8 +279,11 @@ module.exports = async function (app) { includeApplicationSummary }) + const shouldExcludeOwnerFiltering = Object.prototype.hasOwnProperty.call(request.query, 'excludeOwnerFiltering') && + app.hasPermission(request.teamMembership, 'project:create') // checking for the owner role not if the user can create a project + // Apply Application level RBAC - if (!request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications) { + if (!request.session?.User?.admin && request.teamMembership && request.teamMembership.permissions?.applications && !shouldExcludeOwnerFiltering) { applications = applications.filter(application => { return app.hasPermission(request.teamMembership, 'project:read', { application }) }) @@ -1138,7 +1142,13 @@ module.exports = async function (app) { ? app.db.models.Project : app.db.models.Device const membership = request.teamMembership - const stateCounters = await model.countByState(request.query.state, request.team, request.query.applicationId, membership) ?? [] + const stateCounters = await model.countByState( + request.query.state, + request.team, + request.query.applicationId, + membership, + request.session.User?.admin + ) ?? [] const response = {} stateCounters.forEach(res => { diff --git a/forge/routes/api/users.js b/forge/routes/api/users.js index a8b6abf7f7..99c46dfca7 100644 --- a/forge/routes/api/users.js +++ b/forge/routes/api/users.js @@ -1,3 +1,5 @@ +const { completeUserSignup } = require('../../lib/userTeam') + const sharedUser = require('./shared/users') /** @@ -202,13 +204,8 @@ module.exports = async function (app) { logUserInfo.id = newUser.id await app.auditLog.User.users.userCreated(request.session.User, null, logUserInfo) if (request.body.createDefaultTeam) { - const team = await app.db.controllers.Team.createTeamForUser({ - name: `Team ${request.body.name}`, - slug: request.body.username, - TeamTypeId: (await app.db.models.TeamType.byName('starter')).id - }, newUser) - await app.auditLog.Platform.platform.team.created(request.session.User, null, team) - await app.auditLog.User.users.teamAutoCreated(request.session.User, null, team, logUserInfo) + // Create the default team for this user, include application and instance + await completeUserSignup(app, newUser, { createTeamOverride: true }) } reply.send(await app.db.views.User.userProfile(newUser)) } catch (err) { diff --git a/forge/services/expert.js b/forge/services/expert.js new file mode 100644 index 0000000000..caf50ad52c --- /dev/null +++ b/forge/services/expert.js @@ -0,0 +1,109 @@ +/** + * Filter MCP server features based on user access permissions for the owner application. + * If a user does not have access to a specific feature (e.g. a tool with destructive hint), it is removed from the server's feature list. + * If a server has no accessible features after filtering, it is removed from the list. + * @param {ForgeApplication} app + * @param {Array<{server: object, application: import('sequelize').Model, applicationId: string, instanceId: string}>} serverList + * @param {import('sequelize').Model} team + * @param {import('sequelize').Model} teamMembership + * @returns {MCPServerItem[]} + */ +module.exports.filterAccessibleMCPServerFeatures = function (app, serverList, team, teamMembership) { + const servers = [] + for (const serverDetails of serverList) { + const { server, application } = serverDetails + const permissionContext = { application } + + // sanity checks + if (!application) { + continue // not expected, but just in case + } + if (team.id !== application.TeamId || team.hashid !== server.team) { + continue + } + + // first pass - is the user allowed access to this MCP server at all? + if (!app.hasPermission(teamMembership, 'expert:insights:mcp:allow', { application })) { + continue // user doesn't have access to this instance + } + + // NOTE: prompts are not yet implemented in the expert backend + // const defaultPromptPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:prompt:allow', permissionContext) + const defaultResourcePermission = app.hasPermission(teamMembership, 'expert:insights:mcp:resource:allow', permissionContext) + const defaultResourceTemplatePermission = app.hasPermission(teamMembership, 'expert:insights:mcp:resourcetemplate:allow', permissionContext) + const defaultToolPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow', permissionContext) + const allowToolWrite = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:write', permissionContext) + const allowToolDestructive = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:destructive', permissionContext) + const allowToolOpenWorld = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:open-world', permissionContext) + const allowToolNonIdempotent = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:non-idempotent', permissionContext) + + const result = { ...server } + + if (result.resources && Array.isArray(result.resources)) { + result.resources = result.resources.filter(_resource => { + return defaultResourcePermission + }) + } + + if (result.resourceTemplates && Array.isArray(result.resourceTemplates)) { + result.resourceTemplates = result.resourceTemplates.filter(_resourceTemplate => { + return defaultResourceTemplatePermission + }) + } + + if (result.tools && Array.isArray(result.tools)) { + result.tools = result.tools.filter(tool => { + if (defaultToolPermission !== true) { + return false + } + // at this point, we have established the user has general tool access - now filter based on annotations/hints + const isReadonly = tool.annotations?.readOnlyHint === true + const isDestructive = tool.annotations?.destructiveHint !== false // air on side of caution - if not specified, assume destructive + const isOpenWorld = tool.annotations?.openWorldHint === true + const isIdempotent = tool.annotations?.idempotentHint === true + const writeAccessRequired = isDestructive === true || isReadonly === false + + // Sanity check combinations + if (isReadonly && isDestructive) { + // this is not a valid combination - destructive tools cannot be read-only + return false + } + + // test access based on hints - worst to best + if (writeAccessRequired) { + if (isDestructive === true) { + if (!allowToolDestructive) { + return false + } + } + if (isReadonly === false) { + if (!allowToolWrite) { + return false + } + } + if (isIdempotent === false) { + if (!allowToolNonIdempotent) { + return false + } + } + } + if (isOpenWorld === true) { + if (!allowToolOpenWorld) { + return false + } + } + return true + }) + } + servers.push(result) + } + + // finally, before sending the response, filter out any servers that have no accessible features + return servers.filter(server => { + const hasPrompts = Array.isArray(server.prompts) && server.prompts.length > 0 + const hasResources = Array.isArray(server.resources) && server.resources.length > 0 + const hasResourceTemplates = Array.isArray(server.resourceTemplates) && server.resourceTemplates.length > 0 + const hasTools = Array.isArray(server.tools) && server.tools.length > 0 + return hasPrompts || hasResources || hasResourceTemplates || hasTools + }) +} diff --git a/forge/settings/defaults.js b/forge/settings/defaults.js index d0fb2d8e45..2a331cc9b2 100644 --- a/forge/settings/defaults.js +++ b/forge/settings/defaults.js @@ -69,6 +69,8 @@ module.exports = { 'platform:sso:google': false, // Is Google SSO enabled? 'platform:sso:google:clientId': null, // Client ID for Google SSO + 'platform:sso:google:auto-create': false, // Auto-provision users on Google SSO + 'platform:sso:direct': false, // Direct SSO Login // FlowFuse npm registry diff --git a/frontend/src/api/team.js b/frontend/src/api/team.js index 23ada57d0c..362d649a83 100644 --- a/frontend/src/api/team.js +++ b/frontend/src/api/team.js @@ -78,13 +78,15 @@ const deleteTeam = async (teamId) => { * @param includeApplicationSummary * @param includeInstances * @param includeApplicationDevices + * @param excludeOwnerFiltering * @returns An array of application objects containing an array of instances */ const getTeamApplications = async (teamId, { associationsLimit, includeApplicationSummary = false, includeInstances = undefined, - includeApplicationDevices = undefined + includeApplicationDevices = undefined, + excludeOwnerFiltering = undefined } = {}) => { const options = { params: {} } if (associationsLimit) { @@ -99,6 +101,10 @@ const getTeamApplications = async (teamId, { if (includeApplicationDevices !== undefined) { options.params.includeApplicationDevices = includeApplicationDevices } + if (excludeOwnerFiltering !== undefined) { + options.params.excludeOwnerFiltering = excludeOwnerFiltering + } + const result = await client.get(`/api/v1/teams/${teamId}/applications`, options) return result.data } diff --git a/frontend/src/components/DevicesBrowser.vue b/frontend/src/components/DevicesBrowser.vue index 0e237afbe4..a1ed2e354e 100644 --- a/frontend/src/components/DevicesBrowser.vue +++ b/frontend/src/components/DevicesBrowser.vue @@ -90,40 +90,40 @@