diff --git a/.env.example b/.env.example index 34628858..bacb6adb 100644 --- a/.env.example +++ b/.env.example @@ -157,3 +157,8 @@ GITSOURCE_MAX_CLONE_BYTES=104857600 # only if your ARC stats live at a non-standard path inside the container. If no # ARC stats are readable, host memory reporting is unchanged. # SENCHO_ZFS_ARCSTATS_PATH= + +# Path inside the container to /proc/meminfo, for VM memory ballooning awareness. +# Sencho checks this path first, then /host/proc/meminfo, then /proc/meminfo. +# Set it only when your meminfo lives at a non-standard path inside the container. +# SENCHO_PROC_MEMINFO_PATH= diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 606edfc4..13e06eb5 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -72,7 +72,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io # repository_owner is a fixed value (studio-saelix) on every event diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 46f23b3f..4b1ff42f 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -89,7 +89,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ced58280..b5c15c43 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -61,13 +61,13 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io # repository_owner resolves to a fixed value (studio-saelix) on every diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..b863bacf --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,78 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '30 19 * * 1' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 915a0a7c..a749558b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,7 +23,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: days-before-stale: 60 days-before-close: 14 diff --git a/backend/package-lock.json b/backend/package-lock.json index 7cc65f43..30e87b25 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -74,15 +74,15 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.19.tgz", - "integrity": "sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==", + "version": "3.1000.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.22.tgz", + "integrity": "sha512-YsSac72lcCOSjk5X4fMc20SjltkGUDjckB2vYZcEd/RpgB+huzeQwVZrOWxUvLVo+5D7X9sURgmAAPmErgCQ7w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -91,18 +91,18 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1092.0.tgz", - "integrity": "sha512-kovFwhQP08Hn4Aa5zY0EoNyrPYkU+chSIaDBWGV5i7Siq/Oq6C96LVJU2Aq1hK3+RsWQxnFC9GXPPEuDea2KRA==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1097.0.tgz", + "integrity": "sha512-3dxiPZ6Dt4hSDB9CQq8NVxEERPZs+s4t7oNIGA7Uyi8hBuQJetHoVLHglaOuIJ3+/2/GXG3wdHupgzpJAmR9jw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-node": "^3.972.71", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-node": "^3.972.74", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -111,21 +111,21 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1092.0.tgz", - "integrity": "sha512-NfcptdANQM1IgUT8QITKBN+PZPjshm5FyLKKjotEwscsDQGik4iDdLgwFYJSTlGoREv26Tf97WHpL7IZ3HF9nA==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1097.0.tgz", + "integrity": "sha512-iCBD95hrynpxiOzD301pUW9H3mxKcEfMErLqdg58WcIZnEqJuOd7JwcARsV3/y6OWj1t6j2tpS9lGt6X4OnPFw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/checksums": "^3.1000.19", - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-node": "^3.972.71", - "@aws-sdk/middleware-sdk-s3": "^3.972.65", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/checksums": "^3.1000.22", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-node": "^3.972.74", + "@aws-sdk/middleware-sdk-s3": "^3.972.68", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -134,17 +134,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.976.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", - "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", + "version": "3.977.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.2.tgz", + "integrity": "sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.36", + "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.4", - "@smithy/signature-v4": "^5.6.5", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -154,15 +154,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz", - "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.63.tgz", + "integrity": "sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -171,17 +171,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz", - "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==", + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.65.tgz", + "integrity": "sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -190,23 +190,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz", - "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.8.tgz", + "integrity": "sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-login": "^3.972.67", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-login": "^3.972.70", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -215,16 +215,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz", - "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.70.tgz", + "integrity": "sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -233,21 +233,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz", - "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.74.tgz", + "integrity": "sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-ini": "^3.973.5", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-ini": "^3.973.8", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -256,15 +256,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz", - "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.63.tgz", + "integrity": "sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -273,17 +273,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz", - "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==", + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.7.tgz", + "integrity": "sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/token-providers": "3.1092.0", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/token-providers": "3.1097.0", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -292,16 +292,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz", - "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.69.tgz", + "integrity": "sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -310,16 +310,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz", - "integrity": "sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.68.tgz", + "integrity": "sha512-JA/LRxCSXQAsFHyIZzgncYdcTQTOL3ZS8R7EAeDwoSoWcNG8yxDAacrwFTZIyVSMvkogvlKHRvmrMU68UyQbkw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -328,18 +328,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz", - "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==", + "version": "3.997.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.37.tgz", + "integrity": "sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -348,14 +348,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", - "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.5", + "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -364,16 +364,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz", - "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1097.0.tgz", + "integrity": "sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -396,9 +396,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", - "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -530,9 +530,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1190,9 +1190,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.29.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz", - "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1204,13 +1204,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz", - "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.7", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1219,13 +1219,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.6.tgz", - "integrity": "sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw==", + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1234,13 +1234,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.6.tgz", - "integrity": "sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg==", + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1249,13 +1249,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz", - "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==", + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.7", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1552,9 +1552,9 @@ } }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -2409,10 +2409,9 @@ } }, "node_modules/better-sqlite3": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz", - "integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -2490,9 +2489,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3310,9 +3309,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -3322,7 +3321,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -3346,7 +3345,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3607,9 +3606,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -3659,9 +3658,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -4187,9 +4186,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4315,9 +4314,9 @@ "license": "ISC" }, "node_modules/isomorphic-git": { - "version": "1.38.10", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.10.tgz", - "integrity": "sha512-nJSSq7ypu97vM33rSdxXyPzSWksCberjKspzXhFEcBHdVyxdNkWByAzJ/AURV1jIlfv8pLq37lGdIEt7ORj17Q==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.40.0.tgz", + "integrity": "sha512-/CbnxwZqIm17y3c/z0INbkgEKSvFerXtO/NGgaRxZ8nvL3eoMtbjuAS7f4Pj7lZzj8HaultvDD1ClJTBVDl89g==", "license": "MIT", "dependencies": { "async-lock": "^1.4.1", @@ -4966,13 +4965,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6139,9 +6138,9 @@ } }, "node_modules/systeminformation": { - "version": "5.33.0", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.0.tgz", - "integrity": "sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA==", + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", + "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", "license": "MIT", "os": [ "darwin", @@ -6477,9 +6476,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index 693da057..e23e7804 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -4,18 +4,35 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; +import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; let authCookie: string; let viewerCookie: string; +type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor'; + +/** Seed a user with the given role and return a signed bearer token for it. */ +async function seedRoleToken(username: string, role: SeedRole): Promise { + const db = DatabaseService.getInstance(); + let user = db.getUserByUsername(username); + if (!user) { + const hash = await bcrypt.hash('password123', 1); + db.addUser({ username, password_hash: hash, role }); + user = db.getUserByUsername(username); + } + return jwt.sign({ username, role, tv: user!.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); +} + beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); + ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); // Mock LicenseService so paid-gated routes are accessible const { LicenseService } = await import('../services/LicenseService'); @@ -67,6 +84,23 @@ describe('GET /api/alerts', () => { expect(res.body.length).toBe(1); expect(res.body[0].stack_name).toBe('web'); }); + + it('denies a role without stack:read with 403 PERMISSION_DENIED', async () => { + // Every shipped role carries stack:read, so the denial path is exercised + // by temporarily removing it from viewer at runtime, proving the added + // gate actually runs rather than being a no-op. + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get('/api/alerts') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } + }); }); // --- POST /api/alerts --- @@ -79,13 +113,30 @@ describe('POST /api/alerts', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { - const res = await request(app) - .post('/api/alerts') - .set('Cookie', viewerCookie) - .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); - expect(res.status).toBe(403); - }); + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)', + async (role) => { + const token = await seedRoleToken(`alerts-post-${role}`, role); + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${token}`) + .send({ stack_name: 'perm-gate-post', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s pass the permission gate', + async (role) => { + const token = await seedRoleToken(`alerts-post-${role}`, role); + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${token}`) + .send({ stack_name: `perm-gate-post-${role}`, metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); + expect(res.status).toBe(201); + }, + ); it('creates alert and returns 201 with created resource', async () => { const payload = { @@ -282,11 +333,107 @@ describe('DELETE /api/alerts/:id', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)', + async (role) => { + const alert = DatabaseService.getInstance().addStackAlert({ + stack_name: `delete-gate-deny-${role}`, + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const token = await seedRoleToken(`alerts-delete-${role}`, role); + const res = await request(app) + .delete(`/api/alerts/${alert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s delete', + async (role) => { + const alert = DatabaseService.getInstance().addStackAlert({ + stack_name: `delete-gate-allow-${role}`, + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const token = await seedRoleToken(`alerts-delete-${role}`, role); + const res = await request(app) + .delete(`/api/alerts/${alert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }, + ); + + it('returns 404 for a nonexistent alert id', async () => { const res = await request(app) - .delete('/api/alerts/1') - .set('Cookie', viewerCookie); - expect(res.status).toBe(403); + .delete('/api/alerts/99999') + .set('Cookie', authCookie); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Alert not found' }); + }); + + it("authorizes against the alert's own stack, not a caller's scoped grant on a different stack", async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'alerts-scoped-editor', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ + user_id: userId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'scoped-allowed-stack', + node_id: defaultNodeId, + }); + const user = db.getUserByUsername('alerts-scoped-editor')!; + const token = jwt.sign({ username: user.username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); + + try { + // The scoped grant only covers 'scoped-allowed-stack', so an alert + // belonging to a different stack must still be denied. + const deniedAlert = db.addStackAlert({ + stack_name: 'scoped-other-stack', + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const deniedRes = await request(app) + .delete(`/api/alerts/${deniedAlert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(deniedRes.status).toBe(403); + expect(deniedRes.body.code).toBe('PERMISSION_DENIED'); + + const allowedAlert = db.addStackAlert({ + stack_name: 'scoped-allowed-stack', + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const allowedRes = await request(app) + .delete(`/api/alerts/${allowedAlert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(allowedRes.status).toBe(200); + expect(allowedRes.body.success).toBe(true); + } finally { + db.deleteRoleAssignmentsByUser(userId); + db.deleteUser(userId); + } }); it('deletes existing alert rule', async () => { diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index c983c578..de67e840 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -33,7 +33,7 @@ beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); - // Mock LicenseService to return the paid tier for audit log access + // Default suite tier is paid so stats/export/anomaly tests pass; Community cases override. const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); @@ -419,10 +419,14 @@ describe('DatabaseService audit methods', () => { // ---- API endpoint tests ---- +async function mockCommunityTier(): Promise { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); +} + describe('GET /api/audit-log', () => { it('returns 200 for a Community admin (recent-activity window, no tier gate)', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + await mockCommunityTier(); const res = await request(app) .get('/api/audit-log') @@ -431,17 +435,32 @@ describe('GET /api/audit-log', () => { expect(Array.isArray(res.body.entries)).toBe(true); }); - it('returns 403 for viewer role (no system:audit permission)', async () => { + it('returns 200 for a Community auditor (system:audit without paid tier)', async () => { + await mockCommunityTier(); + const db = DatabaseService.getInstance(); - db.addUser({ username: 'vieweraudit', password_hash: 'hash', role: 'viewer' }); - const viewerToken = authToken('vieweraudit', 'viewer'); + db.addUser({ username: 'communityauditor', password_hash: 'hash', role: 'auditor' }); const res = await request(app) .get('/api/audit-log') - .set('Authorization', `Bearer ${viewerToken}`); - expect(res.status).toBe(403); + .set('Authorization', `Bearer ${authToken('communityauditor', 'auditor')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.entries)).toBe(true); }); + it.each(['viewer', 'deployer', 'node-admin'] as const)( + 'returns 403 for %s role (no system:audit permission)', + async (role) => { + const username = `${role.replace(/-/g, '')}audit`; + DatabaseService.getInstance().addUser({ username, password_hash: 'hash', role }); + + const res = await request(app) + .get('/api/audit-log') + .set('Authorization', `Bearer ${authToken(username, role)}`); + expect(res.status).toBe(403); + }, + ); + it('returns paginated results for admin with correct structure', async () => { const res = await request(app) .get('/api/audit-log?page=1&limit=10') @@ -575,6 +594,15 @@ describe('GET /api/audit-log', () => { describe('GET /api/audit-log (Community recent-activity window)', () => { const windowUser = 'communitywindowuser'; + async function communityWindowSearch(queryExtra = ''): Promise { + await mockCommunityTier(); + const res = await request(app) + .get(`/api/audit-log?search=${windowUser}&limit=100${queryExtra}`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + return res.body.entries.map((e: { summary: string }) => e.summary); + } + it('clamps Community results to the last 14 days', async () => { const db = DatabaseService.getInstance(); const now = Date.now(); @@ -589,47 +617,26 @@ describe('GET /api/audit-log (Community recent-activity window)', () => { status_code: 200, node_id: null, ip_address: '127.0.0.1', summary: 'recent windowed entry', }); - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); - const res = await request(app) - .get(`/api/audit-log?search=${windowUser}&limit=100`) - .set('Authorization', `Bearer ${adminToken()}`); - - expect(res.status).toBe(200); - const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + const summaries = await communityWindowSearch(); expect(summaries).toContain('recent windowed entry'); expect(summaries).not.toContain('old windowed entry'); }); it('clamps even when a Community caller passes an explicit from older than the window', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); const explicitOldFrom = Date.now() - 30 * 24 * 60 * 60 * 1000; - const res = await request(app) - .get(`/api/audit-log?search=${windowUser}&from=${explicitOldFrom}&limit=100`) - .set('Authorization', `Bearer ${adminToken()}`); - - expect(res.status).toBe(200); - const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + const summaries = await communityWindowSearch(`&from=${explicitOldFrom}`); expect(summaries).toContain('recent windowed entry'); expect(summaries).not.toContain('old windowed entry'); }); it('does not let a non-numeric from lift the Community window clamp', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); - const res = await request(app) - .get(`/api/audit-log?search=${windowUser}&from=abc&limit=100`) - .set('Authorization', `Bearer ${adminToken()}`); - - expect(res.status).toBe(200); - const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + const summaries = await communityWindowSearch('&from=abc'); expect(summaries).toContain('recent windowed entry'); expect(summaries).not.toContain('old windowed entry'); }); it('paid tier still sees entries older than the Community window', async () => { - // The suite default mock is the paid tier (no clamp). + // Suite default mock is paid (no clamp). const res = await request(app) .get(`/api/audit-log?search=${windowUser}&limit=100`) .set('Authorization', `Bearer ${adminToken()}`); @@ -640,8 +647,7 @@ describe('GET /api/audit-log (Community recent-activity window)', () => { }); it('does not annotate anomalies for Community even when with_anomalies=1', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + await mockCommunityTier(); const res = await request(app) .get('/api/audit-log?with_anomalies=1&limit=5') .set('Authorization', `Bearer ${adminToken()}`); @@ -654,9 +660,20 @@ describe('GET /api/audit-log (Community recent-activity window)', () => { }); describe('GET /api/audit-log/stats', () => { + it('returns 200 for an Admiral auditor (system:audit + paid)', async () => { + DatabaseService.getInstance().addUser({ + username: 'admiralauditorstats', password_hash: 'hash', role: 'auditor', + }); + + const res = await request(app) + .get('/api/audit-log/stats') + .set('Authorization', `Bearer ${authToken('admiralauditorstats', 'auditor')}`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('events_24h'); + }); + it('returns 403 without a paid license', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + await mockCommunityTier(); const res = await request(app) .get('/api/audit-log/stats') @@ -665,6 +682,19 @@ describe('GET /api/audit-log/stats', () => { expect(res.body.code).toBe('PAID_REQUIRED'); }); + it('returns 403 for a Community auditor (permission without paid)', async () => { + await mockCommunityTier(); + DatabaseService.getInstance().addUser({ + username: 'communityauditorstats', password_hash: 'hash', role: 'auditor', + }); + + const res = await request(app) + .get('/api/audit-log/stats') + .set('Authorization', `Bearer ${authToken('communityauditorstats', 'auditor')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + it('returns the four-tile stat structure for admin', async () => { const res = await request(app) .get('/api/audit-log/stats') @@ -682,9 +712,20 @@ describe('GET /api/audit-log/stats', () => { }); describe('GET /api/audit-log/export', () => { + it('returns 200 for an Admiral auditor (system:audit + paid)', async () => { + DatabaseService.getInstance().addUser({ + username: 'admiralauditorexport', password_hash: 'hash', role: 'auditor', + }); + + const res = await request(app) + .get('/api/audit-log/export?format=json') + .set('Authorization', `Bearer ${authToken('admiralauditorexport', 'auditor')}`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/json/); + }); + it('returns 403 without a paid license', async () => { - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + await mockCommunityTier(); const res = await request(app) .get('/api/audit-log/export?format=json') @@ -693,6 +734,19 @@ describe('GET /api/audit-log/export', () => { expect(res.body.code).toBe('PAID_REQUIRED'); }); + it('returns 403 for a Community auditor (permission without paid)', async () => { + await mockCommunityTier(); + DatabaseService.getInstance().addUser({ + username: 'communityauditorexport', password_hash: 'hash', role: 'auditor', + }); + + const res = await request(app) + .get('/api/audit-log/export?format=json') + .set('Authorization', `Bearer ${authToken('communityauditorexport', 'auditor')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + it('exports JSON with correct Content-Type', async () => { const res = await request(app) .get('/api/audit-log/export?format=json') diff --git a/backend/src/__tests__/auto-heal-routes.test.ts b/backend/src/__tests__/auto-heal-routes.test.ts index 962290a0..74158157 100644 --- a/backend/src/__tests__/auto-heal-routes.test.ts +++ b/backend/src/__tests__/auto-heal-routes.test.ts @@ -11,6 +11,9 @@ let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; + +type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor'; function userToken(username: string): string { const user = DatabaseService.getInstance().getUserByUsername(username); @@ -18,6 +21,14 @@ function userToken(username: string): string { return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); } +/** Seed a user with the given role if it doesn't already exist. */ +async function seedRoleUser(username: string, role: SeedRole): Promise { + const db = DatabaseService.getInstance(); + if (db.getUserByUsername(username)) return; + const hash = await bcrypt.hash('password123', 1); + db.addUser({ username, password_hash: hash, role }); +} + function createApiToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string { const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); @@ -57,6 +68,7 @@ beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); ({ LicenseService } = await import('../services/LicenseService')); + ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); const viewerHash = await bcrypt.hash('password123', 1); @@ -132,17 +144,52 @@ describe('/api/auto-heal routes', () => { expect(res.body.proxy_entitled_until).toBe(0); }); - it('rejects non-admin policy mutation', async () => { - const res = await request(app) - .post('/api/auto-heal/policies') - .set('Authorization', `Bearer ${userToken('route-viewer')}`) - .send({ - stack_name: 'route-stack', - unhealthy_duration_mins: 5, - }); + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s policy mutation with 403 PERMISSION_DENIED', + async (role) => { + await seedRoleUser(`auto-heal-post-${role}`, role); + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken(`auto-heal-post-${role}`)}`) + .send({ + stack_name: 'route-stack', + unhealthy_duration_mins: 5, + }); - expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s pass the policy mutation permission gate', + async (role) => { + await seedRoleUser(`auto-heal-post-${role}`, role); + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken(`auto-heal-post-${role}`)}`) + .send({ + stack_name: `route-stack-${role}`, + unhealthy_duration_mins: 5, + }); + + expect(res.status).toBe(201); + }, + ); + + it('denies policy listing with 403 PERMISSION_DENIED when the caller lacks stack:read', async () => { + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } }); it('lists only policies for the active node', async () => { @@ -178,6 +225,45 @@ describe('/api/auto-heal routes', () => { expect(res.status).toBe(404); }); + it('denies history access with 403 PERMISSION_DENIED when the caller lacks stack:read', async () => { + // Every shipped role carries stack:read, so the denial path is exercised + // by temporarily removing it from viewer at runtime. This proves the + // permission check that was previously entirely absent from this route + // actually runs. + const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; + const policy = makePolicy(defaultNodeId, 'history-perm-gate-stack'); + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get(`/api/auto-heal/policies/${policy.id}/history`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } + }); + + it('returns 404 for a wrong-node policy id before the permission check runs', async () => { + // A viewer lacks stack:edit, so if the permission check ran before the + // node-ownership check, this would 403 PERMISSION_DENIED instead of 404. + const secondNodeId = insertLegacyLocal('ordering-second-local'); + const policy = makePolicy(secondNodeId, 'ordering-stack'); + + const patchRes = await request(app) + .patch(`/api/auto-heal/policies/${policy.id}`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`) + .send({ enabled: 0 }); + expect(patchRes.status).toBe(404); + + const deleteRes = await request(app) + .delete(`/api/auto-heal/policies/${policy.id}`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + expect(deleteRes.status).toBe(404); + }); + it('persists enabled toggles through the patch route', async () => { const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; const policy = makePolicy(defaultNodeId, 'toggle-stack'); diff --git a/backend/src/__tests__/blueprints-authz.test.ts b/backend/src/__tests__/blueprints-authz.test.ts index 397dad65..1ee87829 100644 --- a/backend/src/__tests__/blueprints-authz.test.ts +++ b/backend/src/__tests__/blueprints-authz.test.ts @@ -132,7 +132,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { expect(res.body.pinned_node_id).toBe(node.id); }); - it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => { + it('rejects a user without node:manage on a paid license', async () => { const node = seedNode(); const bp = seedBlueprint([node.id]); @@ -142,26 +142,26 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { .send({ nodeId: node.id }); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); }); -describe('Blueprint mutation routes require admin role', () => { +describe('Blueprint mutation routes require their operational permissions', () => { // The gate short-circuits before id parsing, so dummy ids are sufficient // to prove the role boundary. - const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [ - { name: 'create', method: 'post', path: '/api/blueprints' }, - { name: 'update', method: 'put', path: '/api/blueprints/1' }, - { name: 'delete', method: 'delete', path: '/api/blueprints/1' }, - { name: 'apply', method: 'post', path: '/api/blueprints/1/apply' }, - { name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1' }, - { name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1' }, + const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string; status: number }> = [ + { name: 'create', method: 'post', path: '/api/blueprints', status: 403 }, + { name: 'update', method: 'put', path: '/api/blueprints/1', status: 403 }, + { name: 'delete', method: 'delete', path: '/api/blueprints/1', status: 403 }, + { name: 'apply', method: 'post', path: '/api/blueprints/1/apply', status: 403 }, + { name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1', status: 404 }, + { name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1', status: 400 }, ]; - it.each(mutations)('rejects a non-admin on $name with ADMIN_REQUIRED', async ({ method, path }) => { + it.each(mutations)('does not let a viewer perform $name', async ({ method, path, status }) => { const res = await request(app)[method](path).set('Cookie', viewerCookie).send({}); - expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.status).toBe(status); + if (status === 403) expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => { diff --git a/backend/src/__tests__/blueprints-community-tier.test.ts b/backend/src/__tests__/blueprints-community-tier.test.ts index b28246e6..608afb2b 100644 --- a/backend/src/__tests__/blueprints-community-tier.test.ts +++ b/backend/src/__tests__/blueprints-community-tier.test.ts @@ -126,13 +126,13 @@ describe('Blueprints on Community tier', () => { expect(res.body.code).not.toBe('PAID_REQUIRED'); }); - it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => { + it('rejects blueprint mutations for a viewer without stack:create', async () => { const res = await request(app) .post('/api/blueprints') .set('Authorization', viewerAuthHeader) .send({}); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('lets a Community viewer list blueprints', async () => { diff --git a/backend/src/__tests__/blueprints-remote-deploy.test.ts b/backend/src/__tests__/blueprints-remote-deploy.test.ts index 9bf91e72..534a536e 100644 --- a/backend/src/__tests__/blueprints-remote-deploy.test.ts +++ b/backend/src/__tests__/blueprints-remote-deploy.test.ts @@ -263,7 +263,7 @@ describe('BlueprintService remote deploy', () => { expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed'); }); - it('does not clear hub role assignments when withdrawing a remote deployment', async () => { + it('clears hub role assignments for the withdrawn remote stack tuple', async () => { const bcrypt = await import('bcrypt'); const db = DatabaseService.getInstance(); const node = seedRemoteNode(); @@ -282,20 +282,20 @@ describe('BlueprintService remote deploy', () => { username: `remote-wd-rbac-${counter}`, password_hash: hash, role: 'viewer', }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, node_id: node.id, }); vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } }); const delSpy = vi.spyOn(axios, 'delete'); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj); expect(result.status).toBe('withdrawn'); expect(delSpy).not.toHaveBeenCalled(); - expect(rbacSpy).not.toHaveBeenCalled(); + expect(rbacSpy).toHaveBeenCalledWith(node.id, bpObj.name); expect(db.getAllRoleAssignments(userId) - .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true); + .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name && a.node_id === node.id)).toBe(false); db.deleteUser(userId); }); diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts index 261a0e22..dffbafc8 100644 --- a/backend/src/__tests__/blueprints.test.ts +++ b/backend/src/__tests__/blueprints.test.ts @@ -493,10 +493,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const userId = db.addUser({ username: `bp-rbac-${counter}`, password_hash: hash, role: 'viewer' }); const otherNodeId = seedNode(); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, node_id: nodeId, }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: nodeId, }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId), @@ -552,33 +552,42 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const { bp, node, nodeId, userId, deleteStackSpy, db } = await arrangeLocalWithdraw(); const fsErr = Object.assign(new Error('permission denied'), { code: 'EACCES' }); deleteStackSpy.mockRejectedValue(fsErr); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(rbacSpy).not.toHaveBeenCalled(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(rbacSpy).not.toHaveBeenCalled(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + db.deleteUser(userId); + } }); it('fails withdraw and keeps the deployment when role-assignment cleanup throws', async () => { const { bp, node, nodeId, userId, db } = await arrangeLocalWithdraw(); - vi.spyOn(db, 'deleteRoleAssignmentsByResource') + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack') .mockImplementation(() => { throw new Error('simulated rbac cleanup failure'); }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(errorSpy.mock.calls.some((args) => - typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), - )).toBe(true); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(errorSpy.mock.calls.some((args) => + typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), + )).toBe(true); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + errorSpy.mockRestore(); + db.deleteUser(userId); + } }); }); diff --git a/backend/src/__tests__/compose-network-inspector.test.ts b/backend/src/__tests__/compose-network-inspector.test.ts index 702a9328..2ca505b8 100644 --- a/backend/src/__tests__/compose-network-inspector.test.ts +++ b/backend/src/__tests__/compose-network-inspector.test.ts @@ -4,14 +4,21 @@ * runtime-vs-Compose drift comparison (system/default/external networks and * stopped containers are not flagged). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; import type { DeclaredCompose } from '../helpers/composeDependencyParse'; import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController'; import { fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts, + type ManagedNetworkAttachmentPredicate, } from '../services/network/normalize'; -import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { assembleStackNetworkFacts, buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { buildNodeNetworkingFindings } from '../services/network/networkingFindings'; +import type { NetworkingNetworkBase } from '../services/network/networkingTypes'; +import DockerController from '../services/DockerController'; +import { ComposeService } from '../services/ComposeService'; +import { DatabaseService } from '../services/DatabaseService'; +import { FileSystemService } from '../services/FileSystemService'; function effSvc(over: Partial = {}): EffService { const hasHealthcheck = over.hasHealthcheck ?? true; @@ -264,6 +271,187 @@ describe('compareStackNetworks', () => { expect(drift.foreignNetworkAttachments).toEqual([]); }); + it('removes managed Mesh drift before Networking findings while preserving advanced-driver info', () => { + const meshOnlyModel: EffectiveModel = { + projectName: 'myapp', + services: [], + networks: {}, + volumes: {}, + }; + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ id: 'm', name: 'sencho_mesh', driver: 'macvlan', composeProject: null, stack: null })], + ); + const facts = assembleStackNetworkFacts( + 'myapp', + meshOnlyModel, + null, + snap, + (_runtimeContainer, networkName) => networkName === 'sencho_mesh', + ); + const baseNetworks: NetworkingNetworkBase[] = [{ + id: 'm', + name: 'sencho_mesh', + driver: 'macvlan', + scope: 'local', + isSystem: false, + ingress: false, + composeProject: null, + stack: null, + connectedCount: 1, + isSencho: true, + ownership: 'sencho-managed', + declaredByStacks: [], + declaredExternalByStacks: [], + isExternalDependency: false, + }]; + + const findings = buildNodeNetworkingFindings(1, snap, [facts], baseNetworks); + + expect(facts.drift.runtimeOnlyAttachments).toEqual([]); + expect(facts.drift.foreignNetworkAttachments).toEqual([]); + expect(findings.some(f => f.kind === 'network-undeclared' || f.kind === 'foreign-network-attachment')).toBe(false); + expect(findings).toContainEqual(expect.objectContaining({ + kind: 'advanced-driver-caveat', + severity: 'info', + network: 'sencho_mesh', + })); + }); + + it('keeps Networking facts available and Mesh drift actionable when opt-in authority fails', async () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ + rendered: JSON.stringify({ name: 'myapp', services: { web: { image: 'nginx:1.27' } } }), + stderr: '', + code: 0, + timedOut: false, + }), + } as unknown as ComposeService); + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStacks: vi.fn().mockResolvedValue(['myapp']), + } as unknown as FileSystemService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue(snap), + } as unknown as DockerController); + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const facts = await buildStackNetworkFacts(1, 'myapp'); + + expect(facts.runtime).toBe('available'); + expect(facts.drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_mesh' }]); + } finally { + vi.restoreAllMocks(); + } + }); + + it('threads opted-in authority through Networking facts and preserves advanced-driver info', async () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ id: 'm', name: 'sencho_mesh', driver: 'macvlan', composeProject: null, stack: null })], + ); + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ + rendered: JSON.stringify({ name: 'myapp', services: { web: { image: 'nginx:1.27' } } }), + stderr: '', + code: 0, + timedOut: false, + }), + } as unknown as ComposeService); + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStacks: vi.fn().mockResolvedValue(['myapp']), + } as unknown as FileSystemService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue(snap), + } as unknown as DockerController); + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(true), + getStackExposureIntents: vi.fn().mockReturnValue([]), + getStackDossier: vi.fn().mockReturnValue(null), + } as unknown as DatabaseService); + + try { + const facts = await buildStackNetworkFacts(1, 'myapp'); + const baseNetworks: NetworkingNetworkBase[] = [{ + id: 'm', name: 'sencho_mesh', driver: 'macvlan', scope: 'local', isSystem: false, + ingress: false, composeProject: null, stack: null, connectedCount: 1, isSencho: true, + ownership: 'sencho-managed', declaredByStacks: [], declaredExternalByStacks: [], + isExternalDependency: false, + }]; + const findings = buildNodeNetworkingFindings(1, snap, [facts], baseNetworks); + + expect(facts.drift.runtimeOnlyAttachments).toEqual([]); + expect(facts.drift.foreignNetworkAttachments).toEqual([]); + expect(findings.filter(f => f.kind === 'network-undeclared' || f.kind === 'foreign-network-attachment')).toEqual([]); + expect(findings).toContainEqual(expect.objectContaining({ + kind: 'advanced-driver-caveat', + severity: 'info', + network: 'sencho_mesh', + })); + } finally { + vi.restoreAllMocks(); + } + }); + + it('ignores a verified Sencho Mesh attachment for the Sencho container', () => { + const snap = snapshot( + [container({ id: 'sencho-id', name: 'sencho', service: 'sencho', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + const managed: ManagedNetworkAttachmentPredicate = (runtimeContainer, networkName) => + runtimeContainer.id === 'sencho-id' && networkName === 'sencho_mesh'; + + const drift = compareStackNetworks(declared, snap, 'myapp', managed); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('ignores a verified Mesh attachment for an opted-in application stack', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + const managed: ManagedNetworkAttachmentPredicate = (_runtimeContainer, networkName) => + networkName === 'sencho_mesh'; + + const drift = compareStackNetworks(declared, snap, 'myapp', managed); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('keeps an unverified manual Mesh attachment actionable', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + + const drift = compareStackNetworks(declared, snap, 'myapp', () => false); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_mesh' }]); + }); + + it('keeps sencho_extra actionable even when the stack is Mesh-managed', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_extra', id: 'e', ip: '' }] })], + [depNet({ name: 'sencho_extra', composeProject: null, stack: null })], + ); + + const drift = compareStackNetworks(declared, snap, 'myapp', () => true); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_extra' }]); + }); + it('does not flag attachments from stopped containers', () => { const snap = snapshot( [container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })], diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 61d271cc..c71880db 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -181,6 +181,10 @@ vi.mock('../services/MeshService', () => ({ }, })); +vi.mock('../services/recoveryHeldImages', () => ({ + buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate, +})); + vi.mock('../services/StackUpdateRecoveryService', () => ({ StackUpdateRecoveryService: { getInstance: () => ({ @@ -191,7 +195,6 @@ vi.mock('../services/StackUpdateRecoveryService', () => ({ markImmediateVerified: mockMarkImmediateVerified, abandon: mockAbandon, compensateWithCandidate: mockCompensateWithCandidate, - buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate, get: mockGetRecovery, linkGateOrRetain: vi.fn(), }), diff --git a/backend/src/__tests__/containers-route-authz.test.ts b/backend/src/__tests__/containers-route-authz.test.ts index 2b98ea23..05ccc45f 100644 --- a/backend/src/__tests__/containers-route-authz.test.ts +++ b/backend/src/__tests__/containers-route-authz.test.ts @@ -21,6 +21,7 @@ let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSION const VIEWER = 'container-read-viewer'; const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use']; +const MUTATION_PATHS = ['/api/containers/abc123/start', '/api/containers/abc123/stop', '/api/containers/abc123/restart']; /** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */ function viewerToken(): string { @@ -120,3 +121,13 @@ describe('container/ports reads reject unauthenticated requests', () => { expect(res.status).toBe(401); }); }); + +describe('generic container-id mutations remain Admin-only', () => { + it.each(MUTATION_PATHS)('POST %s rejects a non-admin before Docker work', async (path) => { + const { docker } = stubDockerAndFs(); + const res = await request(app).post(path).set('Authorization', `Bearer ${viewerToken()}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(docker).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/deployed-stack-deletion-service.test.ts b/backend/src/__tests__/deployed-stack-deletion-service.test.ts index d8dfc0b4..c156ed15 100644 --- a/backend/src/__tests__/deployed-stack-deletion-service.test.ts +++ b/backend/src/__tests__/deployed-stack-deletion-service.test.ts @@ -60,6 +60,8 @@ describe('DeployedStackDeletionService ready transaction', () => { updated_at: now, created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; db().insertStackUpdateRecoveryGeneration(gen); const svc: ServiceUpdateRecoveryRow = { diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 67591035..bb9fdd79 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -60,6 +60,8 @@ vi.mock('util', () => ({ import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController'; import { CacheService } from '../services/CacheService'; +import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -529,6 +531,9 @@ describe('DockerController - getClassifiedResources', () => { beforeEach(() => { CacheService.getInstance().invalidate('project-name-map'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it('classifies managed and unmanaged images', async () => { mockDocker.listImages.mockResolvedValue([ @@ -616,6 +621,94 @@ describe('DockerController - getClassifiedResources', () => { expect(result.volumes.find(v => v.Name === 'my-stack_data')!.managedStatus).toBe('managed'); expect(result.volumes.find(v => v.Name === 'random_vol')!.managedStatus).toBe('unmanaged'); }); + + it('excludes an image whose only tag is a synthetic sencho-rb rollback hold', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-hold-only', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 50, Containers: 0 }, + { Id: 'img-normal', RepoTags: ['nginx:latest'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + expect(result.images.find(i => i.Id === 'img-hold-only')).toBeUndefined(); + expect(result.images.find(i => i.Id === 'img-normal')).toBeDefined(); + }); + + it('keeps an image visible when it carries both a normal tag and a sencho-rb hold tag', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-multi-tag', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-multi-tag'); + expect(img).toBeDefined(); + expect(img!.RepoTags).toEqual(['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold']); + }); + + it('marks an image rollbackProtected with kind "stack" when StackUpdateRecoveryService holds it', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-stack-held', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-stack-held'); + expect(img?.rollbackProtected).toBe(true); + expect(img?.rollbackProtectionKind).toBe('stack'); + }); + + it('marks an image rollbackProtected with kind "service" when only ServiceUpdateRecoveryService holds it', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-service-held', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-service-held'])); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-service-held'); + expect(img?.rollbackProtected).toBe(true); + expect(img?.rollbackProtectionKind).toBe('service'); + }); + + it('fails closed (marks every image rollbackProtected) when a held-image lookup fails', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-unrelated', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + // getHeldImageIds returns null when its own DB lookup fails (already logs + // internally); the badge must fail the same direction as the delete guard + // (recoveryHeldImages.ts), not the opposite. + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-unrelated'); + expect(img?.rollbackProtected).toBe(true); + }); }); // ── pruneManagedOnly / estimateManagedReclaim (images) ───────────────── @@ -1102,6 +1195,40 @@ describe('DockerController - inspectImage', () => { }); }); +// --- resolveImageId -------------------------------------------------------------- + +describe('DockerController - resolveImageId', () => { + it('returns the canonical full Id from docker.getImage(id).inspect()', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ Id: 'sha256:' + 'a'.repeat(64) }), + }); + + const dc = DockerController.getInstance(1); + const result = await dc.resolveImageId('a'.repeat(12)); + + expect(result).toBe('sha256:' + 'a'.repeat(64)); + expect(mockDocker.getImage).toHaveBeenCalledWith('a'.repeat(12)); + }); + + it('returns null on a 404 from Docker', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image'), { statusCode: 404 })), + }); + + const dc = DockerController.getInstance(1); + expect(await dc.resolveImageId('missing')).toBeNull(); + }); + + it('rethrows a non-404 Docker error', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(Object.assign(new Error('docker daemon unreachable'), { statusCode: 500 })), + }); + + const dc = DockerController.getInstance(1); + await expect(dc.resolveImageId('sha256:abc')).rejects.toThrow('docker daemon unreachable'); + }); +}); + // --- label / image inspection for the label inventory -------------------------- describe('DockerController - inspectImageLabels', () => { diff --git a/backend/src/__tests__/drift-detection.test.ts b/backend/src/__tests__/drift-detection.test.ts index c5198e68..fa810e2d 100644 --- a/backend/src/__tests__/drift-detection.test.ts +++ b/backend/src/__tests__/drift-detection.test.ts @@ -18,6 +18,7 @@ import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompos import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse'; import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; import { fromDeclaredCompose, fromEffectiveModel } from '../services/network/normalize'; +import { DatabaseService } from '../services/DatabaseService'; // ── builders ──────────────────────────────────────────────────────────── @@ -580,6 +581,32 @@ describe('assembleStackDrift - network drift', () => { expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]); expect(report.status).toBe('in-sync'); }); + + it('reports in-sync when a verified Mesh attachment is the only runtime difference', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + managedNetworkAttachment: (_runtimeContainer, networkName) => networkName === 'sencho_mesh', + }); + + expect(report.status).toBe('in-sync'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toEqual([]); + }); + + it('keeps an unverified manual Mesh attachment drifted', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + managedNetworkAttachment: () => false, + }); + + expect(report.status).toBe('drifted'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toHaveLength(1); + }); }); // ── declaredFromEffectiveModel ───────────────────────────────────────────── @@ -815,4 +842,46 @@ describe('buildStackDriftReport - boundaries', () => { expect(findingKinds(report)).toContain('network-undeclared'); vi.restoreAllMocks(); }); + + it('keeps the report available and Mesh drift actionable when opt-in authority fails', async () => { + const snapshot: DependencySnapshot = { + containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + volumes: [], + }; + stubDockerRender({ name: 'app', services: { web: { image: 'nginx:1.25' } } }); + stubFsAndSnapshot(snapshot); + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const report = await buildStackDriftReport(0, 'app'); + + expect(report.status).toBe('drifted'); + expect(report.findings).toContainEqual(expect.objectContaining({ + kind: 'network-undeclared', + actual: 'sencho_mesh', + })); + vi.restoreAllMocks(); + }); + + it('reports in-sync through the public builder when DB authority opts the stack into Mesh', async () => { + const snapshot: DependencySnapshot = { + containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + volumes: [], + }; + stubDockerRender({ name: 'app', services: { web: { image: 'nginx:1.25' } } }); + stubFsAndSnapshot(snapshot); + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(true), + } as unknown as DatabaseService); + + const report = await buildStackDriftReport(0, 'app'); + + expect(report.status).toBe('in-sync'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toEqual([]); + vi.restoreAllMocks(); + }); }); diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts index e9e4718b..497c1373 100644 --- a/backend/src/__tests__/exec.test.ts +++ b/backend/src/__tests__/exec.test.ts @@ -321,23 +321,68 @@ describe('WebSocket upgrade - exec auth enforcement', () => { expect(code).toBe(401); }); - it('rejects WebSocket upgrade with non-admin token (403)', async () => { - // Add a non-admin user + // All five built-in roles: container exec requires admin. Every non-admin + // role must be rejected at upgrade time by generic.ts's role === 'admin' gate. + const NON_ADMIN_EXEC_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const; + + for (const role of NON_ADMIN_EXEC_ROLES) { + it(`rejects /ws upgrade with ${role} token (403)`, async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const bcrypt = await import('bcrypt'); + const username = `exec_${role.replace('-', '_')}`; + const hash = bcrypt.hashSync('password123', 1); + try { + DatabaseService.getInstance().addUser({ username, password_hash: hash, role }); + } catch { + // User may already exist from a prior run in the same worker + } + + const token = jwt.sign( + { username, role }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + } + + it('rejects legacy no-tv admin JWT after token_version bump (401)', async () => { + // A legacy token without a tv claim is treated as version 1; once the + // account version is bumped it must be rejected on /ws like on HTTP. const { DatabaseService } = await import('../services/DatabaseService'); const bcrypt = await import('bcrypt'); - const hash = await bcrypt.hash('viewerpass', 1); - try { - DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: hash, role: 'viewer' }); - } catch { - // User may already exist - } + const db = DatabaseService.getInstance(); + const username = `legacy-tv-admin-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); - const token = jwt.sign( - { username: 'viewer', role: 'viewer' }, + const legacyNoTv = jwt.sign( + { username, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }, ); - const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + // A regression (upgrade accepted) must fail fast with 200, not hang. + ws.on('open', () => { ws.close(); resolve(200); }); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(401); + }); + + it('rejects WebSocket upgrade with node_proxy token (403)', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); const code = await new Promise((resolve) => { ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); ws.on('error', () => resolve(0)); @@ -345,8 +390,81 @@ describe('WebSocket upgrade - exec auth enforcement', () => { expect(code).toBe(403); }); - it('rejects WebSocket upgrade with node_proxy token (403)', async () => { - const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + it('rejects WebSocket upgrade with mfa_pending token (403)', async () => { + // Partial-auth must not open /ws (upgrade early-reject + generic deny-by-default). + // Pre-fix: any set scope skipped the admin check and unlocked execContainer. + const token = jwt.sign( + { scope: 'mfa_pending', user_id: 1, username: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('rejects WebSocket upgrade with pilot_enroll token (403)', async () => { + const token = jwt.sign( + { scope: 'pilot_enroll', nodeId: 1, enrollNonce: 'test-nonce' }, + TEST_JWT_SECRET, + { expiresIn: '15m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('rejects WebSocket upgrade with an unknown scoped JWT (403)', async () => { + const token = jwt.sign({ scope: 'future_machine_scope' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('accepts WebSocket upgrade with pilot_tunnel token (pilot loopback)', async () => { + // Agent loopback injects pilot_tunnel on every forwarded WS, including /ws. + const token = jwt.sign({ scope: 'pilot_tunnel', nodeId: 1 }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const connected = await new Promise((resolve) => { + ws.on('open', () => { + ws.close(); + resolve(true); + }); + ws.on('error', () => resolve(false)); + ws.on('unexpected-response', () => resolve(false)); + }); + expect(connected).toBe(true); + }); + + it('accepts WebSocket upgrade with container-exec console_session (remote exec)', async () => { + const { mintConsoleSession } = await import('../helpers/consoleSession'); + const token = mintConsoleSession({ path: 'container-exec' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const connected = await new Promise((resolve) => { + ws.on('open', () => { + ws.close(); + resolve(true); + }); + ws.on('error', () => resolve(false)); + ws.on('unexpected-response', () => resolve(false)); + }); + expect(connected).toBe(true); + }); + + it('rejects host-console console_session on /ws (path gate before allowlist)', async () => { + // Path mismatch is enforced in upgradeHandler (consoleSessionPathForPathname) + // before the generic allowlist; allowlist must not be the sole path gate. + const { mintConsoleSession } = await import('../helpers/consoleSession'); + const token = mintConsoleSession({ path: 'host-console' }); const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); const code = await new Promise((resolve) => { ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); diff --git a/backend/src/__tests__/fixtures/personas.ts b/backend/src/__tests__/fixtures/personas.ts new file mode 100644 index 00000000..2388910b --- /dev/null +++ b/backend/src/__tests__/fixtures/personas.ts @@ -0,0 +1,53 @@ +/** + * Reusable five-role persona fixtures for RBAC test suites. + * + * Usage (one-time setup per test file): + * const personas = seedPersonas(DatabaseService.getInstance()); + * const viewerReq = request(app).get('/api/stacks').set('Authorization', personas.viewer.bearer); + * + * Each persona carries its own smoke test so a token_version mismatch + * (seeding vs signing) surfaces as a 401 before any permission assertion. + */ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { TEST_JWT_SECRET } from '../helpers/setupTestDb'; +import type { UserRole } from '../../services/DatabaseService'; + +export const FIVE_ROLES: readonly UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const; + +export interface Persona { + username: string; + role: UserRole; + tokenVersion: number; + bearer: string; +} + +export type PersonaMap = Record; + +/** Minimal DB interface needed by seedPersonas — avoids InstanceType issues with private constructors. */ +interface PersonaDb { + addUser(u: { username: string; password_hash: string; role: string }): number; + getUserByUsername(username: string): { username: string; role: UserRole; token_version: number } | undefined; +} + +/** Seed one user per built-in global role and return signed JWTs for all five. */ +export function seedPersonas(db: PersonaDb): PersonaMap { + const personas: Partial = {}; + + for (const role of FIVE_ROLES) { + const username = `persona-${role}`; + // Use a simple shared password since tests auth via Bearer, not login. + const passwordHash = bcrypt.hashSync('password123', 1); + db.addUser({ username, password_hash: passwordHash, role }); + const user = db.getUserByUsername(username)!; + const tv = user.token_version; + const token = jwt.sign( + { username, role, tv }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + personas[role] = { username, role, tokenVersion: tv, bearer: `Bearer ${token}` }; + } + + return personas as PersonaMap; +} diff --git a/backend/src/__tests__/fleet-action-card-endpoints.test.ts b/backend/src/__tests__/fleet-action-card-endpoints.test.ts index e14956fc..9eb0bd3b 100644 --- a/backend/src/__tests__/fleet-action-card-endpoints.test.ts +++ b/backend/src/__tests__/fleet-action-card-endpoints.test.ts @@ -19,6 +19,8 @@ const pruneManagedOnly = vi.fn(); const pruneSystem = vi.fn(); const estimateManagedReclaim = vi.fn(); const estimateSystemReclaim = vi.fn(); +const buildPrunePlan = vi.fn(); +const executePrunePlan = vi.fn(); const getContainersByStack = vi.fn(); const stopContainer = vi.fn(); const restartContainer = vi.fn(); @@ -47,6 +49,8 @@ vi.mock('../services/DockerController', () => ({ pruneSystem, estimateManagedReclaim, estimateSystemReclaim, + buildPrunePlan, + executePrunePlan, getContainersByStack, stopContainer, restartContainer, @@ -90,6 +94,11 @@ beforeEach(() => { pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 }); estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 }); estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 }); + buildPrunePlan.mockResolvedValue({ + nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0, + fingerprint: 'empty-plan', createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] }); getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]); stopContainer.mockResolvedValue(undefined); restartContainer.mockResolvedValue(undefined); @@ -304,14 +313,14 @@ describe('GET /api/fleet/labels/suggestions', () => { expect(res.status).toBe(401); }); - it('returns 403 for a non-admin (viewer) user', async () => { + it('allows a viewer with node:read to load suggestions', async () => { const viewerName = `viewer-sugg-${++labelCounter}`; db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' }); const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; const res = await request(app) .get('/api/fleet/labels/suggestions') .set('Authorization', viewerAuth); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => { @@ -1000,8 +1009,19 @@ describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => { }); describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { - it('routes to estimateManagedReclaim and marks each target dryRun: true', async () => { - estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 2048 }); + it('returns one multi-target itemized plan without calling prune methods', async () => { + buildPrunePlan.mockResolvedValue({ + nodeId: 1, + scope: 'managed', + targets: ['volumes', 'images'], + items: [ + { target: 'volumes', id: 'data', name: 'data', sizeBytes: 512, managed: true, reason: 'unused' }, + { target: 'images', id: 'image', name: 'app:latest', sizeBytes: 1536, managed: true, reason: 'unused' }, + ], + reclaimableBytes: 2048, + fingerprint: 'itemized-plan', + createdAt: 1, + }); const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) @@ -1009,41 +1029,105 @@ describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { expect(res.status).toBe(200); const node = res.body.results[0]; expect(node.reachable).toBe(true); + expect(node.fingerprint).toBe('itemized-plan'); + expect(node.items).toHaveLength(2); expect(node.targets).toHaveLength(2); - for (const t of node.targets) { - expect(t.success).toBe(true); - expect(t.reclaimedBytes).toBe(2048); - expect(t.dryRun).toBe(true); - } + expect(node.targets).toEqual([ + { target: 'images', success: true, reclaimedBytes: 1536, dryRun: true }, + { target: 'volumes', success: true, reclaimedBytes: 512, dryRun: true }, + ]); expect(pruneManagedOnly).not.toHaveBeenCalled(); expect(pruneSystem).not.toHaveBeenCalled(); - expect(estimateManagedReclaim).toHaveBeenCalledTimes(2); + expect(buildPrunePlan).toHaveBeenCalledTimes(1); expect(invalidateNodeCaches).not.toHaveBeenCalled(); }); - it('routes to estimateSystemReclaim when scope is "all"', async () => { - estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 8192 }); + it('loads known stacks and builds attribution even when scope is "all"', async () => { const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) .send({ targets: ['images'], scope: 'all', dryRun: true }); expect(res.status).toBe(200); expect(estimateManagedReclaim).not.toHaveBeenCalled(); - expect(estimateSystemReclaim).toHaveBeenCalled(); + expect(estimateSystemReclaim).not.toHaveBeenCalled(); + expect(buildPrunePlan).toHaveBeenCalledWith(['images'], 'all', ['alpha', 'beta'], expect.any(Number), expect.any(Function)); expect(pruneManagedOnly).not.toHaveBeenCalled(); expect(pruneSystem).not.toHaveBeenCalled(); - expect(res.body.results[0].targets[0].reclaimedBytes).toBe(8192); }); - it('still invokes pruneManagedOnly when dryRun is omitted', async () => { - pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 512 }); + it('requires reviewed roster and fingerprints when dryRun is omitted', async () => { const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(pruneManagedOnly).toHaveBeenCalled(); + expect(res.status).toBe(400); + expect(pruneManagedOnly).not.toHaveBeenCalled(); + expect(executePrunePlan).not.toHaveBeenCalled(); expect(estimateManagedReclaim).not.toHaveBeenCalled(); - expect(res.body.results[0].targets[0].dryRun).toBeUndefined(); + }); + + it('invalidates local caches only after an item is removed', async () => { + const local = db.getNodes().find((node) => node.type === 'local')!; + buildPrunePlan.mockResolvedValue({ + nodeId: local.id, + scope: 'managed', + targets: ['images'], + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused' }], + reclaimableBytes: 0, + fingerprint: 'reviewed-plan', + createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ + success: true, + reclaimedBytes: 0, + outcomes: [{ target: 'images', id: 'image', status: 'removed' }], + }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], + scope: 'managed', + dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: 'reviewed-plan' }], + }); + expect(res.status).toBe(200); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + expect(invalidateNodeCaches).toHaveBeenCalledWith(local.id); + }); + + it('does not invalidate local caches for empty, skipped, or failed outcomes', async () => { + const local = db.getNodes().find((node) => node.type === 'local')!; + const cases = [ + { items: [], outcomes: [] }, + { + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }], + outcomes: [{ target: 'images', id: 'image', status: 'skipped', reason: 'became active' }], + }, + { + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }], + outcomes: [{ target: 'images', id: 'image', status: 'failed', error: 'remove failed' }], + }, + ] as const; + for (const [index, testCase] of cases.entries()) { + const fingerprint = `reviewed-plan-${index}`; + buildPrunePlan.mockResolvedValue({ + nodeId: local.id, scope: 'managed', targets: ['images'], items: [...testCase.items], + reclaimableBytes: 0, fingerprint, createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [...testCase.outcomes] }); + invalidateNodeCaches.mockClear(); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint }], + }); + expect(res.status).toBe(200); + expect(invalidateNodeCaches).not.toHaveBeenCalled(); + } }); }); diff --git a/backend/src/__tests__/fleet-actions.test.ts b/backend/src/__tests__/fleet-actions.test.ts index a1f74bf5..3987fbf0 100644 --- a/backend/src/__tests__/fleet-actions.test.ts +++ b/backend/src/__tests__/fleet-actions.test.ts @@ -10,22 +10,41 @@ import fs from 'fs'; import path from 'path'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; let authHeader: string; +let scopedAuthHeader: string; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let scopedUserId: number; +let defaultNodeId: number; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); authHeader = `Bearer ${token}`; + const db = DatabaseService.getInstance(); + defaultNodeId = db.getDefaultNode()?.id ?? 1; + const hash = await bcrypt.hash('password123', 1); + scopedUserId = db.addUser({ username: 'fleet-scoped-operator', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'node-admin', resource_type: 'stack', resource_id: 'allowed-edit', node_id: defaultNodeId }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'allowed-deploy', node_id: defaultNodeId }); + const scopedUser = db.getUserByUsername('fleet-scoped-operator')!; + scopedAuthHeader = `Bearer ${jwt.sign({ username: scopedUser.username, role: scopedUser.role, tv: scopedUser.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; }); -afterAll(() => cleanupTestDb(tmpDir)); +afterAll(() => { + const db = DatabaseService.getInstance(); + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + cleanupTestDb(tmpDir); +}); function mockTier(tier: 'paid' | 'community') { vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); @@ -237,6 +256,60 @@ describe('Fleet Actions orchestration shape', () => { }); }); +describe('Fleet Actions authorize every target before mutation', () => { + afterEach(() => vi.restoreAllMocks()); + + it('rejects fleet stop when any confirmed stack lacks stack:deploy', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/labels/fleet-stop') + .set('Authorization', scopedAuthHeader) + .send({ + labelName: 'prod', + targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-deploy', 'denied-deploy'] }], + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('does not create a label when bulk assign contains a denied stack', async () => { + mockTier('paid'); + const labelName = 'atomic-bulk-denied'; + const res = await request(app) + .post('/api/fleet/labels/bulk-assign') + .set('Authorization', scopedAuthHeader) + .send({ + label: { name: labelName, color: 'teal' }, + targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-edit', 'denied-edit'] }], + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false); + }); + + it('does not create a label when a local receiver target is denied', async () => { + mockTier('paid'); + const labelName = 'atomic-local-denied'; + const res = await request(app) + .post('/api/fleet-actions/labels/local-assign') + .set('Authorization', scopedAuthHeader) + .send({ label: { name: labelName, color: 'teal' }, stackNames: ['allowed-edit', 'denied-edit'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false); + }); + + it('rejects local stop before work when any confirmed stack is denied', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet-actions/labels/local-stop') + .set('Authorization', scopedAuthHeader) + .send({ labelName: 'prod', stackNames: ['allowed-deploy', 'denied-deploy'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); +}); + // The per-node local-stop receiver is what a control instance calls on each // remote during a fleet-wide stop. It must be reachable on every license (only // admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action diff --git a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts index 5201a3ae..aa5f6f3f 100644 --- a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts @@ -40,6 +40,8 @@ let proxyNodeId: number; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DockerController: typeof import('../services/DockerController').default; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; beforeAll(async () => { tmpDir = await setupTestDb(); @@ -47,6 +49,8 @@ beforeAll(async () => { ({ NodeRegistry } = await import('../services/NodeRegistry')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ LicenseService } = await import('../services/LicenseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ FileSystemService } = await import('../services/FileSystemService')); const db = DatabaseService.getInstance(); pilotNodeId = db.addNode({ @@ -259,7 +263,19 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => { mockFetch((url, init) => { const headers = (init?.headers as Record) ?? {}; calls.push({ url, auth: headers.Authorization }); - return new Response(JSON.stringify({ success: true, reclaimedBytes: 999, dryRun: true }), { + return new Response(JSON.stringify({ + nodeId: 1, + scope: 'managed', + targets: ['images'], + items: [{ + target: 'images', id: 'sha256:pilot', name: 'pilot/app:latest', sizeBytes: 999, + managed: true, reason: 'Image is not used by any container', stackName: 'app', + image: { references: ['pilot/app:latest'] }, + }], + reclaimableBytes: 999, + fingerprint: 'pilot-plan', + createdAt: 1, + }), { status: 200, headers: { 'content-type': 'application/json' }, }); }); @@ -272,11 +288,75 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => { expect(res.status).toBe(200); const pilotCall = calls.find(c => c.url.startsWith(PILOT_LOOPBACK)); expect(pilotCall).toBeDefined(); + expect(pilotCall?.url).toBe(`${PILOT_LOOPBACK}/api/system/prune/plan`); expect(pilotCall?.auth).toBeUndefined(); const pilotResult = res.body.results.find((r: { nodeId: number }) => r.nodeId === pilotNodeId); expect(pilotResult.reachable).toBe(true); expect(pilotResult.targets[0].reclaimedBytes).toBe(999); }); + + it('uses one plan request and one fingerprint-bound execute request per remote', async () => { + mockPaidTier(); + mockTargets(); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const localPlan = { + nodeId: local.id, scope: 'managed' as const, targets: ['images' as const], items: [], + reclaimableBytes: 0, fingerprint: 'local-plan', createdAt: 1, + }; + const executePrunePlan = vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(localPlan), + executePrunePlan, + } as unknown as ReturnType); + vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]); + + const calls: Array<{ url: string; auth: string | undefined; body: Record }> = []; + mockFetch((url, init) => { + const headers = (init?.headers as Record) ?? {}; + const body = JSON.parse(String(init?.body)) as Record; + calls.push({ url, auth: headers.Authorization, body }); + const pilot = url.startsWith(PILOT_LOOPBACK); + if (url.endsWith('/api/system/prune/plan')) { + return new Response(JSON.stringify({ + nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0, + fingerprint: pilot ? 'pilot-plan' : 'proxy-plan', createdAt: 1, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ success: true, reclaimedBytes: 0, outcomes: [] }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [ + { nodeId: local.id, reachable: true }, + { nodeId: pilotNodeId, reachable: true }, + { nodeId: proxyNodeId, reachable: true }, + ], + plans: [ + { nodeId: local.id, fingerprint: 'local-plan' }, + { nodeId: pilotNodeId, fingerprint: 'pilot-plan' }, + { nodeId: proxyNodeId, fingerprint: 'proxy-plan' }, + ], + }); + + expect(res.status).toBe(200); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + const pilotCalls = calls.filter((call) => call.url.startsWith(PILOT_LOOPBACK)); + expect(pilotCalls.map((call) => call.url)).toEqual([ + `${PILOT_LOOPBACK}/api/system/prune/plan`, + `${PILOT_LOOPBACK}/api/system/prune/system`, + ]); + expect(pilotCalls.every((call) => call.auth === undefined)).toBe(true); + expect(pilotCalls[1].body).toMatchObject({ targets: ['images'], planFingerprint: 'pilot-plan' }); + const proxyCalls = calls.filter((call) => call.url.startsWith(PROXY_URL)); + expect(proxyCalls).toHaveLength(2); + expect(proxyCalls.every((call) => call.auth === `Bearer ${PROXY_TOKEN}`)).toBe(true); + }); }); describe('GET /api/image-updates/fleet (pilot inclusion)', () => { diff --git a/backend/src/__tests__/fleet-prune-df-timeout.test.ts b/backend/src/__tests__/fleet-prune-df-timeout.test.ts index 04d8f381..76504dc5 100644 --- a/backend/src/__tests__/fleet-prune-df-timeout.test.ts +++ b/backend/src/__tests__/fleet-prune-df-timeout.test.ts @@ -1,8 +1,7 @@ /** - * F-6 regression: fleet routes that call estimateSystemReclaim on local - * nodes must also bound the slow `docker system df` call (8s) and surface - * a recognizable timeout message to the operator, matching the - * /api/system/prune/estimate behavior. + * F-6 regression: Fleet itemized plan enumeration and byte estimation both + * bound the slow `docker system df` call (8s) and surface a recognizable + * timeout message to the operator. * * Covers: * - POST /api/fleet/labels/fleet-prune with dryRun: true @@ -42,17 +41,27 @@ afterEach(() => { activeBulkActions.clear(); }); -function stubLocalEstimate(impl: () => Promise<{ reclaimableBytes: number }>) { +function stubLocalEstimate( + estimateImpl: () => Promise<{ reclaimableBytes: number }>, + planImpl: () => Promise = async () => ({ + nodeId: 1, scope: 'all', targets: ['volumes'], items: [], reclaimableBytes: 0, + fingerprint: 'empty', createdAt: 1, + }), +) { vi.spyOn(DockerController, 'getInstance').mockReturnValue({ - estimateSystemReclaim: vi.fn().mockImplementation(impl), + estimateSystemReclaim: vi.fn().mockImplementation(estimateImpl), estimateManagedReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }), + buildPrunePlan: vi.fn().mockImplementation(planImpl), } as unknown as ReturnType); vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]); } describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () => { it('POST /api/fleet/labels/fleet-prune dry-run surfaces a busy-daemon error on local timeout', async () => { - stubLocalEstimate(() => new Promise(() => { /* never resolves */ })); + stubLocalEstimate( + () => Promise.resolve({ reclaimableBytes: 0 }), + () => new Promise(() => { /* never resolves */ }), + ); const t0 = Date.now(); const res = await request(app) @@ -86,7 +95,21 @@ describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () => }, 20_000); it('fleet-prune dry-run succeeds normally when estimateSystemReclaim resolves quickly', async () => { - stubLocalEstimate(() => Promise.resolve({ reclaimableBytes: 256 })); + stubLocalEstimate( + () => Promise.resolve({ reclaimableBytes: 256 }), + async () => ({ + nodeId: 1, + scope: 'all', + targets: ['volumes'], + items: [{ + target: 'volumes', id: 'volume-a', name: 'volume-a', sizeBytes: 256, + managed: false, reason: 'Volume is not referenced by any container', + }], + reclaimableBytes: 256, + fingerprint: 'volume-plan', + createdAt: 1, + }), + ); const res = await request(app) .post('/api/fleet/labels/fleet-prune') diff --git a/backend/src/__tests__/fleet-prune.test.ts b/backend/src/__tests__/fleet-prune.test.ts index 1b175d1f..13a83f48 100644 --- a/backend/src/__tests__/fleet-prune.test.ts +++ b/backend/src/__tests__/fleet-prune.test.ts @@ -1,17 +1,12 @@ -/** - * Tests for the fleet-wide Docker prune endpoint. Covers auth, tier gating, - * input validation, local node orchestration with mocked DockerController, - * remote-node fan-out with mocked fetch, lock contention, and partial failures. - */ -import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; -import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { cleanupTestDb, setupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; +import type { PruneItemOutcome, PrunePlan, PrunePlanItem } from '../services/prunePlan'; let tmpDir: string; let app: import('express').Express; let authHeader: string; -let LicenseService: typeof import('../services/LicenseService').LicenseService; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; @@ -20,13 +15,11 @@ let activeBulkActions: typeof import('../routes/labels').activeBulkActions; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); - ({ LicenseService } = await import('../services/LicenseService')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ activeBulkActions } = await import('../routes/labels')); - const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); - authHeader = `Bearer ${token}`; + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' })}`; }); afterAll(() => cleanupTestDb(tmpDir)); @@ -36,198 +29,448 @@ afterEach(() => { activeBulkActions.clear(); }); -function mockTier(tier: 'paid' | 'community') { - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +function item(overrides: Partial = {}): PrunePlanItem { + return { + target: 'images', + id: 'sha256:image', + name: 'example/app:latest', + sizeBytes: 256, + managed: true, + reason: 'Image is not used by any container', + stackName: 'app', + image: { references: ['example/app:latest'] }, + ...overrides, + } as PrunePlanItem; } -function mockLocalPrune(opts: { managedBytes?: Partial>; allBytes?: Partial>; throwOn?: string } = {}) { +function plan(nodeId: number, fingerprint = `fingerprint-${nodeId}`, items: PrunePlanItem[] = [item()]): PrunePlan { + return { + nodeId, + scope: 'managed', + targets: ['images'], + items, + reclaimableBytes: items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0), + fingerprint, + createdAt: 1, + }; +} + +function mockLocal(planFactory: (nodeId: number) => PrunePlan = (nodeId) => plan(nodeId)) { const fake = { - pruneManagedOnly: vi.fn(async (target: string) => { - if (opts.throwOn === target) throw new Error(`mock pruneManagedOnly threw for ${target}`); - return { success: true, reclaimedBytes: opts.managedBytes?.[target] ?? 0 }; - }), - pruneSystem: vi.fn(async (target: string) => { - if (opts.throwOn === target) throw new Error(`mock pruneSystem threw for ${target}`); - return { success: true, reclaimedBytes: opts.allBytes?.[target] ?? 0 }; - }), + buildPrunePlan: vi.fn(async (_targets, _scope, _stacks, nodeId: number) => planFactory(nodeId)), + executePrunePlan: vi.fn(async (reviewedPlan: PrunePlan): Promise<{ + success: boolean; + reclaimedBytes: number; + outcomes: PruneItemOutcome[]; + }> => ({ + success: true, + reclaimedBytes: reviewedPlan.reclaimableBytes, + outcomes: reviewedPlan.items.map((entry) => ({ + id: entry.id, + target: entry.target, + status: 'removed' as const, + sizeBytes: entry.sizeBytes, + })), + })), }; vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType); - // Spy on the prototype so the mock applies to whichever FileSystemService - // instance the route creates for the local node id, not a throwaway one. - vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['stack-a', 'stack-b']); + vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['app']); return fake; } +function localReview(fingerprint: string) { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + return { + local, + body: { + targets: ['images'], + scope: 'managed', + dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint }], + }, + }; +} + +function addRemote(name: string): number { + return DatabaseService.getInstance().addNode({ + name, + type: 'remote', + api_url: `http://${name}.example:1852`, + api_token: 'token', + compose_dir: '/app/compose', + is_default: false, + }); +} + describe('POST /api/fleet/labels/fleet-prune', () => { - it('returns 401 without auth', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(401); - }); - - it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => { - mockTier('community'); - mockLocalPrune({ managedBytes: { images: 128 } }); - const res = await request(app) + it('requires authentication and validates the request', async () => { + expect((await request(app).post('/api/fleet/labels/fleet-prune').send({})).status).toBe(401); + const invalid = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(res.body.code).not.toBe('PAID_REQUIRED'); - expect(Array.isArray(res.body.results)).toBe(true); - }); - - it('returns 400 when body is missing', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send(); - expect(res.status).toBe(400); - }); - - it('returns 400 when targets is empty', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: [], scope: 'managed' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/non-empty/); - }); - - it('returns 400 when a target is unrecognized', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'containers'], scope: 'managed' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/Invalid target/); - }); - - it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => { - const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(res.body.results).toHaveLength(1); - const node = res.body.results[0]; - expect(node.reachable).toBe(true); - expect(node.targets).toEqual([ - { target: 'images', success: true, reclaimedBytes: 1500 }, - { target: 'volumes', success: true, reclaimedBytes: 320 }, - ]); - expect(fake.pruneManagedOnly).toHaveBeenCalledTimes(2); - expect(fake.pruneSystem).not.toHaveBeenCalled(); - }); - - it('runs pruneSystem when scope is "all" and dedupes targets', async () => { - const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'networks', 'images'], scope: 'all' }); - expect(res.status).toBe(200); - expect(fake.pruneManagedOnly).not.toHaveBeenCalled(); - expect(fake.pruneSystem).toHaveBeenCalledTimes(2); - const node = res.body.results[0]; - expect(node.targets.map((t: { target: string }) => t.target).sort()).toEqual(['images', 'networks']); - }); - - it('records per-target failure when DockerController throws but continues remaining targets', async () => { - mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'managed' }); - expect(res.status).toBe(200); - const node = res.body.results[0]; - expect(node.targets.find((t: { target: string }) => t.target === 'images').success).toBe(true); - const volumes = node.targets.find((t: { target: string }) => t.target === 'volumes'); - expect(volumes.success).toBe(false); - expect(volumes.reclaimedBytes).toBe(0); - expect(volumes.error).toMatch(/pruneManagedOnly threw/); - }); - - it('reports lock contention when bulk-prune lock is already held', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const localId = db.getNodes().find(n => n.type === 'local')!.id; - activeBulkActions.add(`bulk-prune:${localId}`); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - const node = res.body.results.find((n: { nodeId: number }) => n.nodeId === localId); - expect(node.targets[0].success).toBe(false); - expect(node.targets[0].error).toMatch(/already running/); - }); - - it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const remoteId = db.addNode({ - name: 'remote-test', - type: 'remote', - api_url: 'http://remote.example:1852', - api_token: 'tok', - compose_dir: '/app/compose', - is_default: false, - }); - try { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); - const res = await request(app) + .send({ targets: ['containers'], dryRun: true }); + expect(invalid.status).toBe(400); + for (const scope of [undefined, 'everything', 1]) { + const response = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes', 'networks'], scope: 'managed' }); - expect(res.status).toBe(200); - const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); - expect(remote.reachable).toBe(false); - expect(remote.error).toMatch(/ECONNREFUSED/); - expect(remote.targets).toHaveLength(3); - for (const t of remote.targets) expect(t.success).toBe(false); - // Only the first target attempts the fetch; the rest short-circuit. - expect(fetchSpy).toHaveBeenCalledTimes(1); - } finally { - db.deleteNode(remoteId); + .send({ targets: ['images'], scope, dryRun: true }); + expect(response.status).toBe(400); } }); - it('parses remote node responses into per-target reclaimed bytes', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const remoteId = db.addNode({ - name: 'remote-ok', - type: 'remote', - api_url: 'http://remote-ok.example:1852/', - api_token: 'tok', - compose_dir: '/app/compose', - is_default: false, - }); + it('rejects malformed remote plan contracts', async () => { + mockLocal(); + const remoteId = addRemote('remote-malformed-plan'); + const base = plan(remoteId); + const malformedPlans = [ + { ...base, targets: ['images', 'images'] }, + { ...base, items: [item(), item()], reclaimableBytes: 512 }, + { ...base, reclaimableBytes: -1 }, + { ...base, nodeId: 'remote' }, + { ...base, createdAt: Number.NaN }, + ]; try { - const responses = new Map([['images', 4096], ['volumes', 512]]); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { - const body = JSON.parse((init?.body as string) ?? '{}') as { target: string }; - const reclaimedBytes = responses.get(body.target) ?? 0; - return new Response(JSON.stringify({ message: 'ok', success: true, reclaimedBytes }), { - status: 200, headers: { 'content-type': 'application/json' }, - }); - }); - const res = await request(app) + for (const malformed of malformedPlans) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true }); + const remote = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId); + expect(remote).toMatchObject({ reachable: true, code: 'REMOTE_PLAN_INVALID' }); + expect(remote.fingerprint).toBeUndefined(); + vi.restoreAllMocks(); + mockLocal(); + } + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('returns itemized dry-run plans without taking the destructive lock', async () => { + const fake = mockLocal(); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed', dryRun: true }); + + expect(response.status).toBe(200); + expect(response.body.results[0]).toMatchObject({ + reachable: true, + fingerprint: expect.stringMatching(/^fingerprint-/), + reclaimableBytes: 256, + items: [expect.objectContaining({ name: 'example/app:latest', managed: true, stackName: 'app' })], + targets: [{ target: 'images', success: true, reclaimedBytes: 256, dryRun: true }], + }); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(activeBulkActions.size).toBe(0); + }); + + it('loads known stacks for All unused attribution', async () => { + mockLocal(); + const stackSpy = vi.spyOn(FileSystemService.prototype, 'getStacks'); + await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'all', dryRun: true }); + expect(stackSpy).toHaveBeenCalled(); + }); + + it('rejects missing, duplicate, and malformed reviewed entries', async () => { + mockLocal(); + const { local } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const cases = [ + { reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [] }, + { reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: local.id, reachable: true }], plans: [] }, + { reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [{ nodeId: local.id, fingerprint: '' }] }, + ]; + for (const testCase of cases) { + const response = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'all' }); - expect(res.status).toBe(200); - const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); - expect(remote.reachable).toBe(true); - expect(remote.targets).toEqual([ - { target: 'images', success: true, reclaimedBytes: 4096 }, - { target: 'volumes', success: true, reclaimedBytes: 512 }, - ]); + .send({ targets: ['images'], scope: 'managed', dryRun: false, ...testCase }); + expect([400, 409]).toContain(response.status); + } + }); + + it('executes a valid empty plan and releases the lock', async () => { + const fake = mockLocal((nodeId) => plan(nodeId, `empty-${nodeId}`, [])); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `empty-${local.id}` }], + }); + expect(response.status).toBe(200); + expect(fake.executePrunePlan).toHaveBeenCalledTimes(1); + expect(response.body.results[0].outcomes).toEqual([]); + expect(activeBulkActions.size).toBe(0); + }); + + it('fails closed when a local prune lock is active', async () => { + const fake = mockLocal(); + const { local, body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const remoteId = addRemote('remote-lock-check'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const lockKey = `bulk-prune:${local.id}`; + activeBulkActions.add(lockKey); + try { + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + ...body, + reviewedNodes: [...body.reviewedNodes, { nodeId: remoteId, reachable: true }], + plans: [...body.plans, { nodeId: remoteId, fingerprint: 'remote-plan' }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_ALREADY_RUNNING'); + expect(fake.buildPrunePlan).not.toHaveBeenCalled(); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(activeBulkActions.has(lockKey)).toBe(true); } finally { - db.deleteNode(remoteId); + activeBulkActions.delete(lockKey); + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('returns a node failure when local execution setup fails after preflight', async () => { + const fake = mockLocal(); + vi.spyOn(FileSystemService.prototype, 'getStacks') + .mockResolvedValueOnce(['app']) + .mockRejectedValueOnce(new Error('stack inventory unavailable')); + const { body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send(body); + + expect(response.status).toBe(200); + expect(response.body.results[0]).toMatchObject({ + code: 'PRUNE_EXECUTE_FAILED', + error: 'stack inventory unavailable', + targets: [{ success: false }], + }); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + }); + + it('prevents every destructive call when one node plan is stale', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-stale'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + JSON.stringify(plan(99, 'remote-new')), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-old' }, + ], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_PLAN_STALE'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toContain('/api/system/prune/plan'); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a reviewed-unreachable node that becomes reachable', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-newly-reachable'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(plan(99)), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: false }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a reviewed-reachable node that becomes unreachable', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-now-offline'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-plan' }, + ], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a changed configured-node roster before preflight', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-added-after-review'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_ROSTER_CHANGED'); + expect(fake.buildPrunePlan).not.toHaveBeenCalled(); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a present but incomplete remote outcome list', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-bad-outcomes'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify(plan(remoteId, 'remote-reviewed')), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + reclaimedBytes: 256, + outcomes: [], + }), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-reviewed' }, + ], + }); + + expect(response.status).toBe(200); + expect(fake.executePrunePlan).toHaveBeenCalledTimes(1); + expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({ + code: 'REMOTE_PRUNE_INVALID', + error: 'Remote returned malformed or incomplete prune outcomes', + }); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects malformed numeric and success fields in remote execute results', async () => { + mockLocal(); + const remoteId = addRemote('remote-bad-result-fields'); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const remotePlan = plan(remoteId, 'remote-fields'); + const malformedResults = [ + { success: 'yes', reclaimedBytes: 256 }, + { success: true, reclaimedBytes: -1 }, + { + success: true, reclaimedBytes: 0, + outcomes: [{ target: 'images', id: 'sha256:image', status: 'removed', sizeBytes: -1 }], + }, + ]; + try { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + for (const malformed of malformedResults) { + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, { nodeId: remoteId, fingerprint: 'remote-fields' }], + }); + expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({ + code: 'REMOTE_PRUNE_INVALID', + }); + } + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('projects mixed local outcomes and accepts a legacy remote total', async () => { + const items = [ + item({ id: 'removed', sizeBytes: 100 }), + item({ id: 'skipped', sizeBytes: 200 }), + item({ id: 'failed', sizeBytes: 300 }), + ]; + const fake = mockLocal((nodeId) => plan(nodeId, `mixed-${nodeId}`, items)); + fake.executePrunePlan.mockResolvedValue({ + success: false, + reclaimedBytes: 100, + outcomes: [ + { target: 'images', id: 'removed', status: 'removed', sizeBytes: 100 }, + { target: 'images', id: 'skipped', status: 'skipped', reason: 'became active' }, + { target: 'images', id: 'failed', status: 'failed', error: 'remove failed' }, + ], + }); + const remoteId = addRemote('remote-legacy-total'); + const remotePlan = plan(remoteId, 'legacy-plan', []); + try { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, reclaimedBytes: 999 }), { status: 200 })); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `mixed-${local.id}` }, { nodeId: remoteId, fingerprint: 'legacy-plan' }], + }); + const localResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === local.id); + expect(localResult.targets[0]).toMatchObject({ + success: false, reclaimedBytes: 100, removed: 1, skipped: 1, failed: 1, + }); + const remoteResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId); + expect(remoteResult.reclaimedBytes).toBe(999); + expect(remoteResult.outcomes).toBeUndefined(); + expect(remoteResult.targets[0]).toMatchObject({ success: true, reclaimedBytes: 0 }); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); } }); }); diff --git a/backend/src/__tests__/helpers/arcstatsFsMock.ts b/backend/src/__tests__/helpers/arcstatsFsMock.ts index 4baf8ffd..3e8198c5 100644 --- a/backend/src/__tests__/helpers/arcstatsFsMock.ts +++ b/backend/src/__tests__/helpers/arcstatsFsMock.ts @@ -1,26 +1,33 @@ import { promises as fs } from 'fs'; import { vi } from 'vitest'; -import { ARCSTATS_FIXED_PATHS } from '../../helpers/hostMemory'; +import { ARCSTATS_FIXED_PATHS, MEMINFO_FIXED_PATHS } from '../../helpers/hostMemory'; /** - * Path-aware partial mock of `fs.promises` for ZFS arcstats reads. + * Path-aware partial mock of `fs.promises` for ZFS arcstats and /proc/meminfo + * reads. * - * `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (and optional - * variants) to compute reclaimable ARC. Tests may run on a ZFS host, so a real - * read would make results host-dependent. This installs a spy that intercepts - * ONLY registered/ARC-candidate paths and delegates every other - * `readFile`/`stat` to the real filesystem, so `setupTestDb` and - * `DatabaseService` keep working. Default behavior: ARC candidates reject with - * ENOENT (no ARC), so consumers fall back to the plain `active/total` reading. + * `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (for ARC) and + * `/proc/meminfo` (for VM ballooning). Tests may run on a ZFS host or a + * ballooned VM, so real reads would make results host-dependent. This installs + * a spy that intercepts ONLY registered/candidate paths and delegates every + * other `readFile`/`stat` to the real filesystem, so `setupTestDb` and + * `DatabaseService` keep working. Default behavior: candidates reject with + * ENOENT, so consumers fall back to the plain `active/total` reading. */ // Sourced from the helper so the mock cannot silently drift from the paths the // production code actually reads. export const ARC_CANDIDATE_PATHS = ARCSTATS_FIXED_PATHS; -/** Second fixed candidate; the default path fixtures are served from. */ +/** Second fixed ARC candidate; the default path ARC fixtures are served from. */ export const DEFAULT_ARC_PATH = ARC_CANDIDATE_PATHS[1]; +/** Meminfo candidate paths (same source-of-truth import pattern as ARC). */ +export const MEMINFO_CANDIDATE_PATHS = MEMINFO_FIXED_PATHS; + +/** Default meminfo path for test fixtures. */ +export const DEFAULT_MEMINFO_PATH = MEMINFO_CANDIDATE_PATHS[1]; + type StatDescriptor = { isFile: boolean; size: number }; export interface ArcstatsFsMock { @@ -47,7 +54,8 @@ export function installArcstatsFsMock(): ArcstatsFsMock { const realStat = fs.stat.bind(fs); const reads = new Map(); const stats = new Map(); - const isArcCandidate = (p: string): boolean => ARC_CANDIDATE_PATHS.includes(p); + const isCandidatePath = (p: string): boolean => + ARC_CANDIDATE_PATHS.includes(p) || MEMINFO_CANDIDATE_PATHS.includes(p); vi.spyOn(fs, 'readFile').mockImplementation((async (p: unknown, ...rest: unknown[]) => { const key = String(p); @@ -56,7 +64,7 @@ export function installArcstatsFsMock(): ArcstatsFsMock { if (v instanceof Error) throw v; return v; } - if (isArcCandidate(key)) throw enoent(key); + if (isCandidatePath(key)) throw enoent(key); return (realReadFile as (...a: unknown[]) => unknown)(p, ...rest); }) as unknown as typeof fs.readFile); @@ -73,7 +81,7 @@ export function installArcstatsFsMock(): ArcstatsFsMock { const size = typeof v === 'string' ? Buffer.byteLength(v) : 0; return { isFile: () => true, size }; } - if (isArcCandidate(key)) throw enoent(key); + if (isCandidatePath(key)) throw enoent(key); return (realStat as (...a: unknown[]) => unknown)(p, ...rest); }) as unknown as typeof fs.stat); @@ -96,3 +104,29 @@ export function arcstatsBody(sizeRow: string | number, cMinRow: string | number) '', ].join('\n'); } + +/** + * Build a realistic /proc/meminfo snippet with the given Balloon value in kB. + * Pass undefined / a negative value to omit the Balloon line entirely. + */ +export function meminfoBody(balloonKb?: number): string { + const balloonLine = balloonKb !== undefined && balloonKb >= 0 + ? `Balloon: ${balloonKb} kB\n` + : ''; + return [ + 'MemTotal: 16433188 kB', + 'MemFree: 620452 kB', + 'MemAvailable: 3489624 kB', + 'Buffers: 158668 kB', + 'Cached: 3335960 kB', + 'SwapCached: 0 kB', + 'Active: 5280444 kB', + 'Inactive: 7478672 kB', + balloonLine, + 'SwapTotal: 8388604 kB', + 'SwapFree: 8388604 kB', + 'Dirty: 124 kB', + 'Writeback: 0 kB', + '', + ].join('\n'); +} diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index fa604f56..aaf7d6a8 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -121,3 +121,24 @@ export async function seedMfaUser( return { userId, secret, backupCodes }; } + +/** + * Seed a user with MFA enrolled and return a session JWT signed with the + * user's current token_version so tests can verify that an operation that + * bumps it (MFA reset, password change) invalidates pre-existing sessions. + */ +export async function seedMfaUserWithToken( + username: string, + password: string, +): Promise<{ userId: number; secret: string; backupCodes: string[]; token: string }> { + const { userId, secret, backupCodes } = await seedMfaUser(username, password); + const { DatabaseService } = await import('../../services/DatabaseService'); + const jwtLib = (await import('jsonwebtoken')).default; + const user = DatabaseService.getInstance().getUser(userId)!; + const token = jwtLib.sign( + { username, role: user.role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + return { userId, secret, backupCodes, token }; +} diff --git a/backend/src/__tests__/host-console-ws.test.ts b/backend/src/__tests__/host-console-ws.test.ts index 973ffa91..8e0cede2 100644 --- a/backend/src/__tests__/host-console-ws.test.ts +++ b/backend/src/__tests__/host-console-ws.test.ts @@ -65,17 +65,39 @@ describe('WebSocket upgrade - host console auth enforcement', () => { expect(await expectRejected(ws)).toBe(403); }); - it('rejects a non-admin user without system:console (403)', async () => { + // All five built-in roles: only admin (system:console) is accepted on host + // console. Every other role must be rejected at upgrade time. + const NON_ADMIN_HOST_CONSOLE_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const; + + for (const role of NON_ADMIN_HOST_CONSOLE_ROLES) { + it(`rejects ${role} user without system:console (403)`, async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const username = `hc_${role.replace('-', '_')}`; + const hash = await bcrypt.hash('password123', 1); + try { + DatabaseService.getInstance().addUser({ username, password_hash: hash, role }); + } catch { + // already exists from a prior run in the same worker + } + const token = jwt.sign({ username, role }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + expect(await expectRejected(ws)).toBe(403); + }); + } + + it('rejects a legacy no-tv admin JWT after token_version bump (401)', async () => { const { DatabaseService } = await import('../services/DatabaseService'); - const hash = await bcrypt.hash('viewerpass', 1); - try { - DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' }); - } catch { - // already exists from a prior run in the same worker - } - const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' }); - const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); - expect(await expectRejected(ws)).toBe(403); + const db = DatabaseService.getInstance(); + const username = `hc-legacy-tv-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); + const legacyNoTv = jwt.sign({ username, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + expect(await expectRejected(ws)).toBe(401); }); it('accepts a Community-tier admin', async () => { diff --git a/backend/src/__tests__/host-memory.test.ts b/backend/src/__tests__/host-memory.test.ts index b7c6bf63..81ee5b7d 100644 --- a/backend/src/__tests__/host-memory.test.ts +++ b/backend/src/__tests__/host-memory.test.ts @@ -9,8 +9,11 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vite import { installArcstatsFsMock, arcstatsBody, + meminfoBody, DEFAULT_ARC_PATH, + DEFAULT_MEMINFO_PATH, ARC_CANDIDATE_PATHS, + MEMINFO_CANDIDATE_PATHS, type ArcstatsFsMock, } from './helpers/arcstatsFsMock'; @@ -20,7 +23,7 @@ vi.mock('systeminformation', () => ({ default: { mem: (...args: unknown[]) => mockMem(...args) }, })); -import { getHostMemory, adjustForArc } from '../helpers/hostMemory'; +import { getHostMemory, adjustForArc, adjustForBalloon, memoryToWire } from '../helpers/hostMemory'; // mem.active === total - available on Linux, so used/free below mirror the // real systeminformation shape the helper consumes. @@ -43,22 +46,24 @@ beforeEach(() => { arcFs.clear(); mockMem.mockReset(); delete process.env.SENCHO_ZFS_ARCSTATS_PATH; + delete process.env.SENCHO_PROC_MEMINFO_PATH; }); describe('adjustForArc', () => { it('reproduces active/total when reclaimable ARC is 0', () => { const result = adjustForArc(memSample(1000, 600), 0); expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + expect('arcReclaimable' in result).toBe(false); }); it('adds reclaimable ARC back into available, lowering usage', () => { const result = adjustForArc(memSample(1000, 600), 200); - expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 }); + expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20, arcReclaimable: 200 }); }); it('clamps effective available to total when ARC exceeds the gap', () => { const result = adjustForArc(memSample(1000, 600), 5000); - expect(result).toEqual({ total: 1000, used: 0, free: 1000, usagePercent: 0 }); + expect(result).toEqual({ total: 1000, used: 0, free: 1000, usagePercent: 0, arcReclaimable: 5000 }); }); it('guards against a zero total', () => { @@ -78,7 +83,7 @@ describe('getHostMemory ARC discovery', () => { mockMem.mockResolvedValue(memSample(1000, 600)); arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 const result = await getHostMemory(); - expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 }); + expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20, arcReclaimable: 200 }); }); it('prefers the operator override path over the fixed candidates', async () => { @@ -199,6 +204,241 @@ describe('getHostMemory ARC discovery', () => { }); }); +describe('adjustForBalloon', () => { + const arcAdjusted = (total: number, used: number, free: number, usagePercent: number): ReturnType => + ({ total, used, free, usagePercent }); + + it('returns the input unchanged when ballooned is 0', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, 0); + expect(result).toBe(input); // identity for zero + }); + + it('returns the input unchanged when ballooned is negative', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, -5); + expect(result).toBe(input); + }); + + it('subtracts ballooned from used, adds to free, sets optional fields', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, 200); + expect(result.ballooned).toBe(200); + expect(result.effectiveTotal).toBe(1000); + expect(result.effectiveUsed).toBe(200); // 400 - 200 + expect(result.effectiveFree).toBe(800); // 600 + 200 + expect(result.effectiveUsagePercent).toBe(20); // 200 / 1000 * 100 + expect(result.balloonSource).toBe('linux_proc_meminfo'); + // Base fields unchanged. + expect(result.total).toBe(1000); + expect(result.used).toBe(400); + expect(result.free).toBe(600); + }); + + it('clamps effectiveUsed at 0 when balloon exceeds used', () => { + const input = arcAdjusted(1000, 100, 900, 10); + const result = adjustForBalloon(input, 5000); + expect(result.effectiveUsed).toBe(0); + expect(result.effectiveFree).toBe(1000); + expect(result.effectiveUsagePercent).toBe(0); + }); + + it('handles zero total gracefully', () => { + const input = arcAdjusted(0, 0, 0, 0); + const result = adjustForBalloon(input, 100); + expect(result.effectiveUsagePercent).toBe(0); + }); +}); + +describe('getHostMemory balloon discovery', () => { + it('returns the base ARC-adjusted shape when no meminfo is present', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('returns the base ARC-adjusted shape when Balloon is missing from meminfo', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody()); // no Balloon line + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('returns the base ARC-adjusted shape when Balloon is 0 kB', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(0)); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('subtracts ballooned memory and sets optional fields', async () => { + mockMem.mockResolvedValue(memSample(16000, 4000)); // 16 GB total, 4 GB available → 75% used + // 4 GiB balloon = 4194304 kB + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(4_194_304)); + const result = await getHostMemory(); + // ARC=0, available=4000: used=12000 + // ballooned=4194304*1024 = 4_294_967_296 bytes + // effectiveUsed = 12000 - ballooned ≈ 7705 MB + expect(result.used).toBe(12000); + expect(typeof result.ballooned).toBe('number'); + expect(result.ballooned!).toBeGreaterThan(0); + expect(result.effectiveUsed).toBeDefined(); + expect(result.effectiveFree).toBeDefined(); + expect(result.effectiveUsagePercent).toBeDefined(); + expect(result.effectiveUsed!).toBeLessThan(result.used); + expect(result.balloonSource).toBe('linux_proc_meminfo'); + }); + + it('combines ARC reclaim and balloon adjustment', async () => { + mockMem.mockResolvedValue(memSample(16000, 2000)); // 12.5% available + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5000, 1000)); // reclaimable ARC = 4000 + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(2_097_152)); // 2 GiB balloon + const result = await getHostMemory(); + // ARC-adjusted: used = 16000 - (2000 + 4000) = 10000 + expect(result.used).toBe(10000); + expect(result.arcReclaimable).toBe(4000); + // Balloon-adjusted: effectiveUsed = 10000 - 2GiB + expect(result.effectiveUsed).toBeDefined(); + expect(result.effectiveUsed!).toBeLessThan(result.used); + }); + + it('prefers the meminfo override path', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setRead('/custom/meminfo', meminfoBody(4_194_304)); // 4 GiB balloon + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(0)); // fixed path says no balloon + const result = await getHostMemory(); + expect(result.ballooned).toBeGreaterThan(0); // override won + }); + + it('falls through to a fixed meminfo path when the override is unreadable', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setReadError('/custom/meminfo', Object.assign(new Error('nope'), { code: 'ENOENT' })); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(2_097_152)); // 2 GiB + const result = await getHostMemory(); + expect(result.ballooned).toBeGreaterThan(0); // fell through to fixed + }); + + it('reads the host-mounted meminfo candidate and prefers it over /proc', async () => { + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setRead(MEMINFO_CANDIDATE_PATHS[0], meminfoBody(4_194_304)); // /host/proc: 4 GiB + arcFs.setRead(MEMINFO_CANDIDATE_PATHS[1], meminfoBody(1_048_576)); // /proc: 1 GiB + const result = await getHostMemory(); + // First candidate wins: 4 GiB balloon. + expect(result.ballooned).toBeGreaterThan(0); + expect(result.effectiveUsed).toBeDefined(); + }); + + it('skips an override path that is not a regular file', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/meminfo', { isFile: false, size: 10 }); + arcFs.setRead('/custom/meminfo', meminfoBody(100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); // override skipped, no meminfo on fixed + }); + + it('skips an override path that exceeds the size bound', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/meminfo', { isFile: true, size: 2 * 1024 * 1024 }); + arcFs.setRead('/custom/meminfo', meminfoBody(100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it.each([ + ['non-numeric value', 'Balloon: abc kB\n'], + ['negative value', 'Balloon: -100 kB\n'], + ['no kB suffix', 'Balloon: 100\n'], + ['wrong suffix', 'Balloon: 100 MB\n'], + ['extra token', 'Balloon: 100 kB extra\n'], + ])('treats a %s Balloon line as unusable and yields no balloon', async (_label, body) => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, body); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('fails open (balloon 0) on a read error', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setReadError(DEFAULT_MEMINFO_PATH, Object.assign(new Error('nope'), { code: 'EACCES' })); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it('logs an unexpected meminfo read error once per code', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Expected fs error: silent fall-through. + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[0], Object.assign(new Error('denied'), { code: 'EACCES' })); + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[1], Object.assign(new Error('denied'), { code: 'EACCES' })); + await getHostMemory(); + expect(warn).not.toHaveBeenCalled(); + + // Unexpected fs error: logged once. Use EBADF to avoid collision with + // the ARC test suite's own EIO trigger (loggedErrorCodes is shared). + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[0], Object.assign(new Error('badf'), { code: 'EBADF' })); + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[1], Object.assign(new Error('badf'), { code: 'EBADF' })); + await getHostMemory(); + await getHostMemory(); + const unexpectedLogs = warn.mock.calls.filter(([msg]) => String(msg).includes('EBADF')); + expect(unexpectedLogs).toHaveLength(1); + warn.mockRestore(); + }); +}); + +describe('memoryToWire', () => { + it('includes arcReclaimable when present on the HostMemory object', () => { + const wire = memoryToWire({ total: 1000, used: 400, free: 600, usagePercent: 40, arcReclaimable: 300 }); + expect(wire.arcReclaimable).toBe(300); + expect(wire.total).toBe(1000); + }); + + it('omits arcReclaimable when absent from the HostMemory object', () => { + const wire = memoryToWire({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + expect('arcReclaimable' in wire).toBe(false); + }); + + it('includes both arcReclaimable and balloon fields when both are present', () => { + const hostMem = adjustForBalloon( + { total: 16000, used: 10000, free: 6000, usagePercent: 62.5, arcReclaimable: 4000 }, + 2_147_483_648, // 2 GiB + ); + const wire = memoryToWire(hostMem); + expect(wire.arcReclaimable).toBe(4000); + expect(wire.ballooned).toBe(2_147_483_648); + expect(wire.effectiveUsed).toBeDefined(); + expect(wire.effectiveUsagePercent).toBeDefined(); + }); +}); + +describe('getHostMemory ARC surfacing end-to-end', () => { + it('surfaces arcReclaimable through memoryToWire with mocked arcstats', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 + const result = await getHostMemory(); + expect(result.arcReclaimable).toBe(200); + const wire = memoryToWire(result); + expect(wire.arcReclaimable).toBe(200); + }); + + it('surfaces both arcReclaimable and balloon fields through memoryToWire', async () => { + mockMem.mockResolvedValue(memSample(16000, 2000)); + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5000, 1000)); // reclaimable 4000 + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(2_097_152)); // 2 GiB balloon + const result = await getHostMemory(); + expect(result.arcReclaimable).toBe(4000); + const wire = memoryToWire(result); + expect(wire.arcReclaimable).toBe(4000); + expect(wire.ballooned).toBeGreaterThan(0); + expect(wire.effectiveUsed).toBeDefined(); + }); +}); + afterEach(() => { delete process.env.SENCHO_ZFS_ARCSTATS_PATH; + delete process.env.SENCHO_PROC_MEMINFO_PATH; }); diff --git a/backend/src/__tests__/image-updates-routes.test.ts b/backend/src/__tests__/image-updates-routes.test.ts index cf4019fe..1505e5eb 100644 --- a/backend/src/__tests__/image-updates-routes.test.ts +++ b/backend/src/__tests__/image-updates-routes.test.ts @@ -3,10 +3,13 @@ * Locks down auth, admin gating, rate limiting, and input validation * before extraction. */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; @@ -14,6 +17,24 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic let adminCookie: string; let viewerCookie: string; +/** Sign a JWT for an already-seeded user, using their live token_version. */ +function userToken(username: string): string { + const user = DatabaseService.getInstance().getUserByUsername(username); + if (!user) throw new Error(`missing test user ${username}`); + return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); +} + +/** Write a minimal on-disk stack so FileSystemService.getStacks() resolves it. */ +function makeOnDiskStack(name: string): void { + const composeDir = process.env.COMPOSE_DIR as string; + fs.mkdirSync(path.join(composeDir, name), { recursive: true }); + fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n'); +} + +function removeOnDiskStack(name: string): void { + fs.rmSync(path.join(process.env.COMPOSE_DIR as string, name), { recursive: true, force: true }); +} + beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); @@ -29,6 +50,12 @@ beforeAll(async () => { const viewerRes = await request(app).post('/api/auth/login').send({ username: 'iu-viewer', password: 'viewerpass' }); const cookies = viewerRes.headers['set-cookie'] as string | string[]; viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; + + const deployerHash = await bcrypt.hash('deployerpass', 1); + DatabaseService.getInstance().addUser({ username: 'iu-deployer', password_hash: deployerHash, role: 'deployer' }); + + const nodeAdminHash = await bcrypt.hash('nodeadminpass', 1); + DatabaseService.getInstance().addUser({ username: 'iu-node-admin', password_hash: nodeAdminHash, role: 'node-admin' }); }); afterAll(() => cleanupTestDb(tmpDir)); @@ -98,6 +125,135 @@ describe('POST /api/image-updates/refresh', () => { }); }); +describe('POST /api/image-updates/refresh/:stackName', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post('/api/image-updates/refresh/some-stack'); + expect(res.status).toBe(401); + }); + + it('rejects an invalid stack name with 400', async () => { + const res = await request(app) + .post(`/api/image-updates/refresh/${encodeURIComponent('bad name')}`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid stack name/); + }); + + it('rejects a role without stack:deploy with 403 PERMISSION_DENIED', async () => { + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Deployer to trigger a per-stack recheck', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!; + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ outcome: 'cleared', warning: null }); + expect(recheckSpy).toHaveBeenCalledWith(nodeId, 'per-stack-refresh'); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('returns 409 with enabled false when checks are disabled', async () => { + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0'); + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(res.status).toBe(409); + expect(res.body.enabled).toBe(false); + expect(res.body.error).toMatch(/disabled/i); + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1'); + }); + + describe('rate limit', () => { + beforeEach(async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + ImageUpdateService.getInstance().resetStackRecheckCooldowns(); + vi.useFakeTimers().setSystemTime(Date.now()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('rejects a second recheck within the cooldown window with 429', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const first = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(first.status).toBe(200); + + // Within the same cooldown window (2 min), a second call is denied. + vi.advanceTimersByTime(1_000); + const second = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(second.status).toBe(429); + expect(second.body.error).toMatch(/too recently/i); + expect(recheckSpy).toHaveBeenCalledTimes(1); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('allows a recheck after the cooldown window expires', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const first = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(first.status).toBe(200); + + // Advance past the 2-minute cooldown. + vi.advanceTimersByTime(2 * 60 * 1000 + 1); + const second = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(second.status).toBe(200); + expect(recheckSpy).toHaveBeenCalledTimes(2); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('enforces the rate limit independently per-stack', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const a1 = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(a1.status).toBe(200); + + // A different stack should not be rate-limited by the first. + const b1 = await request(app) + .post('/api/image-updates/refresh/other-stack') + .set('Cookie', adminCookie); + expect(b1.status).toBe(200); + expect(recheckSpy).toHaveBeenCalledTimes(2); + } finally { + recheckSpy.mockRestore(); + } + }); + }); +}); + describe('GET /api/image-updates/status', () => { it('rejects unauthenticated requests with 401', async () => { const res = await request(app).get('/api/image-updates/status'); @@ -309,11 +465,12 @@ describe('GET /api/image-updates/fleet', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { - // The cross-node aggregation is part of the admin-only readiness surface; - // the single-node GET / endpoint stays open for the sidebar update dot. + it('allows a non-admin authenticated user (auth-only, matching GET / and /detail)', async () => { + // The cross-node aggregation used to be admin-only; it now matches the + // auth-only read model shared with GET /, /detail, and /status. const res = await request(app).get('/api/image-updates/fleet').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(200); + expect(res.body).toBeInstanceOf(Object); }); it('returns the fleet-wide aggregation map', async () => { @@ -334,6 +491,22 @@ describe('POST /api/image-updates/fleet/refresh', () => { expect(res.status).toBe(403); }); + it('rejects a Deployer with 403 PERMISSION_DENIED (requires node:manage)', async () => { + const res = await request(app) + .post('/api/image-updates/fleet/refresh') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Node Admin (holds node:manage)', async () => { + const res = await request(app) + .post('/api/image-updates/fleet/refresh') + .set('Authorization', `Bearer ${userToken('iu-node-admin')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.triggered)).toBe(true); + }); + it('returns triggered/rateLimited/failed arrays for admin caller', async () => { const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie); expect(res.status).toBe(200); @@ -376,12 +549,125 @@ describe('POST /api/auto-update/execute', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('rejects a role without stack:deploy with 403 PERMISSION_DENIED', async () => { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Cookie', viewerCookie) + .send({ target: 'execute-authz-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects a role without stack:deploy on target="*" even when the node has no stacks', async () => { + // On a fresh test instance the "*" expansion resolves to zero stacks, which + // would otherwise short-circuit into a "no stacks found" 200 before any + // per-stack permission check has anything to iterate over. The wildcard + // case requires global stack:deploy up front specifically to close that gap. const res = await request(app) .post('/api/auto-update/execute') .set('Cookie', viewerCookie) .send({ target: '*' }); expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Deployer to execute a single-stack target', async () => { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`) + .send({ target: 'deployer-exec-stack' }); + expect(res.status).toBe(200); + expect(typeof res.body.result).toBe('string'); + }); + + it('denies a Deployer stripped of stack:deploy with 403 PERMISSION_DENIED', async () => { + const { ROLE_PERMISSIONS } = await import('../middleware/permissions'); + const original = ROLE_PERMISSIONS.deployer; + ROLE_PERMISSIONS.deployer = original.filter((p) => p !== 'stack:deploy'); + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`) + .send({ target: 'deployer-exec-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.deployer = original; + } + }); + + it('denies the whole bulk request when one target is unauthorized, with no partial execution', async () => { + // Scoped user: global viewer role (no stack:deploy anywhere) plus a + // deployer role assignment scoped to "bulk-allowed" only. Requesting + // ["bulk-allowed", "bulk-denied"] must deny the entire call on the second + // stack and never touch either stack's containers. + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('scopedpass', 1); + const scopedUserId = db.addUser({ username: 'iu-bulk-scoped', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'bulk-allowed', node_id: nodeId }); + + const DockerController = (await import('../services/DockerController')).default; + const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack'); + + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-bulk-scoped')}`) + .send({ targets: ['bulk-allowed', 'bulk-denied'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(containersSpy).not.toHaveBeenCalled(); + } finally { + containersSpy.mockRestore(); + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + } + }); + + it('rejects a role without stack:deploy with 403 even when checks are disabled node-wide', async () => { + // Permission must be evaluated before the checks-enabled setting is + // consulted: a disabled node must not let an unauthorized caller through + // to the "disabled; skipped" 200 that a legitimate caller would see. + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0'); + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Cookie', viewerCookie) + .send({ target: 'checks-disabled-authz-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1'); + } + }); + + it('denies target="*" for a scoped-only user even when their grant covers every stack on the node', async () => { + // A user with ONLY a scoped stack:deploy role_assignment (no global + // stack:deploy role) is denied the wildcard outright, even though the + // same grant would pass requireExactStacks if the caller enumerated the + // stack explicitly via targets instead of relying on "*" to expand it. + // This is the brief-sanctioned "deny without global deploy" tradeoff for + // the wildcard case. + makeOnDiskStack('wildcard-scoped-stack'); + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('scopedpass', 1); + const scopedUserId = db.addUser({ username: 'iu-wildcard-scoped', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'wildcard-scoped-stack', node_id: nodeId }); + + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-wildcard-scoped')}`) + .send({ target: '*' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + removeOnDiskStack('wildcard-scoped-stack'); + } }); it('serves a community-licensed admin (no paid gate)', async () => { diff --git a/backend/src/__tests__/managed-mesh-attachment.test.ts b/backend/src/__tests__/managed-mesh-attachment.test.ts new file mode 100644 index 00000000..c80fdf11 --- /dev/null +++ b/backend/src/__tests__/managed-mesh-attachment.test.ts @@ -0,0 +1,124 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseService } from '../services/DatabaseService'; +import SelfIdentityService from '../services/SelfIdentityService'; +import type { DependencyContainer } from '../services/DockerController'; +import { resolveManagedMeshAttachment } from '../services/network/managedMeshAttachment'; + +const originalDataDir = process.env.DATA_DIR; +const originalMode = process.env.SENCHO_MODE; +let tempDir: string | null = null; + +function runtimeContainer(overrides: Partial = {}): DependencyContainer { + return { + id: 'app-id', + name: 'app-web-1', + service: 'web', + composeProject: 'app', + stack: 'app', + state: 'running', + exitCode: null, + image: 'nginx:latest', + networks: [], + volumes: [], + ports: [], + ...overrides, + }; +} + +function stubAuthorities(meshStackEnabled: boolean, ownContainers: string[] = []): void { + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(meshStackEnabled), + } as unknown as DatabaseService); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + isOwnContainer: vi.fn((idOrName: string) => ownContainers.includes(idOrName)), + } as unknown as SelfIdentityService); +} + +afterEach(async () => { + vi.restoreAllMocks(); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + if (originalMode === undefined) delete process.env.SENCHO_MODE; + else process.env.SENCHO_MODE = originalMode; + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe('resolveManagedMeshAttachment', () => { + it('authorizes the canonical Mesh attachment for a centrally opted-in stack', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(true); + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(true); + expect(isManaged(runtimeContainer(), 'sencho_extra')).toBe(false); + }); + + it('authorizes the canonical Mesh attachment for the actual Sencho container', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(false, ['sencho-id']); + const isManaged = await resolveManagedMeshAttachment(1, 'sencho'); + + expect(isManaged(runtimeContainer({ id: 'sencho-id', name: 'sencho' }), 'sencho_mesh')).toBe(true); + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('keeps a manual Mesh attachment actionable for an opted-out stack', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(false); + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('uses Pilot override presence as the authoritative opt-in representation', async () => { + process.env.SENCHO_MODE = 'pilot'; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-mesh-drift-')); + process.env.DATA_DIR = tempDir; + const overrideDir = path.join(tempDir, 'mesh', 'overrides', '7'); + await fs.mkdir(overrideDir, { recursive: true }); + await fs.writeFile(path.join(overrideDir, 'app.override.yml'), 'services: {}\n'); + stubAuthorities(false); + + const isManaged = await resolveManagedMeshAttachment(7, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(true); + }); + + it('does not let stale server override presence supersede opted-out DB state', async () => { + process.env.SENCHO_MODE = 'server'; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-mesh-drift-')); + process.env.DATA_DIR = tempDir; + const overrideDir = path.join(tempDir, 'mesh', 'overrides', '1'); + await fs.mkdir(overrideDir, { recursive: true }); + await fs.writeFile(path.join(overrideDir, 'app.override.yml'), 'services: {}\n'); + stubAuthorities(false); + + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('fails closed when Mesh opt-in state cannot be read', async () => { + process.env.SENCHO_MODE = 'server'; + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + isOwnContainer: vi.fn().mockReturnValue(false), + } as unknown as SelfIdentityService); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + expect(warn).toHaveBeenCalledWith( + '[NetworkDrift] Could not verify Mesh opt-in state for %s:', + 'app', + 'database unavailable', + ); + }); +}); diff --git a/backend/src/__tests__/mesh-remove-override-remote.test.ts b/backend/src/__tests__/mesh-remove-override-remote.test.ts index 5fc15682..a027ea9a 100644 --- a/backend/src/__tests__/mesh-remove-override-remote.test.ts +++ b/backend/src/__tests__/mesh-remove-override-remote.test.ts @@ -96,7 +96,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { db.deleteNode(remoteNodeId); }); - it('swallows network errors from the remote so the disable cascade can continue', async () => { + it('rejects network errors so callers can preserve authoritative opt-in state', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); const remoteNodeId = db.addNode({ @@ -117,11 +117,37 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); - // Must not throw: the remote being offline is a tolerable condition; - // the cascade upstream uses Promise.allSettled and continues. - await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')).resolves.toBeUndefined(); + await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')).rejects.toThrow('ECONNREFUSED'); expect(warnSpy).toHaveBeenCalled(); db.deleteNode(remoteNodeId); }); + + it('rejects a non-success response from a busy remote target', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remove-override-busy-test', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'remote-tok', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'remote-tok', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('another operation is already in progress', { status: 500 }), + ); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')) + .rejects.toThrow('HTTP 500'); + + db.deleteNode(remoteNodeId); + }); }); diff --git a/backend/src/__tests__/mesh-route-gating.test.ts b/backend/src/__tests__/mesh-route-gating.test.ts index 9d1e79a2..0a3ff3e9 100644 --- a/backend/src/__tests__/mesh-route-gating.test.ts +++ b/backend/src/__tests__/mesh-route-gating.test.ts @@ -1,9 +1,9 @@ /** * Gate coverage for the mesh router. * - * Every /api/mesh route is tier-gated (requirePaid). The five operator - * mutations are additionally role-gated (requireAdmin): node enable/disable, - * stack opt-in/opt-out, and the override regen. The operator read routes + * Every /api/mesh route is tier-gated (requirePaid). Node enable/disable and + * override regeneration are permission-gated. Stack membership remains Admin-only + * because a membership change redeploys every affected mesh stack. The read routes * (status, aliases, activity, diagnostics) stay reachable for any paid-tier * user regardless of role, which is what lets a non-admin see a read-only * Routing tab. The node-to-node routes that central calls over the proxy on the @@ -103,17 +103,29 @@ describe('mesh read routes are visible to a non-admin paid user', () => { }); }); -describe('mesh mutation routes require the admin role (requireAdmin)', () => { - const mutationRoutes: { name: string; path: () => string }[] = [ +describe('mesh mutation authorization', () => { + const permissionRoutes: { name: string; path: () => string }[] = [ { name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' }, { name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` }, { name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` }, + ]; + const adminRoutes: { name: string; path: () => string }[] = [ { name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` }, { name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` }, ]; - for (const route of mutationRoutes) { - it(`${route.name} rejects a non-admin paid user with ADMIN_REQUIRED`, async () => { + for (const route of permissionRoutes) { + it(`${route.name} rejects a paid user without the required operational permission`, async () => { + const res = await request(app) + .post(route.path()) + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + } + + for (const route of adminRoutes) { + it(`${route.name} remains Admin-only`, async () => { const res = await request(app) .post(route.path()) .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index ea28d274..b4cdd0b7 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EventEmitter } from 'events'; import fsSync from 'fs'; +import fs from 'fs/promises'; import path from 'path'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { getSenchoIpFromSubnet, MeshError, type MeshTarget, type MeshTcpStreamLike } from '../services/MeshService'; @@ -20,6 +21,7 @@ afterAll(() => { }); beforeEach(() => { + process.env.SENCHO_MODE = 'server'; const db = DatabaseService.getInstance().getDb(); db.prepare('DELETE FROM mesh_stacks').run(); db.prepare('DELETE FROM nodes WHERE is_default = 0').run(); @@ -106,6 +108,84 @@ describe('MeshService.optInStack', () => { expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false); }); + it('restores opt-in authority when target override removal is rejected', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'busy-stack', 'setup'); + vi.spyOn(svc, 'removeOverrideFromNode').mockRejectedValue( + new Error('HTTP 500: another operation is already in progress'), + ); + + await expect(svc.optOutStack(localNodeId, 'busy-stack', 'tester')) + .rejects.toThrow('another operation is already in progress'); + + expect(db.isMeshStackEnabled(localNodeId, 'busy-stack')).toBe(true); + }); + + it('serializes concurrent opt-out requests for the same node', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'queued-stack', 'setup'); + let releaseRemoval!: () => void; + const removalPending = new Promise((resolve) => { + releaseRemoval = resolve; + }); + const removeSpy = vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending); + vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, 'regenerateOverridesAcrossFleet') + .mockResolvedValue(undefined); + vi.spyOn(svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, 'cascadeRecomposeAcrossFleet') + .mockImplementation(() => { /* noop */ }); + vi.spyOn(svc, 'triggerRedeploy').mockImplementation(() => { /* noop */ }); + + const first = svc.optOutStack(localNodeId, 'queued-stack', 'tester'); + await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledTimes(1)); + const second = svc.optOutStack(localNodeId, 'queued-stack', 'tester'); + await Promise.resolve(); + expect(removeSpy).toHaveBeenCalledTimes(1); + + releaseRemoval(); + await Promise.all([first, second]); + + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(db.isMeshStackEnabled(localNodeId, 'queued-stack')).toBe(false); + }); + + it('serializes different Mesh mutations per node without blocking another node', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'independent-node', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + db.setNodeMeshEnabled(localNodeId, true); + db.insertMeshStack(localNodeId, 'held-stack', 'setup'); + let releaseRemoval!: () => void; + const removalPending = new Promise((resolve) => { + releaseRemoval = resolve; + }); + vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending); + vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, 'regenerateOverridesAcrossFleet') + .mockResolvedValue(undefined); + vi.spyOn(svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, 'cascadeRecomposeAcrossFleet') + .mockImplementation(() => { /* noop */ }); + vi.spyOn(svc, 'triggerRedeploy').mockImplementation(() => { /* noop */ }); + + const optOut = svc.optOutStack(localNodeId, 'held-stack', 'tester'); + await vi.waitFor(() => expect(svc.removeOverrideFromNode).toHaveBeenCalledTimes(1)); + const disable = svc.disableForNode(localNodeId, 'tester'); + await svc.enableForNode(remoteNodeId); + + expect(db.getNodeMeshEnabled(localNodeId)).toBe(true); + expect(db.getNodeMeshEnabled(remoteNodeId)).toBe(true); + releaseRemoval(); + await Promise.all([optOut, disable]); + expect(db.getNodeMeshEnabled(localNodeId)).toBe(false); + db.deleteNode(remoteNodeId); + }); + it('rejects an invalid stack name (path traversal attempt)', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); @@ -427,6 +507,44 @@ describe('MeshService.disableForNode', () => { db.deleteNode(remoteNodeId); }); + it('keeps failed remote stacks authoritative when node disable is incomplete', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-partial-disable', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + db.setNodeMeshEnabled(remoteNodeId, true); + db.insertMeshStack(remoteNodeId, 'removed-stack', 'setup'); + db.insertMeshStack(remoteNodeId, 'busy-stack', 'setup'); + vi.spyOn(svc, 'removeOverrideFromNode').mockImplementation(async (_nodeId, stackName) => { + if (stackName === 'busy-stack') throw new Error('target busy'); + }); + vi.spyOn( + svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, + 'regenerateOverridesAcrossFleet', + ).mockResolvedValue(undefined); + vi.spyOn( + svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, + 'cascadeRecomposeAcrossFleet', + ).mockImplementation(() => { /* noop */ }); + const redeployed: string[] = []; + vi.spyOn(svc, 'triggerRedeploy').mockImplementation((_nodeId, stackName) => { + redeployed.push(stackName); + }); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.disableForNode(remoteNodeId, 'tester')).rejects.toThrow('busy-stack'); + + expect(db.getNodeMeshEnabled(remoteNodeId)).toBe(true); + expect(db.isMeshStackEnabled(remoteNodeId, 'removed-stack')).toBe(false); + expect(db.isMeshStackEnabled(remoteNodeId, 'busy-stack')).toBe(true); + expect(redeployed).toContain('removed-stack'); + db.deleteNode(remoteNodeId); + }); + it('defaults the actor when none is supplied so legacy callers still log a non-empty actor', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); @@ -673,6 +791,62 @@ describe('MeshService.optInStack rollback', () => { .rejects.toThrow(/simulated remote pilot offline/); expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false); }); + + it('retains remote authority when the push outcome is unknown', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'ambiguous-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc, 'pushOverrideToNode').mockRejectedValue(new Error('connection reset')); + + await expect(svc.optInStack(remoteNodeId, 'ambiguous-stack', 'tester')) + .rejects.toThrow('connection reset'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'ambiguous-stack')).toBe(true); + db.deleteNode(remoteNodeId); + }); + + it('treats a remote gateway error as an ambiguous push outcome', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'gateway-error-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc as unknown as { proxyFetch: () => Promise }, 'proxyFetch') + .mockResolvedValue(new Response('gateway timeout', { status: 502 })); + + await expect(svc.optInStack(remoteNodeId, 'gateway-error-stack', 'tester')) + .rejects.toThrow('HTTP 502'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'gateway-error-stack')).toBe(true); + db.deleteNode(remoteNodeId); + }); + + it('rolls back remote authority when the target explicitly rejects the push', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'rejected-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc, 'pushOverrideToNode') + .mockRejectedValue(new MeshError('push_failed', 'target rejected override')); + + await expect(svc.optInStack(remoteNodeId, 'rejected-stack', 'tester')) + .rejects.toThrow('target rejected override'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'rejected-stack')).toBe(false); + db.deleteNode(remoteNodeId); + }); }); describe('MeshService.optInStack guard rails (network setup)', () => { @@ -984,6 +1158,7 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { const db = DatabaseService.getInstance(); const localNodeId = db.getNodes()[0].id; + process.env.SENCHO_MODE = 'pilot'; // Simulate the pilot scenario: no mesh_stacks row (isMeshStackEnabled → false), // but the override file already exists on disk, pushed by central via D-1. const dataDir = process.env.DATA_DIR as string; @@ -1008,6 +1183,184 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { // Cleanup. fsSync.unlinkSync(overrideFile); + process.env.SENCHO_MODE = 'server'; + }); + + it('persists and removes proxy-target opt-in state with a pushed local override', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + + const file = await svc.applyLocalOverride('proxy-stack', []); + + expect(file).not.toBeNull(); + const yaml = fsSync.readFileSync(file as string, 'utf8'); + expect(yaml).toContain('web:'); + expect(yaml).toContain('sencho_mesh'); + expect(fsSync.readdirSync(path.dirname(file as string)).some((name) => name.includes('proxy-stack') && name.endsWith('.tmp'))).toBe(false); + expect(db.isMeshStackEnabled(localNodeId, 'proxy-stack')).toBe(true); + + await svc.removeLocalOverride('proxy-stack'); + + expect(db.isMeshStackEnabled(localNodeId, 'proxy-stack')).toBe(false); + }); + + it('does not write a proxy override when DB authority cannot be recorded', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(db, 'insertMeshStack').mockImplementation(() => { + throw new Error('database unavailable'); + }); + + await expect(svc.applyLocalOverride('db-failure', [])).rejects.toThrow('database unavailable'); + + const overrideFile = path.join( + process.env.DATA_DIR as string, + 'mesh', + 'overrides', + String(localNodeId), + 'db-failure.override.yml', + ); + expect(fsSync.existsSync(overrideFile)).toBe(false); + }); + + it('restores an existing override when DB authority cannot be recorded', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'db-replacement-failure.override.yml'); + const originalYaml = 'services:\n prior:\n networks:\n - sencho_mesh\n'; + fsSync.writeFileSync(overrideFile, originalYaml, 'utf8'); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(db, 'insertMeshStack').mockImplementation(() => { + throw new Error('database unavailable'); + }); + + await expect(svc.applyLocalOverride('db-replacement-failure', [])).rejects.toThrow('database unavailable'); + + expect(fsSync.readFileSync(overrideFile, 'utf8')).toBe(originalYaml); + expect(db.isMeshStackEnabled(localNodeId, 'db-replacement-failure')).toBe(false); + expect(fsSync.readdirSync(overrideDir).some((name) => name.includes('db-replacement-failure') && name.endsWith('.tmp'))).toBe(false); + }); + + it('does not publish DB authority or a final file when atomic override publication fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(fs, 'rename').mockRejectedValue(new Error('rename failed')); + + await expect(svc.applyLocalOverride('write-failure', [])).rejects.toThrow('rename failed'); + + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + expect(db.isMeshStackEnabled(localNodeId, 'write-failure')).toBe(false); + expect(fsSync.existsSync(path.join(overrideDir, 'write-failure.override.yml'))).toBe(false); + expect(fsSync.readdirSync(overrideDir).some((name) => name.includes('write-failure'))).toBe(false); + }); + + it('preserves the prior override and authority when atomic replacement fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'replacement-failure.override.yml'); + const originalYaml = 'services:\n prior:\n networks:\n - sencho_mesh\n'; + fsSync.writeFileSync(overrideFile, originalYaml, 'utf8'); + db.insertMeshStack(localNodeId, 'replacement-failure', 'tester'); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(fs, 'rename').mockRejectedValue(new Error('rename failed')); + + await expect(svc.applyLocalOverride('replacement-failure', [])).rejects.toThrow('rename failed'); + + expect(fsSync.readFileSync(overrideFile, 'utf8')).toBe(originalYaml); + expect(db.isMeshStackEnabled(localNodeId, 'replacement-failure')).toBe(true); + }); + + it('prevents overlapping override mutations for the same stack', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + let releaseServices!: (services: string[]) => void; + const servicesPending = new Promise((resolve) => { + releaseServices = resolve; + }); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockReturnValue(servicesPending); + + const first = svc.applyLocalOverride('concurrent-stack', []); + await vi.waitFor(() => expect(svc.getDeclaredStackServiceNames).toHaveBeenCalledTimes(1)); + + await expect(svc.applyLocalOverride('concurrent-stack', [])).rejects.toThrow('another operation'); + releaseServices(['web']); + await first; + + expect(db.isMeshStackEnabled(localNodeId, 'concurrent-stack')).toBe(true); + }); + + it('prevents removal from overlapping an in-flight override apply', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + let releaseServices!: (services: string[]) => void; + const servicesPending = new Promise((resolve) => { + releaseServices = resolve; + }); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockReturnValue(servicesPending); + + const apply = svc.applyLocalOverride('apply-remove-stack', []); + await vi.waitFor(() => expect(svc.getDeclaredStackServiceNames).toHaveBeenCalledTimes(1)); + + await expect(svc.removeLocalOverride('apply-remove-stack')).rejects.toThrow('another operation'); + releaseServices(['web']); + const file = await apply; + + expect(file).not.toBeNull(); + expect(fsSync.existsSync(file as string)).toBe(true); + expect(db.isMeshStackEnabled(localNodeId, 'apply-remove-stack')).toBe(true); + }); + + it('does not report committed removal as failed when alias refresh fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'refresh-failure.override.yml'); + fsSync.writeFileSync(overrideFile, 'services: {}\n', 'utf8'); + db.insertMeshStack(localNodeId, 'refresh-failure', 'setup'); + (svc as unknown as { pilotAliasOverlay: Map }).pilotAliasOverlay.set('refresh-failure', []); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.removeLocalOverride('refresh-failure')).resolves.toBeUndefined(); + + expect(fsSync.existsSync(overrideFile)).toBe(false); + expect(db.isMeshStackEnabled(localNodeId, 'refresh-failure')).toBe(false); + }); + + it('does not report committed apply as failed when alias refresh fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + const file = await svc.applyLocalOverride('apply-refresh-failure', [], [{ + host: 'web.example', nodeId: localNodeId, nodeName: 'local', stackName: 'apply-refresh-failure', + serviceName: 'web', port: 8080, + }]); + + expect(file).not.toBeNull(); + expect(fsSync.existsSync(file as string)).toBe(true); + expect(db.isMeshStackEnabled(localNodeId, 'apply-refresh-failure')).toBe(true); }); it('returns null for pilot nodes when no pushed override file exists', async () => { @@ -1015,9 +1368,38 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { const db = DatabaseService.getInstance(); const localNodeId = db.getNodes()[0].id; + process.env.SENCHO_MODE = 'pilot'; // No mesh_stacks row, no file on disk. const result = await svc.ensureStackOverride(localNodeId, 'no-such-stack'); expect(result).toBeNull(); + process.env.SENCHO_MODE = 'server'; + }); + + it('does not use stale override presence as authority on a server', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'stale-server.override.yml'); + fsSync.writeFileSync(overrideFile, 'services: {}\n', 'utf8'); + + const result = await svc.ensureStackOverride(localNodeId, 'stale-server'); + + expect(result).toBeNull(); + fsSync.unlinkSync(overrideFile); + }); + + it('restores proxy-target DB authority when override removal fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'unlink-failure', 'tester'); + vi.spyOn(fs, 'unlink').mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + + await expect(svc.removeLocalOverride('unlink-failure')).rejects.toThrow('permission denied'); + + expect(db.isMeshStackEnabled(localNodeId, 'unlink-failure')).toBe(true); }); }); diff --git a/backend/src/__tests__/mfa.test.ts b/backend/src/__tests__/mfa.test.ts index 1f702393..e96b8e2a 100644 --- a/backend/src/__tests__/mfa.test.ts +++ b/backend/src/__tests__/mfa.test.ts @@ -18,6 +18,7 @@ import { setupTestDb, cleanupTestDb, seedMfaUser, + seedMfaUserWithToken, TEST_USERNAME, TEST_JWT_SECRET, } from './helpers/setupTestDb'; @@ -568,6 +569,30 @@ describe('POST /api/users/:id/mfa/reset', () => { expect(resetRows).toHaveLength(1); expect(resetRows[0].summary).toBe(`Reset two-factor authentication: ${userId}`); }); + + it('invalidates target pre-reset JWT after admin MFA reset', async () => { + const { userId, token } = await seedMfaUserWithToken('victim4', 'victim4pass123'); + const adminJwt = adminToken(); // Pre-minted so we prove the admin session survives the reset. + const stacksWith = (jwt: string) => + request(app).get('/api/stacks').set('Authorization', `Bearer ${jwt}`); + + // Pre-reset JWT is accepted (viewer role grants stack:read). + expect((await stacksWith(token)).status).toBe(200); + + // Admin reset bumps the target's token_version, invalidating their sessions. + const reset = await request(app) + .post(`/api/users/${userId}/mfa/reset`) + .set('Authorization', `Bearer ${adminJwt}`); + expect(reset.status).toBe(200); + + // The same pre-reset JWT is now rejected. + const after = await stacksWith(token); + expect(after.status).toBe(401); + expect(after.body.error).toContain('Session invalidated'); + + // Admin session is untouched (only the target's token_version was bumped). + expect((await stacksWith(adminJwt)).status).toBe(200); + }); }); // ─── SSO bypass toggle ──────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/networking-summary.test.ts b/backend/src/__tests__/networking-summary.test.ts index caa3d011..b774b16b 100644 --- a/backend/src/__tests__/networking-summary.test.ts +++ b/backend/src/__tests__/networking-summary.test.ts @@ -45,6 +45,7 @@ describe('networking summary', () => { afterEach(() => { vi.restoreAllMocks(); DatabaseService.getInstance().deleteStackExposureIntents(1, STACK); + DatabaseService.getInstance().deleteMeshStack(1, STACK); fs.rmSync(stackDir, { recursive: true, force: true }); }); @@ -72,6 +73,41 @@ describe('networking summary', () => { expect(res.body.networkDrift.stacks).toContain(STACK); }); + it('does not count an opted-in Mesh attachment as network drift', async () => { + DatabaseService.getInstance().insertMeshStack(1, STACK, 'tester'); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ + containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', exitCode: null, image: 'nginx', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }], volumes: [], ports: [] }], + networks: [ + { id: 'm', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }, + ], + volumes: [], + }), + } as unknown as DockerController); + + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.networkDrift).toEqual({ count: 0, stacks: [] }); + }); + + it('counts an opted-out manual Mesh attachment as network drift', async () => { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ + containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', exitCode: null, image: 'nginx', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }], volumes: [], ports: [] }], + networks: [ + { id: 'm', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }, + ], + volumes: [], + }), + } as unknown as DockerController); + + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.networkDrift).toEqual({ count: 1, stacks: [STACK] }); + }); + it('still reports declared signals when the snapshot is unavailable (drift skipped)', async () => { vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')), diff --git a/backend/src/__tests__/operational-permission-matrix.test.ts b/backend/src/__tests__/operational-permission-matrix.test.ts new file mode 100644 index 00000000..a876ba04 --- /dev/null +++ b/backend/src/__tests__/operational-permission-matrix.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions'; +import { classifyStackApiPath } from '../helpers/stackRouteAuth'; + +describe('operational role matrix', () => { + /** + * Lockstep guard: every role's full permission set must match exactly. + * Any addition or removal to ROLE_PERMISSIONS must update this table; + * the full-set equality catches drift that a subset check would miss. + */ + const expectedRoleActions: Record = { + admin: [ + 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', + 'node:read', 'node:manage', + 'system:settings', 'system:users', 'system:license', 'system:webhooks', + 'system:tokens', 'system:console', 'system:audit', 'system:registries', + ], + 'node-admin': [ + 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', + 'node:read', 'node:manage', + ], + deployer: ['stack:read', 'stack:deploy'], + viewer: ['stack:read', 'node:read'], + auditor: ['stack:read', 'node:read', 'system:audit'], + }; + + for (const [role, expected] of Object.entries(expectedRoleActions)) { + it(`${role} has exactly the expected permission set`, () => { + const actual = [...(ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS] ?? [])].sort(); + expect(actual).toEqual([...expected].sort()); + }); + } +}); + +describe('named stack route permission inventory', () => { + const routes: Array<[string, string, PermissionAction]> = [ + ['GET', '/stacks/web', 'stack:read'], + ['GET', '/stacks/web/env', 'stack:read'], + ['GET', '/stacks/web/services', 'stack:read'], + ['GET', '/stacks/web/update-preview', 'stack:read'], + ['GET', '/stacks/web/files/content', 'stack:read'], + ['PUT', '/stacks/web', 'stack:edit'], + ['PUT', '/stacks/web/env', 'stack:edit'], + ['PUT', '/stacks/web/dossier', 'stack:edit'], + ['PUT', '/stacks/web/labels', 'stack:edit'], + ['POST', '/stacks/web/deploy', 'stack:deploy'], + ['POST', '/stacks/web/stop', 'stack:deploy'], + ['POST', '/stacks/web/services/api/update', 'stack:deploy'], + ['POST', '/stacks/web/rollback', 'stack:deploy'], + ['DELETE', '/stacks/web', 'stack:delete'], + ]; + + for (const [method, path, action] of routes) { + it(`${method} ${path} requires ${action}`, () => { + expect(classifyStackApiPath(method, path)).toEqual({ + kind: 'named-stack', + stackName: 'web', + action, + }); + }); + } +}); diff --git a/backend/src/__tests__/permissions-stack-rbac.test.ts b/backend/src/__tests__/permissions-stack-rbac.test.ts new file mode 100644 index 00000000..dc27241d --- /dev/null +++ b/backend/src/__tests__/permissions-stack-rbac.test.ts @@ -0,0 +1,254 @@ +/** + * Unit tests for checkPermission evidence + scopedActionsForStack with + * node-qualified stack grants. Uses mocked Request objects where possible. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { Request } from 'express'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { + checkPermission, + scopedActionsForStack, + type PermissionAction, +} from '../middleware/permissions'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let viewerId: number; +let defaultNodeId: number; +let otherNodeId: number; + +function mockReq(partial: { + userId: number; + role: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + username?: string; + nodeId?: number; + proxyTier?: 'paid' | 'community'; + scopedStackEvidence?: { + stackName: string; + actions: ReadonlySet; + }; +}): Request { + return { + user: { + username: partial.username ?? 'test-user', + role: partial.role, + userId: partial.userId, + }, + nodeId: partial.nodeId ?? defaultNodeId, + proxyTier: partial.proxyTier ?? 'paid', + scopedStackEvidence: partial.scopedStackEvidence, + } as Request; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + defaultNodeId = db.getDefaultNode()!.id!; + otherNodeId = db.addNode({ + name: 'perm-other-node', + type: 'remote', + api_url: 'http://192.168.1.60:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'perm-viewer', password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + const db = DatabaseService.getInstance(); + db.deleteRoleAssignmentsByUser(viewerId); + db.deleteUser(viewerId); + db.deleteNode(otherNodeId); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scopedActionsForStack', () => { + it('includes edit and deploy for a node-admin stack assignment', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'actions-stack', + node_id: defaultNodeId, + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'actions-stack'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).toContain('stack:read'); + expect(actions).toContain('stack:delete'); + expect(actions).toContain('stack:create'); + expect(actions).not.toContain('node:manage'); + expect(actions).not.toContain('system:users'); + expect(actions.every((a) => a.startsWith('stack:'))).toBe(true); + + expect(scopedActionsForStack(viewerId, otherNodeId, 'actions-stack')).toEqual([]); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'actions-stack'); + }); +}); + +describe('checkPermission with node-scoped stack grants', () => { + it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'deploy-me', + node_id: defaultNodeId, + }); + + const req = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:read', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'deploy-me')).toBe(false); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:deploy', 'stack', 'deploy-me')).toBe(false); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'deploy-me'); + }); + + it('node-scoped Node Admin authorizes stack actions on that node only', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const sameNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(sameNode, 'stack:edit', 'stack', 'any-stack')).toBe(true); + expect(checkPermission(sameNode, 'stack:deploy', 'stack', 'other-stack')).toBe(true); + expect(checkPermission(sameNode, 'node:manage', 'node', String(defaultNodeId))).toBe(true); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:edit', 'stack', 'any-stack')).toBe(false); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); + + it('uses an explicit target node for hub-orchestrated stack checks', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'remote-stack', + node_id: otherNodeId, + }); + + const hubRequest = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack')).toBe(false); + expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack', otherNodeId)).toBe(true); + expect(checkPermission(hubRequest, 'stack:edit', 'stack', 'remote-stack', otherNodeId)).toBe(false); + + db.deleteRoleAssignmentsByStack(otherNodeId, 'remote-stack'); + }); +}); + +describe('scopedActionsForStack with node-scoped grants', () => { + it('includes stack:* actions from a node-wide grant on the same node', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'fleet-wide'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).not.toContain('node:manage'); + expect(scopedActionsForStack(viewerId, otherNodeId, 'fleet-wide')).toEqual([]); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); + +describe('checkPermission scopedStackEvidence', () => { + it('allows when action is a member of the evidenced set for the same stack', () => { + // Use actions the global viewer role does not already grant so the + // evidence path is what authorizes (not ROLE_PERMISSIONS.viewer). + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'stack:edit']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(true); + }); + + it('denies when the action is absent from the evidenced set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:read']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(false); + }); + + it('denies when the stack name does not match evidence', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false); + }); + + it('ignores evidence for unscoped checks (no resourceType/resourceId)', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users']), + }, + }); + expect(checkPermission(req, 'stack:deploy')).toBe(false); + expect(checkPermission(req, 'system:users')).toBe(false); + }); + + it('does not honor non-stack actions even when present in the evidence set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users', 'node:manage']), + }, + }); + expect(checkPermission(req, 'system:users', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'node:manage', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/persona-fixture.test.ts b/backend/src/__tests__/persona-fixture.test.ts new file mode 100644 index 00000000..6c8693d5 --- /dev/null +++ b/backend/src/__tests__/persona-fixture.test.ts @@ -0,0 +1,56 @@ +/** + * Persona fixture smoke tests: every persona must authenticate and carry + * the correct permission set before any downstream test relies on it. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { + setupTestDb, + cleanupTestDb, +} from './helpers/setupTestDb'; +import { seedPersonas, FIVE_ROLES, type PersonaMap } from './fixtures/personas'; +import { ROLE_PERMISSIONS, checkPermission } from '../middleware/permissions'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('persona fixture integrity', () => { + let tmpDir: string; + let app: import('express').Express; + let personas: PersonaMap; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + personas = seedPersonas(DatabaseService.getInstance()); + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + for (const role of FIVE_ROLES) { + describe(`${role} persona`, () => { + it('authenticates on the auth-status endpoint', async () => { + const res = await request(app) + .get('/api/auth/status') + .set('Authorization', personas[role].bearer); + expect(res.status).toBe(200); + // Smoke: a 200 on /api/auth/status proves the JWT was accepted. + }); + + it('has the expected global permissions from ROLE_PERMISSIONS', () => { + const actions = ROLE_PERMISSIONS[role]; + expect(actions).toBeDefined(); + expect(actions).toContain('stack:read'); + }); + + it('checkPermission matches global role grants for each owned action', () => { + const p = personas[role]; + const actions = ROLE_PERMISSIONS[role]; + for (const action of actions) { + const req = { user: { username: p.username, role: p.role, userId: 1 } } as any; + expect(checkPermission(req, action)).toBe(true); + } + }); + }); + } +}); diff --git a/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts new file mode 100644 index 00000000..97a639ec --- /dev/null +++ b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts @@ -0,0 +1,105 @@ +/** + * Pilot-agent-mode header parity: the createRemoteProxyMiddleware code path + * for pilot_agent remotes is identical to proxy mode (the only difference is + * an empty apiToken from NodeRegistry.getProxyTarget). Stub the singleton so + * a pilot_agent node routes through a loopback capture server and assert the + * same PROXY_ROLE_HEADER is forwarded. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'http'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { PROXY_ROLE_HEADER } from '../services/license-headers'; +import { seedPersonas } from './fixtures/personas'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('pilot-agent-mode proxy role header parity', () => { + let tmpDir: string; + let app: import('express').Express; + let server: http.Server; + let captured: http.IncomingHttpHeaders | null = null; + let pilotNodeId: number; + let personas: ReturnType; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + personas = seedPersonas(DatabaseService.getInstance()); + + // Loopback capture server that advertises cross-node-rbac. + server = http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ version: '0.96.0', capabilities: ['cross-node-rbac'] })); + return; + } + captured = req.headers; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as import('net').AddressInfo).port; + + pilotNodeId = DatabaseService.getInstance().addNode({ + name: 'pilot-header-test', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + // getProxyTarget returns null for pilot_agent without a live bridge. + // Stub it to return the capture-server URL with an empty apiToken — the + // exact shape a real PilotTunnelBridge produces at runtime. + const { NodeRegistry } = await import('../services/NodeRegistry'); + const registry = NodeRegistry.getInstance(); + const orig = registry.getProxyTarget.bind(registry); + vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => { + if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' }; + return orig(nid); + }); + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await new Promise((resolve) => server.close(() => resolve())); + cleanupTestDb(tmpDir); + }); + + it('forwards the deployer session role through the pilot-agent proxy path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.deployer.bearer) + .set('x-node-id', String(pilotNodeId)); + expect(res.status).toBe(200); + // The capture server must have received the request. + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer'); + }); + + it('overwrites a smuggled admin header with the real deployer role on pilot path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.deployer.bearer) + .set('x-node-id', String(pilotNodeId)) + .set(PROXY_ROLE_HEADER, 'admin'); // smuggled + expect(res.status).toBe(200); + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer'); + }); + + it('forwards the admin session role through the pilot-agent proxy path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.admin.bearer) + .set('x-node-id', String(pilotNodeId)); + expect(res.status).toBe(200); + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('admin'); + }); +}); diff --git a/backend/src/__tests__/proxy-role-forwarding.test.ts b/backend/src/__tests__/proxy-role-forwarding.test.ts index 2a7ea3fa..22d1df35 100644 --- a/backend/src/__tests__/proxy-role-forwarding.test.ts +++ b/backend/src/__tests__/proxy-role-forwarding.test.ts @@ -24,22 +24,38 @@ import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADE let tmpDir: string; let app: import('express').Express; let viewerBearer: string; +let nodeAdminBearer: string; +let auditorBearer: string; +let deployerBearer: string; const VIEWER_USER = 'proxy-role-viewer'; +const NODE_ADMIN_USER = 'proxy-role-node-admin'; +const AUDITOR_USER = 'proxy-role-auditor'; +const DEPLOYER_USER = 'proxy-role-deployer'; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); const { DatabaseService } = await import('../services/DatabaseService'); const db = DatabaseService.getInstance(); - const hash = await bcrypt.hash('password123', 1); - db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' }); - const viewer = db.getUserByUsername(VIEWER_USER)!; - viewerBearer = jwt.sign( - { username: VIEWER_USER, role: 'viewer', tv: viewer.token_version }, - TEST_JWT_SECRET, - { expiresIn: '1m' }, - ); + + // Seed bearer tokens for each non-admin role, matching the original + // viewer pattern exactly to avoid drift. + const seed = (username: string, role: string): string => { + const hash = bcrypt.hashSync('password123', 1); + db.addUser({ username, password_hash: hash, role: role as any }); + const user = db.getUserByUsername(username)!; + return jwt.sign( + { username, role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + }; + + viewerBearer = seed(VIEWER_USER, 'viewer'); + deployerBearer = seed(DEPLOYER_USER, 'deployer'); + nodeAdminBearer = seed(NODE_ADMIN_USER, 'node-admin'); + auditorBearer = seed(AUDITOR_USER, 'auditor'); }); afterAll(() => { @@ -127,9 +143,65 @@ describe('authMiddleware - forwarded actor role (node_proxy)', () => { .set(PROXY_ROLE_HEADER, 'superadmin'); expect(res.status).toBe(200); }); + + // All five built-in roles: denied on admin-only, allowed on read-only. + const NON_ADMIN_ROLES = [ + { role: 'viewer', desc: 'viewer' }, + { role: 'deployer', desc: 'deployer' }, + { role: 'node-admin', desc: 'node-admin' }, + { role: 'auditor', desc: 'auditor' }, + ]; + + for (const { role, desc } of NON_ADMIN_ROLES) { + it(`denies admin-only route for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'node_proxy' }); + const res = await request(app) + .get(ADMIN_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(403); + }); + + it(`grants read access for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'node_proxy' }); + const res = await request(app) + .get(READ_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(200); + }); + + it(`applies same trust to pilot_tunnel for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'pilot_tunnel' }); + const res = await request(app) + .get(ADMIN_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(403); + }); + } }); describe('Security - actor-role header cannot be smuggled by a user session', () => { + // Verify each non-admin bearer token authenticates locally before testing + // proxy paths. Catches token-version drift between seeding and signing. + // Bearer variables are set in beforeAll; resolve lazily inside each test. + const BEARER_REFS: Array<{ name: string; get: () => string }> = [ + { name: 'viewer', get: () => viewerBearer }, + { name: 'deployer', get: () => deployerBearer }, + { name: 'node-admin', get: () => nodeAdminBearer }, + { name: 'auditor', get: () => auditorBearer }, + ]; + + for (const { name, get } of BEARER_REFS) { + it(`${name} token authenticates on /api/auth/status`, async () => { + const res = await request(app) + .get('/api/auth/status') + .set('Authorization', `Bearer ${get()}`); + expect(res.status).toBe(200); + }); + } + it('ignores the role header on a cookie session (uses the DB role)', async () => { const cookie = await loginAsTestAdmin(app); // A browser session that tries to downgrade itself (or, by the same path, @@ -235,4 +307,28 @@ describe('remote proxy gateway - actor role header forwarding', () => { expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual'); expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER); }); + + // Anti-smuggling extended to the remaining non-admin roles: each persona's + // real role must be forwarded even when an attacker sets the admin header. + // The bearer variables are set in beforeAll; resolve them lazily inside each + // test so we never capture undefined at describe-registration time. + const SMUGGLE_PERSONAS: Array<{ name: string; bearerRef: () => string; role: string }> = [ + { name: 'deployer', bearerRef: () => deployerBearer, role: 'deployer' }, + { name: 'node-admin', bearerRef: () => nodeAdminBearer, role: 'node-admin' }, + { name: 'auditor', bearerRef: () => auditorBearer, role: 'auditor' }, + ]; + + for (const { name, bearerRef, role } of SMUGGLE_PERSONAS) { + it(`overwrites smuggled admin header with ${name} session role`, async () => { + captured = null; + const res = await request(app) + .get(READ_ONLY) + .set('Authorization', `Bearer ${bearerRef()}`) + .set('x-node-id', String(remoteNodeId)) + .set(PROXY_ROLE_HEADER, 'admin'); + expect(res.status).toBe(200); + expect(captured?.[PROXY_ROLE_HEADER]).toBe(role); + }); + } }); + diff --git a/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts new file mode 100644 index 00000000..717a41c4 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts @@ -0,0 +1,386 @@ +/** + * Hub → remote proxy coverage for scoped stack-evidence forwarding to + * alerts, auto-heal, and node-wide image-refresh routes. Exercises the + * three new proxy gates in createRemoteProxyMiddleware through live + * loopback remotes. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_ROLE_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; +import { DatabaseService } from '../services/DatabaseService'; +import { classifyStackApiPath } from '../helpers/stackRouteAuth'; + +let tmpDir: string; +let app: import('express').Express; +let deployerBearer: string; +let deployerId: number; +let nodeAdminBearer: string; + +let grantedServer: http.Server; +let ungrantedServer: http.Server; +let noEvidenceServer: http.Server; +let grantedNodeId: number; +let ungrantedNodeId: number; +let noEvidenceNodeId: number; + +interface CapturedHop { + method: string; + url: string; + roleHeader: string | undefined; + stackNameHeader: string | undefined; + stackActionsHeader: string | undefined; +} + +const grantedHops: CapturedHop[] = []; +const ungrantedHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined, + stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined, + stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined, + }); +} + +function evidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, grantedHops); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); +} + +function noEvidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + + // Deployer (no global stack:edit; needs scoped evidence) + deployerId = db.addUser({ + username: 'proxy-evid-deployer', + password_hash: hash, + role: 'deployer', + }); + const deployerUser = db.getUserByUsername('proxy-evid-deployer')!; + deployerBearer = jwt.sign( + { username: 'proxy-evid-deployer', role: 'deployer', tv: deployerUser.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + // Node-admin (has global stack:edit; should skip gates) + db.addUser({ + username: 'proxy-evid-nodeadmin', + password_hash: hash, + role: 'node-admin', + }); + const naUser = db.getUserByUsername('proxy-evid-nodeadmin')!; + nodeAdminBearer = jwt.sign( + { username: 'proxy-evid-nodeadmin', role: 'node-admin', tv: naUser.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + grantedServer = evidenceRemote(); + ungrantedServer = evidenceRemote(); + noEvidenceServer = noEvidenceRemote(); + const grantedPort = await listen(grantedServer); + const ungrantedPort = await listen(ungrantedServer); + const noEvidencePort = await listen(noEvidenceServer); + + grantedNodeId = db.addNode({ + name: 'evidence-granted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${grantedPort}`, + api_token: 'granted-token', + }); + ungrantedNodeId = db.addNode({ + name: 'evidence-ungranted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${ungrantedPort}`, + api_token: 'ungranted-token', + }); + noEvidenceNodeId = db.addNode({ + name: 'evidence-noevidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${noEvidencePort}`, + api_token: 'noev-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => grantedServer.close(() => resolve())); + await new Promise((resolve) => ungrantedServer.close(() => resolve())); + await new Promise((resolve) => noEvidenceServer.close(() => resolve())); + cleanupTestDb(tmpDir); +}); + +beforeEach(async () => { + vi.restoreAllMocks(); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + grantedHops.length = 0; + ungrantedHops.length = 0; +}); + +function grantScopedStackEdit(userId: number, nodeId: number, stackName: string): void { + // node-admin role grants stack:edit (plus all stack permissions). + // Scoped to a single stack so the user only gets stack:edit on that stack. + DatabaseService.getInstance().addRoleAssignment({ + user_id: userId, + role: 'node-admin', + resource_type: 'stack', + resource_id: stackName, + node_id: nodeId, + }); +} + +function grantScopedNodeManage(userId: number, nodeId: number): void { + DatabaseService.getInstance().addRoleAssignment({ + user_id: userId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(nodeId), + }); +} + +function clearAssignments(userId: number): void { + DatabaseService.getInstance().deleteRoleAssignmentsByUser(userId); +} + +describe('remote proxy alerts scoped-evidence gate', () => { + it('forwards scoped stack:edit evidence for scoped deployer on POST /alerts', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/api/alerts')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('web'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + clearAssignments(deployerId); + }); + + it('denies scoped deployer on ungranted stack for POST /alerts', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ stack_name: 'other-stack', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + clearAssignments(deployerId); + }); + + it('denies when remote lacks scoped-stack-auth-evidence capability', async () => { + grantScopedStackEdit(deployerId, noEvidenceNodeId, 'web'); + + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(noEvidenceNodeId)) + .send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('does not support scoped stack authorization'); + + clearAssignments(deployerId); + }); + + it('passes through without evidence for node-admin (global stack:edit)', async () => { + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${nodeAdminBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/api/alerts')); + expect(hop).toBeDefined(); + // Node-admin global role grants stack:edit; no evidence needed. + expect(hop!.stackNameHeader).toBeUndefined(); + }); +}); + +describe('remote proxy auto-heal scoped-evidence gate', () => { + it('forwards scoped stack:edit evidence for scoped deployer on POST /auto-heal/policies', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ stack_name: 'web', service_name: 'app', unhealthy_duration_mins: 5, cooldown_mins: 5, max_restarts_per_hour: 3, auto_disable_after_failures: 5 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/api/auto-heal')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('web'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + clearAssignments(deployerId); + }); + + it('rejects compressed body with 415', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Encoding', 'gzip') + .send(Buffer.alloc(32)); + + expect(res.status).toBe(415); + expect(res.body.code).toBe('encoding_unsupported'); + + clearAssignments(deployerId); + }); + + it('denies when body is unparseable (fail closed)', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Type', 'application/json') + .send('not json'); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('not valid JSON'); + + clearAssignments(deployerId); + }); +}); + +describe('remote proxy node-wide image refresh elevation gate', () => { + it('elevates role to node-admin for scoped node-manager on POST /image-updates/refresh', async () => { + grantScopedNodeManage(deployerId, grantedNodeId); + + const res = await request(app) + .post('/api/image-updates/refresh') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/api/image-updates/refresh')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + clearAssignments(deployerId); + }); + + it('denies scoped node-manager on ungranted node', async () => { + grantScopedNodeManage(deployerId, grantedNodeId); + + const res = await request(app) + .post('/api/image-updates/refresh') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(ungrantedNodeId)); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + clearAssignments(deployerId); + }); + + it('does not elevate role header for POST /image-updates/fleet/refresh', async () => { + // /fleet/refresh is the separate fleet-wide fan-out route; must not be + // matched by the isImageRefreshNodeWide predicate. + grantScopedNodeManage(deployerId, grantedNodeId); + + await request(app) + .post('/api/image-updates/fleet/refresh') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)); + + // The route is requireAdmin on main today, but our gate must not interfere + // regardless. The role header should carry the caller's real role, not + // an elevated one (since the predicate excludes fleet/refresh). + const hop = grantedHops.find((h) => h.url?.includes('/api/image-updates/fleet')); + if (hop) { + expect(hop.roleHeader).not.toBe('node-admin'); + } + + clearAssignments(deployerId); + }); +}); + +describe('classifyStackApiPath per-stack image refresh', () => { + it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => { + const result = classifyStackApiPath('POST', '/image-updates/refresh/web'); + expect(result.kind).toBe('named-stack'); + if (result.kind === 'named-stack') { + expect(result.stackName).toBe('web'); + expect(result.action).toBe('stack:deploy'); + } + }); + + it('classifies POST /image-updates/refresh (no stack name) as static', () => { + const result = classifyStackApiPath('POST', '/image-updates/refresh'); + expect(result.kind).toBe('static'); + }); +}); diff --git a/backend/src/__tests__/proxy-scoped-settings-authz.test.ts b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts new file mode 100644 index 00000000..2262dd13 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts @@ -0,0 +1,343 @@ +/** + * Hub → remote proxy coverage for scoped node-admin Settings writes. + * Exercises the settings pre-authorization gate in createRemoteProxyMiddleware + * through live loopback remotes. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { PROXY_ROLE_HEADER } from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let viewerId: number; + +let grantedServer: http.Server; +let ungrantedServer: http.Server; +let grantedNodeId: number; +let ungrantedNodeId: number; + +interface CapturedHop { + method: string; + url: string; + roleHeader: string | undefined; +} +const grantedHops: CapturedHop[] = []; +const ungrantedHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined, + }); +} + +function grantedRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + captureHop(req, grantedHops); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); +} + +function ungrantedRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + captureHop(req, ungrantedHops); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ + username: 'settings-scoped-viewer', + password_hash: hash, + role: 'viewer', + }); + const viewer = db.getUserByUsername('settings-scoped-viewer')!; + viewerBearer = jwt.sign( + { username: 'settings-scoped-viewer', role: 'viewer', tv: viewer.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + grantedServer = grantedRemote(); + ungrantedServer = ungrantedRemote(); + const grantedPort = await listen(grantedServer); + const ungrantedPort = await listen(ungrantedServer); + + grantedNodeId = db.addNode({ + name: 'settings-granted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${grantedPort}`, + api_token: 'granted-token', + }); + ungrantedNodeId = db.addNode({ + name: 'settings-ungranted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${ungrantedPort}`, + api_token: 'ungranted-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => grantedServer.close(() => resolve())); + await new Promise((resolve) => ungrantedServer.close(() => resolve())); + cleanupTestDb(tmpDir); +}); + +beforeEach(async () => { + vi.restoreAllMocks(); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + grantedHops.length = 0; + ungrantedHops.length = 0; +}); + +describe('remote proxy scoped node-admin settings writes', () => { + it('allows scoped node-admin on granted remote node and elevates role header', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies scoped node-admin on ungranted remote node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(ungrantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies viewer with no scoped grant on any remote node', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('denies empty-body PATCH from viewer with no grant (fail-closed)', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows empty-body PATCH from scoped node-admin on granted node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({}); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies mixed node:manage + system:settings PATCH from scoped node-admin', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85, developer_mode: '1' }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('rejects compressed settings body with 415', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Encoding', 'gzip') + .send(Buffer.from('compressed')); + + expect(res.status).toBe(415); + expect(res.body.code).toBe('encoding_unsupported'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('rejects oversized settings body with 413', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + // Build a body larger than 100 KB + const bigValue = 'x'.repeat(102 * 1024); + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Length', String(bigValue.length + 30)) + .send(Buffer.from(bigValue)); + + expect(res.status).toBe(413); + expect(res.body.code).toBe('entity_too_large'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('allows global node-admin to write on remote without elevation gate', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + // The viewer already has role viewer; create a global node-admin + const nodeAdminHash = await bcrypt.hash('nodeadmin123', 1); + const nodeAdminId = db.addUser({ + username: 'settings-global-na', + password_hash: nodeAdminHash, + role: 'node-admin', + }); + const nodeAdmin = db.getUserByUsername('settings-global-na')!; + const nodeAdminBearer = jwt.sign( + { username: 'settings-global-na', role: 'node-admin', tv: nodeAdmin.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${nodeAdminBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 90 }); + + expect(res.status).toBe(200); + + db.deleteUser(nodeAdminId); + }); + + it('allows scoped node-admin POST single key on granted node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .post('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ key: 'host_cpu_limit', value: 95 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); +}); diff --git a/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts new file mode 100644 index 00000000..06d3dc27 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts @@ -0,0 +1,327 @@ +/** + * Orchestrated hub → remote proxy coverage for scoped stack elevation and + * DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through + * live loopback remotes (not helper-only unit tests). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let viewerId: number; +let evidenceServer: http.Server; +let noEvidenceServer: http.Server; +let failDeleteServer: http.Server; +let wrongNodeServer: http.Server; +let evidenceNodeId: number; +let noEvidenceNodeId: number; +let failDeleteNodeId: number; +let wrongNodeId: number; + +interface CapturedHop { + method: string; + url: string; + stackNameHeader: string | undefined; + stackActionsHeader: string | undefined; +} + +const evidenceHops: CapturedHop[] = []; +const failDeleteHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined, + stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined, + }); +} + +function evidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, evidenceHops); + if (req.method === 'DELETE') { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function noEvidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function failDeleteRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, failDeleteHops); + if (req.method === 'DELETE') { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream delete failed' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' }); + const viewer = db.getUserByUsername('scoped-proxy-viewer')!; + viewerBearer = jwt.sign( + { username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + evidenceServer = evidenceRemote(); + noEvidenceServer = noEvidenceRemote(); + failDeleteServer = failDeleteRemote(); + wrongNodeServer = evidenceRemote(); + const evidencePort = await listen(evidenceServer); + const noEvidencePort = await listen(noEvidenceServer); + const failDeletePort = await listen(failDeleteServer); + const wrongNodePort = await listen(wrongNodeServer); + + evidenceNodeId = db.addNode({ + name: 'evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${evidencePort}`, + api_token: 'evidence-token', + }); + noEvidenceNodeId = db.addNode({ + name: 'no-evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${noEvidencePort}`, + api_token: 'no-evidence-token', + }); + failDeleteNodeId = db.addNode({ + name: 'fail-delete-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${failDeletePort}`, + api_token: 'fail-delete-token', + }); + wrongNodeId = db.addNode({ + name: 'wrong-node-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${wrongNodePort}`, + api_token: 'wrong-node-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => evidenceServer.close(() => resolve())); + await new Promise((resolve) => noEvidenceServer.close(() => resolve())); + await new Promise((resolve) => failDeleteServer.close(() => resolve())); + await new Promise((resolve) => wrongNodeServer.close(() => resolve())); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('remote proxy scoped stack evidence and DELETE cleanup', () => { + it('elevates a matching stack grant and forwards bound evidence headers', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('shared-name'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).not.toContain('node:manage'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies the same stack name on a node without a grant', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(wrongNodeId)); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'needs-evidence', + node_id: noEvidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/needs-evidence/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(noEvidenceNodeId)); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/scoped stack authorization/i); + + db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence'); + }); + + it('clears the hub grant tuple after a successful remote DELETE', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'doomed', + node_id: evidenceNodeId, + }); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(1); + + const res = await request(app) + .delete('/api/stacks/doomed') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(204); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(0); + }); + + it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'keep-me', + node_id: failDeleteNodeId, + }); + + const res = await request(app) + .delete('/api/stacks/keep-me') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(failDeleteNodeId)); + + expect(res.status).toBe(500); + expect( + db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId), + ).toHaveLength(1); + + db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me'); + }); + + it('builds evidence from a node-scoped grant on the target node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(evidenceNodeId), + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/node-wide-stack/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('node-wide-stack'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); diff --git a/backend/src/__tests__/prune-plan.test.ts b/backend/src/__tests__/prune-plan.test.ts index 5c4fc81d..0a504a1a 100644 --- a/backend/src/__tests__/prune-plan.test.ts +++ b/backend/src/__tests__/prune-plan.test.ts @@ -105,6 +105,168 @@ describe('normalizePruneTargets', () => { }); describe('DockerController.buildPrunePlan', () => { + it('projects target metadata and only safe ownership labels in all scope', async () => { + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:dangling', + RepoTags: [':'], + RepoDigests: ['example/app@sha256:digest'], + Created: 1_700_000_000, + Size: 100, + Containers: 0, + Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' }, + }]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ + Name: 'my-stack_data', + Driver: 'local', + Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' }, + }] }); + mockDocker.listNetworks.mockResolvedValue([{ + Id: 'network-id', + Name: 'my-stack_default', + Driver: 'bridge', + Scope: 'local', + Labels: { + 'com.docker.compose.project': 'my-stack', + 'com.docker.compose.network': 'default', + secret: 'do-not-return', + }, + }]); + mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 42 } }], + Images: [{ Id: 'sha256:dangling', SharedSize: 10 }], + LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan( + ['images', 'volumes', 'networks'], 'all', ['my-stack'], 1, + ); + + expect(plan.items.find((entry) => entry.target === 'images')).toMatchObject({ + name: ':', + managed: true, + stackName: 'my-stack', + image: { + references: [], + digest: 'example/app@sha256:digest', + createdAt: 1_700_000_000, + }, + }); + expect(plan.items.find((entry) => entry.target === 'volumes')).toMatchObject({ + managed: true, + stackName: 'my-stack', + volume: { + driver: 'local', + ownershipLabels: { 'com.docker.compose.project': 'my-stack' }, + }, + }); + expect(plan.items.find((entry) => entry.target === 'networks')).toMatchObject({ + managed: true, + stackName: 'my-stack', + network: { + driver: 'bridge', + scope: 'local', + ownershipLabels: { + 'com.docker.compose.project': 'my-stack', + 'com.docker.compose.network': 'default', + }, + }, + }); + expect(JSON.stringify(plan.items)).not.toContain('do-not-return'); + expect(plan.reclaimableBytes).toBe( + plan.items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0), + ); + }); + + it('uses Compose path ownership fallbacks for non-container resources', async () => { + const ownershipLabels = { + 'com.docker.compose.project.working_dir': '/app/compose/my-stack', + 'com.docker.compose.project.config_files': '/app/compose/my-stack/compose.yml', + }; + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:path-owned', RepoTags: ['example/path:latest'], Size: 100, Containers: 0, Labels: ownershipLabels, + }]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: ownershipLabels }] }); + mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: ownershipLabels }]); + mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }], + Images: [{ Id: 'sha256:path-owned', SharedSize: 0 }], + LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan( + ['images', 'volumes', 'networks'], 'managed', ['my-stack'], 1, + ); + + expect(plan.items).toHaveLength(3); + expect(plan.items.every((entry) => entry.managed && entry.stackName === 'my-stack')).toBe(true); + }); + + it.each(['__proto__', 'constructor', 'toString', 'prototype'])( + 'does not attribute inherited project key %s to a managed stack', + async (project) => { + const labels = { 'com.docker.compose.project': project }; + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: `${project}_data`, Labels: labels }] }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: `${project}_data`, UsageData: { RefCount: 0, Size: 42 } }], + Images: [], + LayersSize: 0, + }); + + const managedPlan = await DockerController.getInstance(1).buildPrunePlan( + ['volumes'], 'managed', ['my-stack'], 1, + ); + const allPlan = await DockerController.getInstance(1).buildPrunePlan( + ['volumes'], 'all', ['my-stack'], 1, + ); + + expect(managedPlan.items).toEqual([]); + expect(allPlan.items).toEqual([ + expect.objectContaining({ id: `${project}_data`, managed: false }), + ]); + expect(allPlan.items[0].stackName).toBeUndefined(); + }, + ); + + it('does not plan an image referenced by a container when Docker reports Containers as unknown', async () => { + mockDocker.listContainers.mockResolvedValue([{ Id: 'container', ImageID: 'sha256:in-use' }]); + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:in-use', RepoTags: ['example/in-use:latest'], Size: 100, Containers: -1, + }]); + mockDocker.df.mockResolvedValue({ + Volumes: [], Images: [{ Id: 'sha256:in-use', SharedSize: 0 }], LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan(['images'], 'all', [], 1); + expect(plan.items).toEqual([]); + }); + + it('preserves Compose path ownership fallback during volume and network execution', async () => { + const labels = { 'com.docker.compose.project.working_dir': '/app/compose/my-stack' }; + const volumeRemove = vi.fn().mockResolvedValue(undefined); + const networkRemove = vi.fn().mockResolvedValue(undefined); + const networkInspect = vi.fn().mockResolvedValue({ Name: 'path_default', Labels: labels, Containers: {} }); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: labels }] }); + mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: labels }]); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }], Images: [], LayersSize: 0, + }); + mockDocker.getVolume.mockReturnValue({ remove: volumeRemove }); + mockDocker.getNetwork.mockReturnValue({ inspect: networkInspect, remove: networkRemove }); + + const controller = DockerController.getInstance(1); + const plan = await controller.buildPrunePlan(['volumes', 'networks'], 'managed', ['my-stack'], 1); + const result = await controller.executePrunePlan(plan, ['my-stack']); + + expect(result.outcomes).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: 'volumes', status: 'removed' }), + expect.objectContaining({ target: 'networks', status: 'removed' }), + ])); + expect(volumeRemove).toHaveBeenCalledWith({ force: false }); + expect(networkRemove).toHaveBeenCalled(); + }); + it('enumerates managed stopped containers and never calls pruneSystem', async () => { mockDocker.listContainers.mockResolvedValue([ { diff --git a/backend/src/__tests__/rbac-regression.test.ts b/backend/src/__tests__/rbac-regression.test.ts new file mode 100644 index 00000000..d5d2367f --- /dev/null +++ b/backend/src/__tests__/rbac-regression.test.ts @@ -0,0 +1,112 @@ +/** + * RBAC regression coverage: MFA reset gating, SSO user permission parity, + * and scoped-evidence fail-closed on remote. + * + * Tests for last-admin protection, immediate role-change effect, and + * API-token scope isolation are already covered by users-rbac.test.ts, + * api-tokens.test.ts, and api-token-ws-scope.test.ts respectively. + * + * Single describe + single setupTestDb()/cleanupTestDb() pair because the + * DatabaseService singleton is lazy-constructed on first getInstance() and + * never re-initializes; a second setupTestDb() after a first cleanupTestDb() + * would reuse a stale handle pointing at a deleted file, tripping + * SQLITE_READONLY_DBMOVED on Linux. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { + setupTestDb, + cleanupTestDb, + TEST_JWT_SECRET, +} from './helpers/setupTestDb'; +import { checkPermission } from '../middleware/permissions'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('RBAC regression coverage', () => { + let tmpDir: string; + let app: import('express').Express; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + // ── MFA reset gating ──────────────────────────────────────────── + // POST /:id/mfa/reset requires system:users (admin only). + const TARGET_ID = 1; // Baseline admin is id 1, seeded by globalSetup. + + it('rejects unauthenticated MFA reset (401)', async () => { + const res = await request(app).post(`/api/users/${TARGET_ID}/mfa/reset`); + expect(res.status).toBe(401); + }); + + it('rejects non-admin from resetting MFA (403)', async () => { + const db = DatabaseService.getInstance(); + const bcrypt = await import('bcrypt'); + const hash = await bcrypt.default.hash('password123', 1); + db.addUser({ username: 'mfa-viewer', password_hash: hash, role: 'viewer' }); + const user = db.getUserByUsername('mfa-viewer')!; + const token = jwt.sign( + { username: 'mfa-viewer', role: 'viewer', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const res = await request(app) + .post(`/api/users/${TARGET_ID}/mfa/reset`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + }); + + // ── SSO user permission parity ────────────────────────────────── + + it('grants same permissions as a local user of the same role', () => { + const db = DatabaseService.getInstance(); + db.addUser({ + username: 'sso-node-admin', + password_hash: '$sso$fake', + role: 'node-admin', + auth_provider: 'oidc_google', + provider_id: 'google-456', + email: 'sso-na@test.com', + }); + const ssoUser = db.getUserByUsername('sso-node-admin')!; + + // permissions.ts never branches on auth_provider; only role matters. + const req = { + user: { username: ssoUser.username, role: ssoUser.role, userId: ssoUser.id }, + } as any; + + expect(checkPermission(req, 'stack:read')).toBe(true); + expect(checkPermission(req, 'stack:edit')).toBe(true); + expect(checkPermission(req, 'stack:deploy')).toBe(true); + expect(checkPermission(req, 'node:manage')).toBe(true); + expect(checkPermission(req, 'system:settings')).toBe(false); + expect(checkPermission(req, 'system:users')).toBe(false); + }); + + // ── Scoped evidence fail-closed ───────────────────────────────── + + it('denies a scoped-only user when scopedStackEvidence is absent', () => { + const remoteReq = { + user: { username: 'node-proxy', role: 'viewer', userId: 0 }, + scopedStackEvidence: undefined, + } as any; + expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(false); + }); + + it('allows the same action when evidence is present and matches', () => { + const remoteReq = { + user: { username: 'node-proxy', role: 'viewer', userId: 0 }, + scopedStackEvidence: { + stackName: 'web', + actions: new Set(['stack:edit', 'stack:deploy']), + }, + } as any; + expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/recovery-held-images.test.ts b/backend/src/__tests__/recovery-held-images.test.ts new file mode 100644 index 00000000..d64191ad --- /dev/null +++ b/backend/src/__tests__/recovery-held-images.test.ts @@ -0,0 +1,83 @@ +/** + * Unit tests for the unified held-image predicate's fail-closed composition: + * a lookup failure on either underlying service must protect every image, + * not just the ones the other service happens to hold. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { buildUnifiedHeldImagePredicate } from '../services/recoveryHeldImages'; +import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('buildUnifiedHeldImagePredicate', () => { + it('holds an image present in either service\'s held set', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:service-held'])); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:stack-held')).toBe(true); + expect(predicate('sha256:service-held')).toBe(true); + expect(predicate('sha256:unrelated')).toBe(false); + }); + + it('fails closed (protects every image) when StackUpdateRecoveryService.getHeldImageIds returns null', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); + + it('fails closed (protects every image) when ServiceUpdateRecoveryService.getHeldImageIds returns null', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); +}); + +/** + * The prune routes (/system/prune/plan, /system/prune/system) build their + * predicate via ServiceUpdateRecoveryService.buildHeldImagePredicate, not the + * module function directly. That method delegates to the shared module, so a + * full-stack rollback hold must gate prune too, not just service-scoped holds. + */ +describe('ServiceUpdateRecoveryService.buildHeldImagePredicate (the prune-path entry point)', () => { + it('protects a full-stack rollback hold, not just service-scoped holds', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + + expect(predicate('sha256:stack-held')).toBe(true); + expect(predicate('sha256:unrelated')).toBe(false); + }); + + it('re-reads the held set on every call so a hold taken after plan time still gates the delete', () => { + const stackSpy = vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + expect(predicate('sha256:late-hold')).toBe(false); + + // A generation is captured between plan and delete. + stackSpy.mockReturnValue(new Set(['sha256:late-hold'])); + expect(predicate('sha256:late-hold')).toBe(true); + }); + + it('fails closed on the prune path when a held lookup fails', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/role-assignments-node-qualified.test.ts b/backend/src/__tests__/role-assignments-node-qualified.test.ts new file mode 100644 index 00000000..4fdd7cf6 --- /dev/null +++ b/backend/src/__tests__/role-assignments-node-qualified.test.ts @@ -0,0 +1,430 @@ +/** + * migrateRoleAssignmentsNodeQualified: legacy rebuild, default remap, + * no-default omit, sqlite_master idempotency probe, unique indexes, + * deleteNode stack-grant cleanup, preserved ids/timestamps. + */ +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; + +function resetDatabaseSingleton(): void { + const holder = DatabaseService as unknown as { instance?: DatabaseService }; + const existing = holder.instance; + if (existing) { + try { + existing.getDb().close(); + } catch { + // already closed + } + holder.instance = undefined; + } +} + +type IndexRow = { name: string; sql: string | null }; +type AssignmentRow = { + id: number; + user_id: number; + role: string; + resource_type: string; + resource_id: string; + node_id: number | null; + created_at: number; +}; + +function roleAssignmentsTableSql(raw: Database.Database): string { + return ( + (raw.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'", + ).get() as { sql: string } | undefined)?.sql ?? '' + ); +} + +function roleAssignmentIndexes(raw: Database.Database): Map { + const rows = raw.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'", + ).all() as IndexRow[]; + return new Map(rows.map((r) => [r.name, r.sql ?? ''])); +} + +/** Rewrite role_assignments to the pre-node_id schema and seed legacy rows. */ +function seedLegacyRoleAssignments( + dbPath: string, + seed: { + userId: number; + stackRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + nodeRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + }, +): void { + const raw = new Database(dbPath); + try { + raw.exec('PRAGMA foreign_keys = OFF'); + raw.exec('DROP TABLE IF EXISTS role_assignments'); + raw.exec('DROP TABLE IF EXISTS role_assignments_new'); + raw.exec(` + CREATE TABLE role_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource + ON role_assignments(resource_type, resource_id); + `); + const insert = raw.prepare(` + INSERT INTO role_assignments (id, user_id, role, resource_type, resource_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const row of seed.stackRows) { + insert.run(row.id, seed.userId, row.role, 'stack', row.resource_id, row.created_at); + } + for (const row of seed.nodeRows) { + insert.run(row.id, seed.userId, row.role, 'node', row.resource_id, row.created_at); + } + } finally { + raw.close(); + } +} + +function removeRoleAssignmentsCheck(raw: Database.Database): void { + raw.exec(` + CREATE TABLE role_assignments_without_check ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + INSERT INTO role_assignments_without_check + SELECT * FROM role_assignments; + DROP TABLE role_assignments; + ALTER TABLE role_assignments_without_check RENAME TO role_assignments; + CREATE INDEX idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); +} + +function expectFinalSchema(raw: Database.Database): void { + const tableSql = roleAssignmentsTableSql(raw); + expect(tableSql).toContain("resource_type = 'stack' AND node_id IS NOT NULL"); + expect(tableSql).toContain("resource_type = 'node' AND node_id IS NULL"); + + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique).toMatch(/user_id/i); + expect(stackUnique).toMatch(/role/i); + expect(stackUnique).toMatch(/resource_type/i); + expect(stackUnique).toMatch(/resource_id/i); + expect(stackUnique).toMatch(/node_id/i); + expect(stackUnique).toMatch(/WHERE\s+resource_type\s*=\s*'stack'/i); + + expect(nodeUnique).toMatch(/user_id/i); + expect(nodeUnique).toMatch(/role/i); + expect(nodeUnique).toMatch(/resource_type/i); + expect(nodeUnique).toMatch(/resource_id/i); + expect(nodeUnique).toMatch(/WHERE\s+resource_type\s*=\s*'node'/i); + const nodeCols = nodeUnique.replace(/WHERE[\s\S]*/i, ''); + expect(nodeCols).not.toMatch(/node_id/); +} + +describe('migrateRoleAssignmentsNodeQualified', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await setupTestDb(); + }); + + afterEach(() => { + resetDatabaseSingleton(); + cleanupTestDb(tmpDir); + }); + + it('remaps legacy stack rows to the default node and preserves ids/timestamps', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-remote', + type: 'remote', + api_url: 'http://192.168.1.50:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-remap', password_hash: hash, role: 'viewer' }); + + const stackCreatedAt = 1_700_000_000_001; + const nodeCreatedAt = 1_700_000_000_002; + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 41, role: 'deployer', resource_id: 'web', created_at: stackCreatedAt }, + { id: 42, role: 'viewer', resource_id: 'api', created_at: stackCreatedAt + 1 }, + ], + nodeRows: [ + { id: 51, role: 'node-admin', resource_id: String(remoteNodeId), created_at: nodeCreatedAt }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const raw = migrated.getDb(); + expectFinalSchema(raw); + + const rows = raw.prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + expect(rows).toHaveLength(3); + + const stackWeb = rows.find((r) => r.id === 41)!; + expect(stackWeb.resource_type).toBe('stack'); + expect(stackWeb.resource_id).toBe('web'); + expect(stackWeb.node_id).toBe(defaultNodeId); + expect(stackWeb.created_at).toBe(stackCreatedAt); + expect(stackWeb.role).toBe('deployer'); + + const stackApi = rows.find((r) => r.id === 42)!; + expect(stackApi.node_id).toBe(defaultNodeId); + expect(stackApi.created_at).toBe(stackCreatedAt + 1); + + const nodeGrant = rows.find((r) => r.id === 51)!; + expect(nodeGrant.resource_type).toBe('node'); + expect(nodeGrant.node_id).toBeNull(); + expect(nodeGrant.created_at).toBe(nodeCreatedAt); + }); + + it('omits legacy stack rows when no default node exists', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'mig-no-default-remote', + type: 'remote', + api_url: 'http://192.168.1.51:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-omit', password_hash: hash, role: 'viewer' }); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + expect(db.getDefaultNode()).toBeUndefined(); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 61, role: 'deployer', resource_id: 'orphan-stack', created_at: 99 }, + ], + nodeRows: [ + { id: 62, role: 'deployer', resource_id: String(remoteNodeId), created_at: 100 }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const rows = migrated.getDb().prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(62); + expect(rows[0].resource_type).toBe('node'); + expect(rows[0].node_id).toBeNull(); + }); + + it('second init is idempotent via sqlite_master CHECK and partial-index WHERE probes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-idem', password_hash: hash, role: 'viewer' }); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 71, role: 'deployer', resource_id: 'idem-stack', created_at: 200 }, + ], + nodeRows: [], + }); + + process.env.DATA_DIR = tmpDir; + const first = DatabaseService.getInstance(); + expectFinalSchema(first.getDb()); + const before = first.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(before.node_id).toBe(defaultNodeId); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const second = DatabaseService.getInstance(); + expectFinalSchema(second.getDb()); + const after = second.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(after).toEqual(before); + + const stale = second.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments_new'", + ).get(); + expect(stale).toBeUndefined(); + }); + + it('preserves node-qualified stack rows when repairing a missing index', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-remote', + type: 'remote', + api_url: 'http://192.168.1.54:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().exec('DROP INDEX idx_role_assignments_stack_unique'); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + + const rows = repaired.getAllRoleAssignments(userId); + expect(rows).toEqual(before); + }); + + it('preserves node-qualified rows without a default node when repairing the table check', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-no-default', + type: 'remote', + api_url: 'http://192.168.1.55:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair-check', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'admin', resource_type: 'stack', + resource_id: 'remote-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'viewer', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + removeRoleAssignmentsCheck(db.getDb()); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + expect(repaired.getAllRoleAssignments(userId)).toEqual(before); + }); + + it('unique indexes use exact column sets including role', () => { + const raw = DatabaseService.getInstance().getDb(); + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*,\s*node_id\s*\)/i, + ); + expect(nodeUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*\)/i, + ); + }); + + it('deleteNode clears stack grants by node_id and preserves other nodes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const doomedId = db.addNode({ + name: 'mig-doomed', + type: 'remote', + api_url: 'http://192.168.1.52:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const survivorId = db.addNode({ + name: 'mig-survivor', + type: 'remote', + api_url: 'http://192.168.1.53:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-delnode', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: doomedId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: survivorId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-only', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(doomedId), + }); + + db.deleteNode(doomedId); + + const remaining = db.getAllRoleAssignments(userId); + expect(remaining.some((a) => a.node_id === doomedId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(doomedId))).toBe(false); + expect(remaining.some((a) => a.node_id === survivorId && a.resource_id === 'shared-name')).toBe(true); + expect(remaining.some((a) => a.node_id === defaultNodeId && a.resource_id === 'local-only')).toBe(true); + + db.deleteUser(userId); + db.deleteNode(survivorId); + }); +}); diff --git a/backend/src/__tests__/rollback-generation-lifecycle.test.ts b/backend/src/__tests__/rollback-generation-lifecycle.test.ts new file mode 100644 index 00000000..bfd4045d --- /dev/null +++ b/backend/src/__tests__/rollback-generation-lifecycle.test.ts @@ -0,0 +1,455 @@ +/** + * Real-DB tests for the rollback-generation retention/cap/release lifecycle: + * DatabaseService's retention/cap/release SQL, StackUpdateRecoveryService's + * cap enforcement and releaseGeneration orchestration, and the + * GET/POST /api/system/rollback/generations routes. Docker is stubbed + * (no real daemon); the DB is real via setupTestDb() so the SQL under test + * (atomic release UPDATE, retention/cap queries) runs for real. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { randomUUID } from 'crypto'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import type { StackUpdateRecoveryGenerationRow, HealthGateRunRow } from '../services/DatabaseService'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let viewerCookie: string; +let deployerCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let StackUpdateRecoveryService: typeof import('../services/StackUpdateRecoveryService').StackUpdateRecoveryService; +let DockerController: typeof import('../services/DockerController').default; + +const mockRemove = vi.fn().mockResolvedValue(undefined); +const mockGetImage = vi.fn(() => ({ remove: mockRemove })); + +const NODE = 1; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ StackUpdateRecoveryService } = await import('../services/StackUpdateRecoveryService')); + ({ default: DockerController } = await import('../services/DockerController')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; + + // Non-admin personas for the RBAC block below. Release is requireAdmin + // (a host-destructive Docker operation); the list endpoint is stack:read. + const db = DatabaseService.getInstance(); + for (const [role, pw] of [['viewer', 'vwpass'], ['deployer', 'dppass']] as const) { + const hash = await bcrypt.hash(pw, 1); + db.addUser({ username: `rb-${role}`, password_hash: hash, role }); + const res = await request(app).post('/api/auth/login').send({ username: `rb-${role}`, password: pw }); + const cookies = res.headers['set-cookie'] as string | string[]; + const c = Array.isArray(cookies) ? cookies[0] : cookies; + if (role === 'viewer') viewerCookie = c; + else deployerCookie = c; + } +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + mockRemove.mockClear().mockResolvedValue(undefined); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDocker: () => ({ getImage: mockGetImage }), + } as unknown as ReturnType); +}); + +afterEach(() => { + vi.restoreAllMocks(); + const db = DatabaseService.getInstance(); + // Restore the shipped defaults so a test that tunes retention/cap does not + // leak that value into the next one. + db.updateGlobalSetting('recovery_retention_days', '7'); + db.updateGlobalSetting('recovery_max_generations', '0'); + db.getDb().prepare('DELETE FROM stack_update_recovery_generations').run(); + db.getDb().prepare('DELETE FROM health_gate_runs').run(); +}); + +function makeRow(overrides: Partial = {}): StackUpdateRecoveryGenerationRow { + const id = overrides.id ?? randomUUID(); + const now = Date.now(); + return { + id, + node_id: NODE, + stack_name: 'my-stack', + status: 'active', + phase: 'immediate_verified', + is_current: 1, + backup_slot_id: null, + override_path: null, + services_json: JSON.stringify([{ + serviceName: 'web', + scale: 1, + hasBuild: false, + declaredImageRef: 'nginx:latest', + referenceKind: 'moving_tag', + replicas: [{ + containerId: 'c1', + imageId: `sha256:${id.replace(/-/g, '').padEnd(64, '0').slice(0, 64)}`, + repoDigest: null, + state: 'running', + rollbackTag: `sencho-rb/${id.replace(/-/g, '').slice(0, 12)}/web:hold`, + }], + }]), + health_gate_id: null, + gate_retain_until: null, + artifact_expires_at: null, + operation_lease_expires_at: null, + created_at: now, + updated_at: now, + created_by: null, + artifacts_retired: 0, + released_at: null, + released_by: null, + ...overrides, + }; +} + +function insertRow(overrides: Partial = {}): StackUpdateRecoveryGenerationRow { + const row = makeRow(overrides); + DatabaseService.getInstance().insertStackUpdateRecoveryGeneration(row); + return row; +} + +function insertHealthGate(overrides: Partial = {}): HealthGateRunRow { + const run: HealthGateRunRow = { + id: randomUUID(), + node_id: NODE, + stack_name: 'my-stack', + trigger_action: 'update', + status: 'observing', + reason: null, + window_seconds: 90, + containers_json: '[]', + started_at: Date.now(), + ended_at: null, + created_by: null, + target_scope: 'stack', + service_name: null, + failure_source: null, + ...overrides, + }; + DatabaseService.getInstance().insertHealthGateRun(run); + return run; +} + +function imageIdOf(row: StackUpdateRecoveryGenerationRow): string { + const parsed = JSON.parse(row.services_json); + return parsed[0].replicas[0].imageId as string; +} + +describe('recovery_retention_days wired into casHandoffGeneration', () => { + it('uses a configured retention value instead of the hardcoded 7 days', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_retention_days', '2'); + const current = insertRow({ status: 'active', is_current: 1 }); + const candidate = insertRow({ + id: randomUUID(), + status: 'candidate', + phase: 'acquired', + is_current: 0, + stack_name: current.stack_name, + }); + const ok = db.casHandoffGeneration(candidate.id, NODE, current.stack_name); + expect(ok).toBe(true); + + const superseded = db.getStackUpdateRecoveryGeneration(current.id)!; + expect(superseded.status).toBe('superseded'); + const expiresInDays = (superseded.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(expiresInDays).toBeGreaterThan(1.9); + expect(expiresInDays).toBeLessThan(2.1); + }); + + it('reflects a retention-days change made between two consecutive handoffs, not the value at the time of the first', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_retention_days', '2'); + const stackA = insertRow({ stack_name: 'stack-a', status: 'active', is_current: 1 }); + const candidateA = insertRow({ + id: randomUUID(), status: 'candidate', phase: 'acquired', is_current: 0, stack_name: stackA.stack_name, + }); + expect(db.casHandoffGeneration(candidateA.id, NODE, stackA.stack_name)).toBe(true); + const supersededA = db.getStackUpdateRecoveryGeneration(stackA.id)!; + const daysA = (supersededA.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysA).toBeGreaterThan(1.9); + expect(daysA).toBeLessThan(2.1); + + // Change the setting without restarting anything, then handoff a + // different stack: its expiry must reflect the new value, not a value + // cached from the first call. + db.updateGlobalSetting('recovery_retention_days', '5'); + const stackB = insertRow({ stack_name: 'stack-b', status: 'active', is_current: 1 }); + const candidateB = insertRow({ + id: randomUUID(), status: 'candidate', phase: 'acquired', is_current: 0, stack_name: stackB.stack_name, + }); + expect(db.casHandoffGeneration(candidateB.id, NODE, stackB.stack_name)).toBe(true); + const supersededB = db.getStackUpdateRecoveryGeneration(stackB.id)!; + const daysB = (supersededB.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysB).toBeGreaterThan(4.9); + expect(daysB).toBeLessThan(5.1); + + // The earlier write is not retroactively touched by the later setting change. + const supersededAAfter = db.getStackUpdateRecoveryGeneration(stackA.id)!; + expect(supersededAAfter.artifact_expires_at).toBe(supersededA.artifact_expires_at); + }); +}); + +describe('recovery_max_generations cap enforcement (reconcileIncomplete)', () => { + it('retains current + (cap - 1) superseded generations; forces the rest to expire now', async () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_max_generations', '2'); + const stackName = 'capped-stack'; + insertRow({ stack_name: stackName, status: 'active', is_current: 1 }); + const superseded: StackUpdateRecoveryGenerationRow[] = []; + for (let i = 0; i < 3; i++) { + superseded.push(insertRow({ + id: randomUUID(), + stack_name: stackName, + status: 'superseded', + is_current: 0, + artifact_expires_at: Date.now() + 6 * 24 * 60 * 60 * 1000, + created_at: Date.now() - (3 - i) * 60_000, + })); + } + + const svc = StackUpdateRecoveryService.getInstance(); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + + // cap=2 => current (1) + 1 superseded kept; the other 2 superseded get + // artifact_expires_at pulled to now and their artifacts retired. + const rows = superseded.map((r) => db.getStackUpdateRecoveryGeneration(r.id)!); + const stillRetained = rows.filter((r) => !r.artifacts_retired); + expect(stillRetained.length).toBe(1); + // Keeps the newest superseded row. + expect(stillRetained[0].id).toBe(superseded[2].id); + }); + + it('never touches a recovery_required generation', async () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_max_generations', '1'); + const stackName = 'stuck-stack'; + insertRow({ stack_name: stackName, status: 'active', is_current: 1 }); + const stuck = insertRow({ + id: randomUUID(), + stack_name: stackName, + status: 'recovery_required', + is_current: 0, + }); + + const svc = StackUpdateRecoveryService.getInstance(); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + + const after = db.getStackUpdateRecoveryGeneration(stuck.id)!; + expect(after.artifacts_retired).toBe(0); + expect(after.artifact_expires_at).toBeNull(); + }); +}); + +describe('StackUpdateRecoveryService.releaseGeneration', () => { + it('release on the current generation clears the held-image set for its image', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.getHeldImageIds(NODE)?.has(imageIdOf(row))).toBe(true); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + expect(svc.getHeldImageIds(NODE)?.has(imageIdOf(row))).toBe(false); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.is_current).toBe(0); + expect(after.released_at).not.toBeNull(); + }); + + it('after releasing the current generation, no rollback point is claimed for the stack (D05)', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.getCurrent(NODE, row.stack_name)).toBeDefined(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + + // Both consumers of the current-generation lookup filter on is_current = 1, + // which release clears, so a released row can never be offered as a live + // rollback target by a later failed update. + expect(svc.getCurrent(NODE, row.stack_name)).toBeUndefined(); + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(false); + + // It is also gone from the list endpoint's supported-status projection. + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.some((g: { id: string }) => g.id === row.id)).toBe(false); + }); + + it('release on a restored_current generation clears the service-update pin', async () => { + const row = insertRow({ status: 'restored_current', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(true); + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(false); + }); + + it('rejects release while the linked health gate is observing', async () => { + const gate = insertHealthGate({ status: 'observing' }); + const row = insertRow({ status: 'active', is_current: 1, health_gate_id: gate.id, stack_name: gate.stack_name }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('not_eligible'); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(after.is_current).toBe(1); + }); + + it('allows release once the linked health gate has passed', async () => { + const gate = insertHealthGate({ status: 'passed' }); + const row = insertRow({ status: 'active', is_current: 1, health_gate_id: gate.id, stack_name: gate.stack_name }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + }); + + it('rejects release when the row has already moved to recovery_required (race)', async () => { + const row = insertRow({ status: 'recovery_required', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('not_eligible'); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.artifacts_retired).toBe(0); + }); + + it('rejects a second release of an already-released generation', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const first = await svc.releaseGeneration(row.id, 'tester'); + expect(first.ok).toBe(true); + const second = await svc.releaseGeneration(row.id, 'tester'); + expect(second.ok).toBe(false); + if (!second.ok) expect(second.reason).toBe('already_released'); + }); + + it('leaves artifacts_retired at 0 (retryable) when Docker tag removal fails', async () => { + mockRemove.mockRejectedValueOnce(Object.assign(new Error('docker busy'), { statusCode: 500 })); + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.artifactsCleaned).toBe(false); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).not.toBeNull(); + expect(after.artifacts_retired).toBe(0); + + // The reconcile sweep retries a released-but-uncleaned row immediately. + mockRemove.mockResolvedValue(undefined); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + const retried = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(retried.artifacts_retired).toBe(1); + }); +}); + +describe('GET/POST /api/system/rollback/generations', () => { + it('lists generations for the requesting node with a releasable flag', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Authorization', authHeader); + + expect(res.status).toBe(200); + const found = res.body.find((g: { id: string }) => g.id === row.id); + expect(found).toBeDefined(); + expect(found.stackName).toBe(row.stack_name); + expect(found.isCurrent).toBe(true); + expect(found.releasable).toBe(true); + }); + + it('releases a generation and returns success', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('404s releasing a generation that belongs to a different node', async () => { + const row = insertRow({ status: 'superseded', is_current: 0, node_id: 999 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(404); + }); + + it('409s releasing an ineligible generation', async () => { + const row = insertRow({ status: 'recovery_required', is_current: 1 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('NOT_ELIGIBLE'); + }); +}); + +describe('RBAC on the rollback-generation routes', () => { + it('refuses a viewer POST to the release endpoint and leaves the generation intact', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Cookie', viewerCookie); + + expect(res.status).toBe(403); + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(after.artifacts_retired).toBe(0); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + it('refuses a deployer POST to the release endpoint (release is Admin-only)', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Cookie', deployerCookie); + + expect(res.status).toBe(403); + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + it('allows a viewer to read the generations list (stack:read, matching sibling Resources routes)', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Cookie', viewerCookie); + + expect(res.status).toBe(200); + expect(res.body.some((g: { id: string }) => g.id === row.id)).toBe(true); + }); +}); diff --git a/backend/src/__tests__/scheduled-tasks-rbac.test.ts b/backend/src/__tests__/scheduled-tasks-rbac.test.ts new file mode 100644 index 00000000..bd4e2133 --- /dev/null +++ b/backend/src/__tests__/scheduled-tasks-rbac.test.ts @@ -0,0 +1,465 @@ +/** + * RBAC tests for /api/scheduled-tasks. Verifies that target-aware permission + * checks replace the blanket requireAdmin gate: scoped deployers can create + * stack-lifecycle schedules for their stacks, node admins can create node-wide + * schedules, viewers/auditors get 403 on mutations, and listing is filtered. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let deployerCookie: string; +let viewerCookie: string; +let auditorCookie: string; +let tierSpy: ReturnType; + +/** + * Creates a user whose ONLY source of the given permission is a scoped + * role assignment. The global role is set to 'viewer' so the scoped + * grant is the sole path for authorization beyond read-only access. + */ +async function createScopedUser( + app: import('express').Express, + db: ReturnType, + username: string, + assignmentRole: 'deployer' | 'node-admin', + resourceType: 'stack' | 'node', + resourceId: string, + nodeId?: number, +): Promise { + const hash = await bcrypt.hash('testpass', 1); + const userId = db.addUser({ username, password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: userId, role: assignmentRole, resource_type: resourceType, resource_id: resourceId, node_id: nodeId ?? null }); + const res = await request(app).post('/api/auth/login').send({ username, password: 'testpass' }); + const cookies = res.headers['set-cookie'] as string | string[]; + return Array.isArray(cookies) ? cookies[0] : cookies; +} + +let scopedDeployerCookie: string; +let scopedNodeAdminCookie: string; +let secondStackDeployerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + const db = DatabaseService.getInstance(); + + const { LicenseService } = await import('../services/LicenseService'); + tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + // Global roles + for (const [role, pw] of [['deployer', 'dp'], ['viewer', 'vwp'], ['auditor', 'aud']] as const) { + const hash = await bcrypt.hash(pw, 1); + db.addUser({ username: `sched-${role}`, password_hash: hash, role }); + const res = await request(app).post('/api/auth/login').send({ username: `sched-${role}`, password: pw }); + const cookies = res.headers['set-cookie'] as string | string[]; + const c = Array.isArray(cookies) ? cookies[0] : cookies; + if (role === 'deployer') deployerCookie = c; + else if (role === 'viewer') viewerCookie = c; + else auditorCookie = c; + } + + // Seed real stack directories so existence validators pass. + const composeDir = path.join(tmpDir, 'compose'); + for (const name of ['web', 'api']) { + fs.mkdirSync(path.join(composeDir, name), { recursive: true }); + fs.writeFileSync(path.join(composeDir, name, 'compose.yaml'), 'version: "3"\n'); + } + + // Insert a local node for stack-target fixtures + for (const [nodeName, nodeType] of [['local-test', 'local'], ['remote-test', 'remote']] as const) { + const existing = db.getDb().prepare('SELECT id FROM nodes WHERE name = ?').get(nodeName) as { id: number } | undefined; + if (!existing) { + db.getDb().prepare( + `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at) + VALUES (?, ?, 'proxy', '/tmp/compose', 0, 'online', ?)`, + ).run(nodeName, nodeType, Date.now()); + } + } + + // Scoped deployer: stack:deploy on stack "web" at node 1 + scopedDeployerCookie = await createScopedUser(app, db, 'scoped-deploy', 'deployer', 'stack', 'web', 1); + // Scoped node-admin: node:manage on node 1 + scopedNodeAdminCookie = await createScopedUser(app, db, 'scoped-nodeadm', 'node-admin', 'node', '1'); + // Second scoped deployer: stack:deploy on stack "api" at node 1 + secondStackDeployerCookie = await createScopedUser(app, db, 'scoped-deploy-2', 'deployer', 'stack', 'api', 1); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM scheduled_tasks').run(); + tierSpy.mockReturnValue('paid'); +}); + +const stackRestartPayload = { + name: 'nightly-restart', target_type: 'stack', target_id: 'web', + node_id: 1, action: 'restart', cron_expression: '0 3 * * *', enabled: true, +}; + +const nodeScanPayload = { + name: 'nightly-scan', target_type: 'system', target_id: null, + node_id: 1, action: 'scan', cron_expression: '0 4 * * *', enabled: true, +}; + +const prunePayload = { + name: 'weekly-prune', target_type: 'system', target_id: null, + node_id: 1, action: 'prune', cron_expression: '0 5 * * 0', enabled: true, +}; + +const fleetUpdatePayload = { + name: 'fleet-update', target_type: 'fleet', target_id: null, + node_id: 1, action: 'update', cron_expression: '0 6 * * *', enabled: true, +}; + +// ── Create ───────────────────────────────────────────────────────────────── + +describe('POST /api/scheduled-tasks (RBAC)', () => { + it('allows global deployer (stack:deploy) to create stack restart', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + }); + + it('allows global deployer to create node scan (node:manage)', async () => { + // Global deployer has stack:read + stack:deploy only — no node:manage. + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(nodeScanPayload); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows global deployer to create fleet-wide update (node:manage)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(fleetUpdatePayload); + expect(res.status).toBe(403); + }); + + it('rejects global deployer creating prune (system:settings)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(prunePayload); + expect(res.status).toBe(403); + }); + + it('allows admin to create prune', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(prunePayload); + expect(res.status).toBe(201); + }); + + it('allows scoped deployer on their own stack', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + }); + + it('rejects scoped deployer on a different stack', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send({ + ...stackRestartPayload, target_id: 'api', + }); + expect(res.status).toBe(403); + }); + + it('rejects scoped deployer creating node scan', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send(nodeScanPayload); + expect(res.status).toBe(403); + }); + + it('allows scoped node-admin to create node scan', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedNodeAdminCookie).send(nodeScanPayload); + expect(res.status).toBe(201); + }); + + it('rejects scoped node-admin creating prune (unscoped, admin-only)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedNodeAdminCookie).send(prunePayload); + expect(res.status).toBe(403); + }); + + it('rejects viewer creating any task', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', viewerCookie).send(stackRestartPayload); + expect(res.status).toBe(403); + }); + + it('rejects auditor creating any task', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', auditorCookie).send(stackRestartPayload); + expect(res.status).toBe(403); + }); + + it('records creator_user_id from the authenticated user', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + const task = DatabaseService.getInstance().getScheduledTask(res.body.id); + expect(task).toBeDefined(); + expect(task!.creator_user_id).not.toBeNull(); + expect(task!.created_by).toBe('sched-deployer'); + }); +}); + +// ── List filtering ───────────────────────────────────────────────────────── + +describe('GET /api/scheduled-tasks (RBAC listing filter)', () => { + let db: ReturnType; + + beforeEach(() => { + db = DatabaseService.getInstance(); + // Create a mix of tasks + db.createScheduledTask({ + name: 'web-restart', target_type: 'stack', target_id: 'web', node_id: 1, + action: 'restart', cron_expression: '0 3 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + db.createScheduledTask({ + name: 'api-restart', target_type: 'stack', target_id: 'api', node_id: 1, + action: 'restart', cron_expression: '0 4 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + db.createScheduledTask({ + name: 'node-scan', target_type: 'system', target_id: null, node_id: 1, + action: 'scan', cron_expression: '0 5 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + }); + + it('admin sees all tasks', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + }); + + it('scoped deployer sees only their own stack tasks', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].target_id).toBe('web'); + }); + + it('global deployer sees all stack and node tasks but not prune', async () => { + // Global deployer: stack:read + stack:deploy. Can see stack tasks but not + // scan (node:manage) or prune (system:settings). Fleet update without a + // specific node_id is unscoped node:manage — also 403. + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', deployerCookie); + expect(res.status).toBe(200); + // Should see only the two stack restart tasks + expect(res.body).toHaveLength(2); + expect(res.body.every((t: any) => t.target_type === 'stack')).toBe(true); + }); + + it('viewer sees empty list', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +// ── By-id / runs / export ────────────────────────────────────────────────── + +describe('GET /:id, /:id/runs, /:id/runs/export (RBAC)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('test-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can GET /:id for their own stack', async () => { + const res = await request(app).get(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + }); + + it('scoped deployer gets 404 for a different stack task', async () => { + // Create a task they don't own + const db = DatabaseService.getInstance(); + const other = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('api-task', 'stack', 'api', 1, 'restart', '0 4 * * *', 1, 'admin', 1, 0, 0) + `).run(); + const res = await request(app).get(`/api/scheduled-tasks/${other.lastInsertRowid}`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(404); + }); +}); + +// ── Update (two-phase) ───────────────────────────────────────────────────── + +describe('PUT /:id (RBAC two-phase)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('web-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can update their own stack task', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ name: 'renamed' }); + expect(res.status).toBe(200); + }); + + it('scoped deployer gets 403 when trying to retarget to a different stack', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ target_id: 'api' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('scoped deployer gets 403 when trying to flip restart -> prune', async () => { + // prune requires target_type: system, so include it to reach the + // permission check rather than hitting structural validation first. + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ action: 'prune', target_type: 'system', target_id: null }); + expect(res.status).toBe(403); + }); + + it('second deployer gets 404 editing a task they do not own', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', secondStackDeployerCookie).send({ name: 'stolen' }); + expect(res.status).toBe(404); + }); +}); + +// ── Toggle / Run-now ─────────────────────────────────────────────────────── + +describe('PATCH /:id/toggle and POST /:id/run (RBAC)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('web-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can toggle their stack task', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + }); + + it('scoped deployer can run-now their stack task', async () => { + const res = await request(app).post(`/api/scheduled-tasks/${taskId}/run`).set('Cookie', scopedDeployerCookie); + // 409 (already running) is also acceptable; 202 is the success case + // 403 means the permission check rejected + expect(res.status).not.toBe(403); + }); + + it('scoped deployer gets 404 on toggle of other stack task', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', secondStackDeployerCookie); + expect(res.status).toBe(404); + }); + + it('viewer gets 404 on toggle', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', viewerCookie); + expect(res.status).toBe(404); + }); +}); + +// ── Execution-time revalidation ──────────────────────────────────────────── + +describe('Scheduler revalidation', () => { + it('rejects a task whose creator was deleted', async () => { + // Verify that executeTask auto-disables the task when the creator no longer exists: + const { SchedulerService } = await import('../services/SchedulerService'); + const db = DatabaseService.getInstance(); + + // Create a task with a non-existent creator_user_id + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('orphan-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'deleted-user', 99999, 0, 0) + `).run(); + const task = db.getScheduledTask(res.lastInsertRowid as number); + expect(task).toBeDefined(); + + // executeTask catches TaskAuthorizationError internally (auto-disables the + // task and records the error), then returns normally rather than re-throwing. + await (SchedulerService.getInstance() as any).executeTask(task!, 'scheduler'); + const updated = db.getScheduledTask(task!.id); + expect(updated!.enabled).toBe(0); + expect(updated!.last_error).toContain('creator account no longer exists'); + }); + + it('legacy task with NULL creator_user_id executes without revalidation', async () => { + const { SchedulerService } = await import('../services/SchedulerService'); + const db = DatabaseService.getInstance(); + + // A legacy task with NULL creator_user_id + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('legacy-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', NULL, 0, 0) + `).run(); + const task = db.getScheduledTask(res.lastInsertRowid as number); + + // Should not throw TaskAuthorizationError. Any other error (e.g., Docker + // not available) means the revalidation was skipped correctly. + let threwAuthError = false; + try { + await (SchedulerService.getInstance() as any).executeTask(task!, 'scheduler'); + } catch (e: unknown) { + const { TaskAuthorizationError } = await import('../services/SchedulerService'); + threwAuthError = e instanceof TaskAuthorizationError; + } + expect(threwAuthError).toBe(false); + }); +}); + +// ── Registry lockstep ────────────────────────────────────────────────────── + +describe('Action registry lockstep', () => { + it('every BACKEND_SCHEDULED_ACTIONS entry has a valid permission', async () => { + const { BACKEND_SCHEDULED_ACTIONS } = await import('../services/scheduledActionRegistry'); + const { ALL_PERMISSION_ACTIONS } = await import('../middleware/permissions'); + for (const def of BACKEND_SCHEDULED_ACTIONS) { + expect(ALL_PERMISSION_ACTIONS).toContain(def.permission); + } + }); + + it('resolveTaskPermissionScope covers every (action x target_type) pair', async () => { + const { BACKEND_SCHEDULED_ACTIONS, resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + for (const def of BACKEND_SCHEDULED_ACTIONS) { + for (const tt of def.targetTypes) { + const scope = resolveTaskPermissionScope(def.id, tt, 'test-stack', 1, null); + expect(scope.action).toBeDefined(); + expect(scope.action).not.toBeNull(); + } + } + }); + + it('prune resolves unscoped regardless of node_id', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('prune', 'system', null, 1, null); + expect(scope.resourceType).toBeUndefined(); + expect(scope.action).toBe('system:settings'); + }); + + it('snapshot resolves unscoped', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('snapshot', 'fleet', null, null, null); + expect(scope.resourceType).toBeUndefined(); + expect(scope.action).toBe('node:manage'); + }); + + it('scan resolves node-scoped', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('scan', 'system', null, 1, null); + expect(scope.resourceType).toBe('node'); + expect(scope.resourceId).toBe('1'); + expect(scope.action).toBe('node:manage'); + }); +}); diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 24e92161..bc5d32d6 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import bcrypt from 'bcrypt'; +import fs from 'fs'; +import path from 'path'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; let tmpDir: string; @@ -30,6 +32,24 @@ beforeAll(async () => { const viewerRes = await request(app).post('/api/auth/login').send({ username: 'sched-viewer', password: 'viewerpass' }); const cookies = viewerRes.headers['set-cookie'] as string | string[]; viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; + + // Seed real stack directories so existence validators pass. + const composeDir = path.join(tmpDir, 'compose'); + for (const name of ['my-stack', 's']) { + fs.mkdirSync(path.join(composeDir, name), { recursive: true }); + fs.writeFileSync(path.join(composeDir, name, 'compose.yaml'), 'version: "3"\n'); + } + + // Mock DockerController.findContainerByName so container existence checks pass + // in tests (no real Docker daemon available). Only resolve for names used by + // the test fixtures; everything else returns null to exercise the 400 path. + const { default: DockerController } = await import('../services/DockerController'); + const containerFixture = { id: 'abc123test', name: 'test-container', state: 'running', image: 'test:latest', stackProject: null }; + vi.spyOn(DockerController.prototype, 'findContainerByName') + .mockImplementation(async (name: string) => { + if (name === 'watchtower' || name === 'sidecar') return { ...containerFixture, name }; + return null; + }); }); afterAll(() => cleanupTestDb(tmpDir)); @@ -47,9 +67,11 @@ describe('GET /api/scheduled-tasks', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('returns a permission-filtered list for non-admin users (empty for viewers)', async () => { const res = await request(app).get('/api/scheduled-tasks').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + // Viewers have no scheduled-action permission; they see an empty list, not a 403. + expect(res.status).toBe(200); + expect(res.body).toEqual([]); }); it('returns an empty array when no tasks exist', async () => { @@ -176,6 +198,15 @@ describe('POST /api/scheduled-tasks', () => { expect(res.status).toBe(403); }); + it('returns 403 (not 400) for unauthorized caller on nonexistent target', async () => { + // Permissions check runs first; an unauthorized caller must not learn + // whether a stack exists through the error code difference. + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', viewerCookie).send({ + ...basePayload, target_id: 'nonexistent-stack', + }); + expect(res.status).toBe(403); + }); + it('creates a task and returns the new record', async () => { const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(basePayload); expect(res.status).toBe(201); @@ -200,6 +231,52 @@ describe('POST /api/scheduled-tasks', () => { expect(res.body.error).toMatch(/5 fields/); }); + it('rejects a nonexistent node_id with 400', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, node_id: 9999, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/require an existing node/); + }); + + it('returns 403 (not 400) for unauthorized caller probing nonexistent node via fleet update', async () => { + // A viewer must never learn whether a node ID exists through the error + // code difference (400 "node doesn't exist" vs 403 "permission denied"). + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', viewerCookie).send({ + name: 'probe-node', + action: 'update', + target_type: 'fleet', + node_id: 999999, + cron_expression: '0 0 * * *', + enabled: true, + }); + expect(res.status).toBe(403); + }); + + it('returns same 403 for unauthorized caller regardless of node existence', async () => { + const resNonexistent = await request(app).post('/api/scheduled-tasks') + .set('Cookie', viewerCookie).send({ + name: 'probe-nonexistent', action: 'update', target_type: 'fleet', + node_id: 999999, cron_expression: '0 0 * * *', enabled: true, + }); + const resExisting = await request(app).post('/api/scheduled-tasks') + .set('Cookie', viewerCookie).send({ + name: 'probe-existing', action: 'update', target_type: 'fleet', + node_id: 1, cron_expression: '0 0 * * *', enabled: true, + }); + expect(resNonexistent.status).toBe(403); + expect(resExisting.status).toBe(403); + expect(resNonexistent.body.error).toBe(resExisting.body.error); + }); + + it('rejects a nonexistent stack target with 400', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, target_id: 'nonexistent-stack', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not found on the target node/); + }); + it('rejects a missing cron expression with a clear message', async () => { const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ ...basePayload, cron_expression: undefined, @@ -614,6 +691,23 @@ describe('POST /api/scheduled-tasks - container lifecycle', () => { expect(res.body.action).toBe('restart'); }); + it('rejects a nonexistent container target with 400', async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'missing-ctr', + target_type: 'container', + target_id: 'nonexistent-container', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: true, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not found on the target node/); + }); + it('rejects invalid container names', async () => { const res = await request(app) .post('/api/scheduled-tasks') diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 17541bbf..090ab55e 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -1867,6 +1867,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial): Promise { + return new Promise((resolve, reject) => { + const fullReq = Object.assign( + { cookies: {} as Record, headers: {} as Record }, + req, + { + headers: { ...(req.headers ?? {}) }, + cookies: {}, + }, + ) as Request; + let settled = false; + const res = { + status: () => res, + json: (body: unknown) => { + if (!settled) { + settled = true; + reject(new Error(`authMiddleware rejected: ${JSON.stringify(body)}`)); + } + return res; + }, + } as unknown as Response; + const next: NextFunction = (err?: unknown) => { + if (settled) return; + settled = true; + if (err) reject(err); + else resolve(fullReq); + }; + void Promise.resolve(authMiddleware(fullReq, res, next)).catch(reject); + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ authMiddleware } = await import('../middleware/auth')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scoped stack evidence under machine auth', () => { + it('attaches evidence for node_proxy when headers are valid', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,stack:deploy', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('web'); + expect(req.scopedStackEvidence?.actions.has('stack:edit')).toBe(true); + expect(req.scopedStackEvidence?.actions.has('stack:deploy')).toBe(true); + expect(checkPermission(req, 'stack:deploy', 'stack', 'web')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(true); + }); + + it('attaches evidence for pilot_tunnel the same way', async () => { + const token = jwt.sign({ scope: 'pilot_tunnel' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'api', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:read', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('api'); + expect(checkPermission(req, 'stack:read', 'stack', 'api')).toBe(true); + }); + + it('treats malformed actions as absent evidence', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,not-real', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(false); + }); + + it('ignores evidence headers on a user session JWT', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const { TEST_USERNAME } = await import('./helpers/setupTestDb'); + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME); + expect(user).toBeDefined(); + const token = jwt.sign( + { username: user!.username, role: user!.role, tv: user!.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:deploy,stack:edit', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + }); +}); diff --git a/backend/src/__tests__/secrets.test.ts b/backend/src/__tests__/secrets.test.ts index 3e9735cf..f41b2269 100644 --- a/backend/src/__tests__/secrets.test.ts +++ b/backend/src/__tests__/secrets.test.ts @@ -5,7 +5,7 @@ * - encrypt round-trip via CryptoService * - DatabaseService secret + version + push CRUD * - SecretsService versioning, importFromStack, executePush aggregation - * - Route guards (requirePaid 403, requireAdmin 403, requireUserSession 403, push lock 409) + * - Route guards (requireAdmin 403, requireUserSession 403, push lock 409) * - Hub-only enforcement is covered in hub-only-guard.test.ts * - developer_mode diagnostics gating (and that diagnostics never log the secret value) * - getAuditSummary patterns for /secrets routes @@ -74,9 +74,6 @@ beforeAll(async () => { ({ SecretsService } = await import('../services/SecretsService')); ({ CryptoService } = await import('../services/CryptoService')); - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - ({ app } = await import('../index')); }); @@ -387,34 +384,12 @@ describe('getAuditSummary for secrets routes', () => { // ---- Route guards via supertest ---- -describe('Routes /api/secrets tier gating and lock', () => { - it('returns 403 when license is community', async () => { - const { LicenseService } = await import('../services/LicenseService'); - // Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests. - // requirePaid only consults getTier once per request via effectiveTier(req). - const inst = LicenseService.getInstance(); - const tierSpy = vi.spyOn(inst, 'getTier'); - tierSpy.mockReturnValueOnce('community'); - const res = await request(app) - .get('/api/secrets') - .set('Authorization', `Bearer ${adminToken()}`); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); - }); - +describe('Routes /api/secrets basic guards', () => { it('rejects unauthenticated requests', async () => { const res = await request(app).get('/api/secrets'); expect(res.status).toBe(401); }); - it('returns 200 when paid', async () => { - const res = await request(app) - .get('/api/secrets') - .set('Authorization', `Bearer ${adminToken()}`); - expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - }); - it('rejects malformed body on POST /secrets', async () => { const res = await request(app) .post('/api/secrets') @@ -424,6 +399,114 @@ describe('Routes /api/secrets tier gating and lock', () => { }); }); +// ---- Community Admin happy path: Fleet Secrets is available without a paid license ---- + +describe('Routes /api/secrets Community Admin access', () => { + it('lets a Community admin list bundles', async () => { + const res = await request(app) + .get('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('lets a Community admin create, read, update, and delete a bundle', async () => { + // Create + const create = await request(app) + .post('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ name: 'community-test-bundle', kv: { KEY: 'val' } }); + expect(create.status).toBe(201); + const id: number = create.body.id; + // Read + const get = await request(app) + .get(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(get.status).toBe(200); + expect(get.body.kv).toEqual({ KEY: 'val' }); + // Update + const upd = await request(app) + .put(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ kv: { KEY: 'updated' } }); + expect(upd.status).toBe(200); + // Delete + const del = await request(app) + .delete(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(del.status).toBe(200); + }); + + it('lets a Community admin list versions', async () => { + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'versions-community', kv: { X: '1' }, user: TEST_USERNAME }); + const res = await request(app) + .get(`/api/secrets/${id}/versions`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + }); + + it('lets a Community admin import from a stack over HTTP', async () => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'importstack'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'IMPORT_KEY=hello\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'import-http', kv: { X: '1' }, user: TEST_USERNAME }); + + const res = await request(app) + .post(`/api/secrets/${id}/import-from-stack`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ nodeId: localNode.id, stackName: 'importstack', envFileBasename: '.env' }); + expect(res.status).toBe(200); + expect(res.body.kv).toEqual({ IMPORT_KEY: 'hello' }); + }); + + it('lets a Community admin preview and execute a push over HTTP', async () => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'pushstack'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'push-http', kv: { EXISTING: 'updated', NEWKEY: 'added' }, user: TEST_USERNAME }); + + // Preview + const preview = await request(app) + .post(`/api/secrets/${id}/push/preview`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' }); + expect(preview.status).toBe(200); + expect(Array.isArray(preview.body)).toBe(true); + expect(preview.body.length).toBeGreaterThanOrEqual(1); + expect(preview.body[0].reachable).toBe(true); + + // Execute push + const push = await request(app) + .post(`/api/secrets/${id}/push`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' }); + expect(push.status).toBe(200); + expect(push.body.pushId).toBeTruthy(); + expect(push.body.results).toHaveLength(1); + expect(push.body.results[0].status).toBe('ok'); + + // Verify the .env was actually written + const envText = fs.readFileSync(path.join(composeDir, 'pushstack', '.env'), 'utf-8'); + const kv = parseEnv(envText); + expect(kv.EXISTING).toBe('updated'); + expect(kv.NEWKEY).toBe('added'); + }); +}); + // ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ---- describe('Routes /api/secrets admin-role gating', () => { @@ -439,18 +522,26 @@ describe('Routes /api/secrets admin-role gating', () => { return authToken('sec-viewer', 'viewer', user.token_version); } - it.each(SECRET_ENDPOINTS)('403s a non-admin paid user on %s %s', async (method, p) => { + it.each(SECRET_ENDPOINTS)('403s a non-admin user on %s %s', async (method, p) => { const res = await callWithToken(method, p, viewerToken()); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); - it('lets an admin paid user list (200)', async () => { + it('lets an admin user list (200)', async () => { const res = await request(app) .get('/api/secrets') .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); }); + + it('403s a Community viewer on all endpoints', async () => { + for (const [method, p] of SECRET_ENDPOINTS) { + const res = await callWithToken(method, p, viewerToken()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + } + }); }); // ---- Machine-credential rejection: secrets need a real signed-in user session ---- diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index eafebc30..ad02c13f 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -459,9 +459,9 @@ describe('GET /api/security/vex/export (Community)', () => { expect(res.status).toBe(200); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a viewer with stack:read export VEX', async () => { const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); it('exports an OpenVEX document from triage decisions', async () => { diff --git a/backend/src/__tests__/security-sbom-sarif-tier.test.ts b/backend/src/__tests__/security-sbom-sarif-tier.test.ts index 6ecdc547..fa85a47a 100644 --- a/backend/src/__tests__/security-sbom-sarif-tier.test.ts +++ b/backend/src/__tests__/security-sbom-sarif-tier.test.ts @@ -1,5 +1,5 @@ /** - * Both scan-export endpoints are available on every tier (admin only, no tier gate): + * Both scan-export endpoints are available on every tier with stack:read: * POST /api/security/sbom -> per-image SBOM artifact * GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion */ @@ -55,13 +55,16 @@ describe('POST /api/security/sbom (Community)', () => { expect(res.headers['content-disposition']).toContain('nginx_latest.cdx.json'); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a Community viewer generate an SBOM with stack:read', async () => { mockTier('community'); + const svc = TrivyService.getInstance(); + vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true); + vi.spyOn(svc, 'generateSBOM').mockResolvedValue('{"bomFormat":"CycloneDX"}'); const res = await request(app) .post('/api/security/sbom') .set('Cookie', viewerCookie) .send({ imageRef: 'nginx:latest', format: 'cyclonedx' }); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); }); @@ -77,11 +80,11 @@ describe('GET /api/security/scans/:scanId/sarif (Community)', () => { expect(res.status).toBe(404); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a Community viewer reach SARIF export with stack:read', async () => { mockTier('community'); const res = await request(app) .get('/api/security/scans/999999/sarif') .set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(404); }); }); diff --git a/backend/src/__tests__/self-update-pinned-routes.test.ts b/backend/src/__tests__/self-update-pinned-routes.test.ts index 50da0aca..051de0bb 100644 --- a/backend/src/__tests__/self-update-pinned-routes.test.ts +++ b/backend/src/__tests__/self-update-pinned-routes.test.ts @@ -115,13 +115,14 @@ describe('POST /api/system/update', () => { expect(res.status).toBe(202); expect(res.body?.message).toMatch(/restart/i); - // triggerUpdate runs on res finish + delay; flush the microtask queue. - await new Promise(r => setTimeout(r, 600)); - expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({ - targetVersion: '0.99.0', - successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/), - successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/), - })); + // The route schedules triggerUpdate 500ms after responding, so poll for it. + await vi.waitFor(() => { + expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({ + targetVersion: '0.99.0', + successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/), + successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/), + })); + }, { timeout: 5_000 }); }); }); diff --git a/backend/src/__tests__/settings-permission-authz.test.ts b/backend/src/__tests__/settings-permission-authz.test.ts new file mode 100644 index 00000000..634a55f7 --- /dev/null +++ b/backend/src/__tests__/settings-permission-authz.test.ts @@ -0,0 +1,308 @@ +/** + * Settings write authorization: per-key permission buckets on /api/settings + * and Settings-scoped image-update routes. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import type { UserRole } from '../services/DatabaseService'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +const roleCookie: Partial> = {}; + +async function seedAndLogin(role: UserRole): Promise { + const username = `settings-perm-${role}`; + const password = `${username}-pass`; + const passwordHash = await bcrypt.hash(password, 1); + DatabaseService.getInstance().addUser({ username, password_hash: passwordHash, role }); + const res = await request(app).post('/api/auth/login').send({ username, password }); + const cookies = res.headers['set-cookie'] as string | string[]; + return Array.isArray(cookies) ? cookies[0] : cookies; +} + +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) { + roleCookie[role] = await seedAndLogin(role); + } +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +describe('PATCH /api/settings permission buckets', () => { + it('lets node-admin write a node:manage key', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({ host_cpu_limit: 80 }); + expect(res.status).toBe(200); + }); + + it('rejects node-admin writing a system:settings key', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({ developer_mode: '1' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects mixed node-manage + system-settings PATCH from node-admin', async () => { + const before = DatabaseService.getInstance().getGlobalSettings().host_cpu_limit; + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({ host_cpu_limit: 80, developer_mode: '1' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(DatabaseService.getInstance().getGlobalSettings().host_cpu_limit).toBe(before); + }); + + it.each(['deployer', 'viewer', 'auditor'] as const)( + 'rejects %s writing a node:manage key', + async (role) => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie[role]!) + .send({ host_cpu_limit: 70 }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it('lets admin write system:settings keys', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({ developer_mode: '0' }); + expect(res.status).toBe(200); + }); + + it('lets admin empty PATCH as a no-op', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({}); + expect(res.status).toBe(200); + }); + + it('lets node-admin empty PATCH as a no-op', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({}); + expect(res.status).toBe(200); + }); + + it.each(['deployer', 'viewer', 'auditor'] as const)( + 'rejects empty PATCH from %s', + async (role) => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', roleCookie[role]!) + .send({}); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it('lets node-admin POST a single node:manage key', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({ key: 'host_cpu_limit', value: 75 }); + expect(res.status).toBe(200); + }); + + it('rejects node-admin POST of a system:settings key', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', roleCookie['node-admin']!) + .send({ key: 'developer_mode', value: '1' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('honors node-scoped node-admin grants for node:manage writes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteId = db.addNode({ + name: 'settings-scoped-remote', + type: 'remote', + api_url: 'http://192.168.1.50:1852', + api_token: 'test-token', + compose_dir: '/tmp', + is_default: false, + }); + + const allowedPassword = 'settings-scoped-allow-pass'; + const allowedUserId = db.addUser({ + username: 'settings-scoped-allow', + password_hash: await bcrypt.hash(allowedPassword, 1), + role: 'viewer', + }); + db.addRoleAssignment({ + user_id: allowedUserId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + const allowedLogin = await request(app).post('/api/auth/login').send({ + username: 'settings-scoped-allow', + password: allowedPassword, + }); + const allowedCookies = allowedLogin.headers['set-cookie'] as string | string[]; + const allowedCookie = Array.isArray(allowedCookies) ? allowedCookies[0] : allowedCookies; + + const allowed = await request(app) + .patch('/api/settings') + .set('Cookie', allowedCookie) + .set('x-node-id', String(defaultNodeId)) + .send({ host_cpu_limit: 81 }); + expect(allowed.status).toBe(200); + + // Grant only on a remote node; local default writes must still 403 (and stay + // on the local settings route, not the remote proxy). + const deniedPassword = 'settings-scoped-deny-pass'; + const deniedUserId = db.addUser({ + username: 'settings-scoped-deny', + password_hash: await bcrypt.hash(deniedPassword, 1), + role: 'viewer', + }); + db.addRoleAssignment({ + user_id: deniedUserId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(remoteId), + }); + const deniedLogin = await request(app).post('/api/auth/login').send({ + username: 'settings-scoped-deny', + password: deniedPassword, + }); + const deniedCookies = deniedLogin.headers['set-cookie'] as string | string[]; + const deniedCookie = Array.isArray(deniedCookies) ? deniedCookies[0] : deniedCookies; + + const denied = await request(app) + .patch('/api/settings') + .set('Cookie', deniedCookie) + .set('x-node-id', String(defaultNodeId)) + .send({ host_cpu_limit: 82 }); + expect(denied.status).toBe(403); + expect(denied.body.code).toBe('PERMISSION_DENIED'); + }); +}); + +describe('Settings feature routes permission matrix', () => { + it('rejects node-admin on system:* feature mutations', async () => { + const cookie = roleCookie['node-admin']!; + const cases: Array<{ method: 'get' | 'post' | 'put' | 'delete'; path: string; body?: object }> = [ + { method: 'get', path: '/api/users' }, + { method: 'post', path: '/api/api-tokens', body: { name: 'x', scope: 'read-only' } }, + { method: 'post', path: '/api/webhooks', body: { name: 'x', stack_name: 'demo', action: 'restart' } }, + { method: 'post', path: '/api/registries', body: { name: 'x', url: 'https://example.com', username: 'u', password: 'p' } }, + { method: 'post', path: '/api/license/activate', body: { license_key: 'x' } }, + ]; + for (const c of cases) { + const req = request(app)[c.method](c.path).set('Cookie', cookie); + const res = c.body ? await req.send(c.body) : await req; + expect(res.status, c.path).toBe(403); + expect(res.body.code, c.path).toBe('PERMISSION_DENIED'); + } + }); + + it('lets node-admin upsert a notification agent channel', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', roleCookie['node-admin']!) + .send({ + type: 'discord', + url: 'https://discord.com/api/webhooks/123/abc', + enabled: true, + }); + expect(res.status).toBe(200); + }); + + it('rejects viewer upserting a notification agent channel', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', roleCookie.viewer!) + .send({ + type: 'discord', + url: 'https://discord.com/api/webhooks/123/abc', + enabled: true, + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); +}); + +describe('image-updates Settings-scoped routes', () => { + it('rejects node-admin PUT /interval (system:settings)', async () => { + const res = await request(app) + .put('/api/image-updates/interval') + .set('Cookie', roleCookie['node-admin']!) + .send({ minutes: 60 }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects node-admin PUT /enabled (system:settings)', async () => { + const res = await request(app) + .put('/api/image-updates/enabled') + .set('Cookie', roleCookie['node-admin']!) + .send({ enabled: false }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('lets admin PUT /interval', async () => { + const res = await request(app) + .put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 60 }); + expect(res.status).toBe(200); + }); + + it('lets admin PUT /enabled', async () => { + const res = await request(app) + .put('/api/image-updates/enabled') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(200); + }); + + it('lets node-admin POST /refresh (node:manage)', async () => { + const res = await request(app) + .post('/api/image-updates/refresh') + .set('Cookie', roleCookie['node-admin']!); + // 200 on success, 409 when checks disabled, 429 on cooldown — not 403. + expect(res.status).not.toBe(403); + expect([200, 409, 429]).toContain(res.status); + }); + + it('rejects viewer POST /refresh', async () => { + const res = await request(app) + .post('/api/image-updates/refresh') + .set('Cookie', roleCookie.viewer!); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index cc3fb9bb..1eee2cf4 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -241,6 +241,88 @@ describe('prune_on_update (auto-prune after updates)', () => { }); }); +describe('recovery_retention_days (superseded rollback generation retention)', () => { + it('defaults to 7 days in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).toBe('7'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.recovery_retention_days).toBeDefined(); + }); + + it('accepts a well-formed write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: '14' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).toBe('14'); + DatabaseService.getInstance().updateGlobalSetting('recovery_retention_days', '7'); + }); + + it('rejects an out-of-range value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: '91' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).not.toBe('91'); + }); + + it('rejects a non-numeric value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: 'banana' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + }); +}); + +describe('recovery_max_generations (cap on retained rollback generations per stack)', () => { + it('defaults to 0 (unlimited) in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).toBe('0'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.recovery_max_generations).toBeDefined(); + }); + + it('accepts a well-formed write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '3' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).toBe('3'); + DatabaseService.getInstance().updateGlobalSetting('recovery_max_generations', '0'); + }); + + it('rejects a negative value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '-1' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).not.toBe('-1'); + }); + + it('rejects an out-of-range value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '51' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + }); +}); + describe('session_sliding_refresh (keep active sessions alive)', () => { it('defaults to ON in a freshly seeded database', () => { expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('1'); diff --git a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts index e1ad3b0a..a188b776 100644 --- a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts +++ b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts @@ -142,8 +142,9 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( name: 'stack-del-rbac-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack' }); + const defaultNodeId = db.getDefaultNode()!.id; + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api', node_id: defaultNodeId }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: defaultNodeId }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId) }); const res = await request(app) @@ -152,8 +153,8 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( expect(res.status).toBe(200); const remaining = db.getAllRoleAssignments(userId); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api')).toBe(false); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack')).toBe(true); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api' && a.node_id === defaultNodeId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack' && a.node_id === defaultNodeId)).toBe(true); expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(otherNodeId))).toBe(true); db.deleteUser(userId); diff --git a/backend/src/__tests__/stack-update-recovery-service.test.ts b/backend/src/__tests__/stack-update-recovery-service.test.ts index b7e784bc..385aa08c 100644 --- a/backend/src/__tests__/stack-update-recovery-service.test.ts +++ b/backend/src/__tests__/stack-update-recovery-service.test.ts @@ -152,6 +152,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; vi.spyOn(DatabaseService.prototype, 'getStackUpdateRecoveryGeneration').mockReturnValue(row); @@ -193,6 +195,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; vi.spyOn(DatabaseService.prototype, 'getStackUpdateRecoveryGeneration').mockReturnValue(row); const update = vi.spyOn(DatabaseService.prototype, 'updateStackUpdateRecoveryGeneration') @@ -354,6 +358,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; mockRemove.mockRejectedValueOnce(Object.assign(new Error('docker busy'), { statusCode: 500 })); const markRetired = vi.spyOn(DatabaseService.prototype, 'markStackUpdateRecoveryArtifactsRetired') diff --git a/backend/src/__tests__/stackRouteAuth.test.ts b/backend/src/__tests__/stackRouteAuth.test.ts new file mode 100644 index 00000000..4c115647 --- /dev/null +++ b/backend/src/__tests__/stackRouteAuth.test.ts @@ -0,0 +1,155 @@ +/** + * Pure classifyStackApiPath coverage for hub stack RBAC gating. + */ +import { describe, it, expect } from 'vitest'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, + parseScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import type { PermissionAction } from '../middleware/permissions'; + +describe('classifyStackApiPath', () => { + describe('named-stack families', () => { + it('maps read routes to stack:read', () => { + expect(classifyStackApiPath('GET', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/drift/recheck')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + + it('maps edit routes to stack:edit', () => { + expect(classifyStackApiPath('PUT', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('DELETE', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + + it('maps deploy routes and service lifecycle ops to stack:deploy', () => { + expect(classifyStackApiPath('POST', '/stacks/web/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/update')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/restart')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + }); + + it('maps stack DELETE to stack:delete', () => { + expect(classifyStackApiPath('DELETE', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:delete', + }); + }); + + it('treats git-source/apply primary as stack:edit', () => { + expect(classifyStackApiPath('POST', '/stacks/web/git-source/apply')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + }); + + describe('static exclusions', () => { + it('classifies collection and create paths as static', () => { + expect(classifyStackApiPath('GET', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/statuses')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/discovery')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/scan')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/move')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/bulk')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/from-git')).toEqual({ kind: 'static' }); + }); + + it('classifies non-/stacks paths as static', () => { + expect(classifyStackApiPath('GET', '/nodes')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/users')).toEqual({ kind: 'static' }); + }); + }); + + describe('encoding and trailing slashes', () => { + it('decodes percent-encoded stack names', () => { + expect(classifyStackApiPath('GET', '/stacks/my%2Dstack')).toEqual({ + kind: 'named-stack', stackName: 'my-stack', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web%5Fprod/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web_prod', action: 'stack:deploy', + }); + }); + + it('strips trailing slashes before matching', () => { + expect(classifyStackApiPath('GET', '/stacks/web/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/deploy/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/statuses/')).toEqual({ kind: 'static' }); + }); + + it('ignores query strings', () => { + expect(classifyStackApiPath('GET', '/stacks/web?nodeId=1')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + }); + + describe('fail-closed unknown-named', () => { + it('returns unknown-named for unrecognized /stacks//... suffixes', () => { + expect(classifyStackApiPath('GET', '/stacks/web/weird')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('POST', '/stacks/web/not-a-real-action')).toEqual({ + kind: 'unknown-named', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'unknown-named', + }); + }); + + it('returns unknown-named for invalid stack name segments', () => { + expect(classifyStackApiPath('GET', '/stacks/bad name')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('GET', '/stacks/%2E%2E')).toEqual({ kind: 'unknown-named' }); + }); + }); +}); + +describe('scoped stack actions header encode/decode', () => { + it('round-trips a PermissionAction set', () => { + const actions: PermissionAction[] = ['stack:edit', 'stack:deploy', 'stack:read']; + const encoded = formatScopedStackActionsHeader(actions); + expect(parseScopedStackActionsHeader(encoded)).toEqual(actions); + }); + + it('returns null for malformed tokens', () => { + expect(parseScopedStackActionsHeader('stack:edit,not-a-real-action')).toBeNull(); + expect(parseScopedStackActionsHeader('')).toBeNull(); + expect(parseScopedStackActionsHeader(' ')).toBeNull(); + }); + + it('deduplicates while preserving first-seen order', () => { + expect(parseScopedStackActionsHeader('stack:edit,stack:deploy,stack:edit')).toEqual([ + 'stack:edit', + 'stack:deploy', + ]); + }); +}); diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts index 1ed76e59..94421b04 100644 --- a/backend/src/__tests__/suppression-routes.test.ts +++ b/backend/src/__tests__/suppression-routes.test.ts @@ -114,13 +114,13 @@ describe('POST /api/security/suppressions', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('rejects users without stack:edit with 403', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', viewerAuthHeader) .send(validBody); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('is accessible on community tier (admin still required)', async () => { diff --git a/backend/src/__tests__/system-maintenance-prune.test.ts b/backend/src/__tests__/system-maintenance-prune.test.ts index d7d5e021..7ca13686 100644 --- a/backend/src/__tests__/system-maintenance-prune.test.ts +++ b/backend/src/__tests__/system-maintenance-prune.test.ts @@ -16,12 +16,16 @@ let app: import('express').Express; let authHeader: string; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let CacheService: typeof import('../services/CacheService').CacheService; +let activeBulkActions: typeof import('../helpers/bulkActionLocks').activeBulkActions; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); + ({ CacheService } = await import('../services/CacheService')); + ({ activeBulkActions } = await import('../helpers/bulkActionLocks')); // 10-minute expiry survives the full file even when two timeout tests // burn ~8.5s each in real-timer mode. const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' }); @@ -31,6 +35,7 @@ beforeAll(async () => { afterAll(() => cleanupTestDb(tmpDir)); afterEach(() => { + activeBulkActions.clear(); vi.restoreAllMocks(); }); @@ -150,6 +155,7 @@ describe('Prune plan routes', () => { buildPrunePlan: vi.fn().mockResolvedValue(plan), executePrunePlan, } as unknown as ReturnType); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate'); const res = await request(app) .post('/api/system/prune/system') @@ -161,6 +167,45 @@ describe('Prune plan routes', () => { expect(res.body.reclaimedBytes).toBe(42); expect(res.body.outcomes).toHaveLength(1); expect(executePrunePlan).toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith('stats:1'); + expect(invalidate).toHaveBeenCalledWith('stack-statuses:1'); + }); + + it('rejects an overlapping destructive prune on the same node', async () => { + stubFsStacks(); + const plan = samplePlan('fp-lock'); + let releaseExecution!: () => void; + const executionBlocked = new Promise((resolve) => { + releaseExecution = resolve; + }); + const executePrunePlan = vi.fn().mockImplementation(async () => { + await executionBlocked; + return { outcomes: [], reclaimedBytes: 0, success: true }; + }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(plan), + executePrunePlan, + } as unknown as ReturnType); + + const firstRequest = request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' }); + const firstResponse = firstRequest.then((response) => response); + await vi.waitFor(() => expect(executePrunePlan).toHaveBeenCalledTimes(1)); + + const overlapping = await request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' }); + + expect(overlapping.status).toBe(409); + expect(overlapping.body.code).toBe('PRUNE_ALREADY_RUNNING'); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + + releaseExecution(); + expect((await firstResponse).status).toBe(200); + expect(activeBulkActions.size).toBe(0); }); it('POST /api/system/prune/system returns 409 PRUNE_PLAN_STALE on fingerprint mismatch', async () => { diff --git a/backend/src/__tests__/system-maintenance-self-protect.test.ts b/backend/src/__tests__/system-maintenance-self-protect.test.ts index 760d46f2..211d4342 100644 --- a/backend/src/__tests__/system-maintenance-self-protect.test.ts +++ b/backend/src/__tests__/system-maintenance-self-protect.test.ts @@ -13,6 +13,8 @@ let app: import('express').Express; let authHeader: string; let SelfIdentityService: typeof import('../services/SelfIdentityService').default; let DockerController: typeof import('../services/DockerController').default; +let ServiceUpdateRecoveryService: typeof import('../services/ServiceUpdateRecoveryService').ServiceUpdateRecoveryService; +let StackUpdateRecoveryService: typeof import('../services/StackUpdateRecoveryService').StackUpdateRecoveryService; const SELF_IMAGE = 'a'.repeat(64); const SELF_NETWORK = 'b'.repeat(64); @@ -26,6 +28,8 @@ beforeAll(async () => { ({ app } = await import('../index')); ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); ({ default: DockerController } = await import('../services/DockerController')); + ({ ServiceUpdateRecoveryService } = await import('../services/ServiceUpdateRecoveryService')); + ({ StackUpdateRecoveryService } = await import('../services/StackUpdateRecoveryService')); const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); authHeader = `Bearer ${token}`; }); @@ -48,6 +52,10 @@ function stubSelfIdentity(opts: { imageId?: string; networkId?: string; containe function stubDockerControllerNoops() { const fake = { removeImage: vi.fn().mockResolvedValue(undefined), + // Identity resolver by default: canonicalId === the submitted id, so + // existing removeImage(id) assertions keep working. Tests that need + // short-id canonicalization override this per-test. + resolveImageId: vi.fn().mockImplementation(async (id: string) => id), removeNetwork: vi.fn().mockResolvedValue(undefined), removeVolume: vi.fn().mockResolvedValue(undefined), removeContainers: vi.fn().mockResolvedValue([]), @@ -209,3 +217,71 @@ describe('Self-protection in dev mode (SelfIdentityService empty)', () => { }); }); +describe('Held-image protection on /api/system/images/delete', () => { + it('refuses to delete a rollback-held image with 409 IMAGE_HELD_FOR_ROLLBACK', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + const heldId = 'sha256:' + OTHER_IMAGE; + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set([heldId])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: heldId }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('IMAGE_HELD_FOR_ROLLBACK'); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); + + it('still deletes an unrelated image when a held predicate is active', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds') + .mockReturnValue(new Set(['sha256:' + 'z'.repeat(64)])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_IMAGE }); + + expect(res.status).toBe(200); + expect(docker.removeImage).toHaveBeenCalledWith(OTHER_IMAGE); + }); + + it('canonicalizes a short/truncated id before checking the held predicate, closing the bypass', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + const shortId = OTHER_IMAGE.slice(0, 12); + const canonicalId = 'sha256:' + OTHER_IMAGE; + docker.resolveImageId.mockImplementation(async (id: string) => (id === shortId ? canonicalId : id)); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set([canonicalId])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: shortId }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('IMAGE_HELD_FOR_ROLLBACK'); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); + + it('returns 404 when the image no longer exists', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + docker.resolveImageId.mockResolvedValue(null); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_IMAGE }); + + expect(res.status).toBe(404); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); +}); + diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index c5917576..d2777e71 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -9,11 +9,22 @@ import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { generateApiToken } from '../utils/apiTokenFormat'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; + +vi.mock('../helpers/assertStackExistsOnNode', () => ({ + assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })), +})); let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +function defaultNodeId(): number { + const node = DatabaseService.getInstance().getDefaultNode(); + if (!node) throw new Error('test default node missing'); + return node.id; +} + /** Sign a JWT for a given user with optional token_version (tv). */ function authToken(username: string, role: string = 'admin', tv?: number): string { const payload: Record = { username, role }; @@ -107,7 +118,7 @@ describe('POST /api/users', () => { .set('Authorization', `Bearer ${viewerToken}`) .send({ username: 'test999', password: 'password123', role: 'viewer' }); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('blocks API tokens (403 SCOPE_DENIED)', async () => { @@ -383,20 +394,103 @@ describe('Scoped Role Assignments', () => { }); it('POST /api/users/:id/roles creates assignment (201)', async () => { + const nodeId = defaultNodeId(); const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); expect(res.body.resource_type).toBe('stack'); + expect(res.body.node_id).toBe(nodeId); + }); + + it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/node_id/i); + }); + + it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => { + vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({ + ok: false, + error: 'Stack not found on node', + }); + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'stack', + resource_id: 'missing-stack', + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not found/i); + }); + + it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/must not be set/i); + }); + + it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: '999999', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Node not found/i); + }); + + it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + }); + expect(res.status).toBe(201); + expect(res.body.resource_type).toBe('node'); + expect(res.body.node_id).toBeNull(); + }); + + it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: `0${defaultNodeId()}`, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/canonical/i); }); it('POST /api/users/:id/roles rejects duplicate (409)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() }); expect(res.status).toBe(409); }); @@ -420,7 +514,7 @@ describe('Scoped Role Assignments', () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); } finally { @@ -452,7 +546,8 @@ describe('GET /api/permissions/me', () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + const nodeId = defaultNodeId(); + db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId }); const user = db.getUserById(id)!; const token = authToken('permcheck', 'viewer', user.token_version); @@ -461,7 +556,7 @@ describe('GET /api/permissions/me', () => { .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.globalRole).toBe('viewer'); - expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined(); + expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined(); // Cleanup db.deleteRoleAssignmentsByUser(id); @@ -474,7 +569,13 @@ describe('GET /api/permissions/me', () => { const svc = LicenseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + db.addRoleAssignment({ + user_id: id, + role: 'deployer', + resource_type: 'stack', + resource_id: 'my-stack', + node_id: defaultNodeId(), + }); const user = db.getUserById(id)!; const token = authToken('permcheck-community', 'viewer', user.token_version); @@ -689,53 +790,64 @@ describe('Atomic last-admin guard', () => { }); // ---- Orphaned Role Assignment Cleanup ---- +// Proxied remote stack DELETE clears hub grants only on 2xx in +// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows. +// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts; +// these cases lock the DB helper isolation that the proxy calls. describe('Orphaned role assignment cleanup', () => { - it('deleting a node removes its role assignments', async () => { + it('deleting a node removes its node and stack role assignments', async () => { const db = DatabaseService.getInstance(); - // Create a test node - const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false }); - // Create a role assignment for this node + const nodeId = db.addNode({ + name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId), + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'on-doomed', node_id: nodeId, + }); - // Verify assignment exists - const before = db.getAllRoleAssignments(userId); - expect(before.length).toBe(1); + expect(db.getAllRoleAssignments(userId)).toHaveLength(2); - // Delete the node db.deleteNode(nodeId); - // Assignments should be gone - const after = db.getAllRoleAssignments(userId); - expect(after.length).toBe(0); - - // Cleanup + expect(db.getAllRoleAssignments(userId)).toHaveLength(0); db.deleteUser(userId); }); - it('deleteRoleAssignmentsByResource removes only the matching resource tuple', async () => { + it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' }); - const nodeId = db.addNode({ - name: 'tuple-cleanup-node', type: 'remote', api_url: 'http://test:1852', + const nodeA = db.addNode({ + name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'target-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + const nodeB = db.addNode({ + name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) }); - db.deleteRoleAssignmentsByResource('stack', 'target-stack'); + db.deleteRoleAssignmentsByStack(nodeA, 'shared-name'); const after = db.getAllRoleAssignments(userId); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'target-stack')).toBe(false); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack')).toBe(true); - expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeId))).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true); + expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true); db.deleteUser(userId); - db.deleteNode(nodeId); + db.deleteNode(nodeA); + db.deleteNode(nodeB); }); }); @@ -778,6 +890,6 @@ describe('ROLE_PERMISSIONS enforcement via API', () => { .get('/api/users') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); }); diff --git a/backend/src/helpers/assertStackExistsOnNode.ts b/backend/src/helpers/assertStackExistsOnNode.ts new file mode 100644 index 00000000..6d4f0d0b --- /dev/null +++ b/backend/src/helpers/assertStackExistsOnNode.ts @@ -0,0 +1,81 @@ +import axios from 'axios'; +import { DatabaseService } from '../services/DatabaseService'; +import { FileSystemService } from '../services/FileSystemService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { PROXY_TIER_HEADER } from '../services/license-headers'; +import { LicenseService } from '../services/LicenseService'; +import { isValidStackName } from '../utils/validation'; +import { getErrorMessage } from '../utils/errors'; + +const REMOTE_STACKS_TIMEOUT_MS = 30_000; + +export type AssertStackExistsResult = + | { ok: true } + | { ok: false; error: string }; + +/** + * Verify that `stackName` exists on `nodeId` before inserting a stack-scoped + * role assignment. Local nodes use FileSystemService; remotes use a machine + * GET /api/stacks via NodeRegistry.getProxyTarget. + */ +export async function assertStackExistsOnNode( + nodeId: number, + stackName: string, +): Promise { + if (!isValidStackName(stackName)) { + return { ok: false, error: 'Invalid stack name' }; + } + + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + return { ok: false, error: 'Node not found' }; + } + + if (node.type === 'local') { + try { + const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + if (!stacks.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Local stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on node' }; + } + } + + const target = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!target) { + return { ok: false, error: 'Remote node is unreachable' }; + } + + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers: Record = { + [PROXY_TIER_HEADER]: LicenseService.getInstance().getProxyHeaders().tier, + }; + if (target.apiToken) { + headers.Authorization = `Bearer ${target.apiToken}`; + } + + try { + const res = await axios.get(`${baseUrl}/api/stacks`, { + headers, + timeout: REMOTE_STACKS_TIMEOUT_MS, + validateStatus: () => true, + }); + if (res.status < 200 || res.status >= 300) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + if (!Array.isArray(res.data)) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + const names = res.data.filter((n): n is string => typeof n === 'string'); + if (!names.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Remote stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on remote node' }; + } +} diff --git a/backend/src/helpers/bulkActionLocks.ts b/backend/src/helpers/bulkActionLocks.ts new file mode 100644 index 00000000..4d91cadb --- /dev/null +++ b/backend/src/helpers/bulkActionLocks.ts @@ -0,0 +1,3 @@ +// Process-local mutation locks shared by direct node actions and fleet-wide +// orchestration. Callers must use the same operation-specific key format. +export const activeBulkActions = new Set(); diff --git a/backend/src/helpers/fleetPrune.ts b/backend/src/helpers/fleetPrune.ts new file mode 100644 index 00000000..46bd4896 --- /dev/null +++ b/backend/src/helpers/fleetPrune.ts @@ -0,0 +1,597 @@ +import type { Node } from '../services/DatabaseService'; +import DockerController from '../services/DockerController'; +import { FileSystemService } from '../services/FileSystemService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { + PrunePlanStaleError, + hasOnlyPruneOwnershipLabels, + projectPruneOwnershipLabels, + type PruneItemOutcome, + type PrunePlan, + type PrunePlanItem, + type PruneScope, +} from '../services/prunePlan'; +import { invalidateNodeCaches } from './cacheInvalidation'; +import { getErrorMessage } from '../utils/errors'; +import { TimeoutError, withTimeout } from '../utils/withTimeout'; +import { formatNoTargetError } from '../utils/remoteTarget'; +import { sanitizeForLog } from '../utils/safeLog'; + +export const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; +export type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; + +export interface ReviewedFleetNode { + nodeId: number; + reachable: boolean; +} + +export interface ReviewedFleetPlan { + nodeId: number; + fingerprint: string; +} + +export interface FleetPruneTargetResult { + target: FleetPruneTarget; + success: boolean; + reclaimedBytes: number; + dryRun: boolean; + removed?: number; + skipped?: number; + failed?: number; + error?: string; +} + +export interface FleetPruneNodeResult { + nodeId: number; + nodeName: string; + reachable: boolean; + code?: string; + error?: string; + fingerprint?: string; + items?: PrunePlanItem[]; + reclaimableBytes?: number; + reclaimedBytes?: number; + outcomes?: PruneItemOutcome[]; + targets: FleetPruneTargetResult[]; +} + +export type ParsedFleetPruneRequest = { + targets: FleetPruneTarget[]; + scope: PruneScope; + dryRun: boolean; + reviewedNodes: ReviewedFleetNode[]; + plans: ReviewedFleetPlan[]; +}; + +type ParseResult = { request: ParsedFleetPruneRequest } | { error: string }; + +type Preflight = { + node: Node; + reachable: boolean; + plan?: PrunePlan; + code?: string; + error?: string; +}; + +type FleetPruneResponse = { + status: number; + body: { error?: string; code?: string; nodeId?: number; results?: FleetPruneNodeResult[] }; +}; + +const PLAN_TIMEOUT_MS = 8_000; +const REMOTE_PLAN_TIMEOUT_MS = 120_000; +const BUSY_DAEMON_ERROR = 'Docker daemon is busy. Please try again in a moment.'; + +function parseTargets(value: unknown): FleetPruneTarget[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const targets = new Set(); + for (const target of value) { + if (typeof target !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(target)) { + return null; + } + targets.add(target as FleetPruneTarget); + } + return [...targets]; +} + +function parseReviewedNodes(value: unknown): ReviewedFleetNode[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const parsed: ReviewedFleetNode[] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') return null; + const { nodeId, reachable } = entry as { nodeId?: unknown; reachable?: unknown }; + if (!Number.isInteger(nodeId) || typeof reachable !== 'boolean' || seen.has(nodeId as number)) return null; + seen.add(nodeId as number); + parsed.push({ nodeId: nodeId as number, reachable }); + } + return parsed; +} + +function parsePlans(value: unknown): ReviewedFleetPlan[] | null { + if (!Array.isArray(value)) return null; + const parsed: ReviewedFleetPlan[] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') return null; + const { nodeId, fingerprint } = entry as { nodeId?: unknown; fingerprint?: unknown }; + if (!Number.isInteger(nodeId) || typeof fingerprint !== 'string' || fingerprint.trim() === '' || seen.has(nodeId as number)) { + return null; + } + seen.add(nodeId as number); + parsed.push({ nodeId: nodeId as number, fingerprint: fingerprint.trim() }); + } + return parsed; +} + +export function parseFleetPruneRequest(body: unknown): ParseResult { + if (!body || typeof body !== 'object') return { error: 'Request body is required' }; + const input = body as Record; + const targets = parseTargets(input.targets); + if (!targets) return { error: 'targets must be a non-empty array of images, volumes, or networks' }; + if (input.scope !== 'managed' && input.scope !== 'all') return { error: 'scope must be managed or all' }; + const scope: PruneScope = input.scope; + const dryRun = input.dryRun === true; + if (dryRun) return { request: { targets, scope, dryRun, reviewedNodes: [], plans: [] } }; + const reviewedNodes = parseReviewedNodes(input.reviewedNodes); + const plans = parsePlans(input.plans); + if (!reviewedNodes || !plans) return { error: 'reviewedNodes and plans are required for fleet prune execution' }; + return { request: { targets, scope, dryRun, reviewedNodes, plans } }; +} + +function validateReviewedRoster( + nodes: Node[], + reviewedNodes: ReviewedFleetNode[], + plans: ReviewedFleetPlan[], +): string | null { + const currentIds = nodes.map((node) => node.id).sort((a, b) => a - b); + const reviewedIds = reviewedNodes.map((node) => node.nodeId).sort((a, b) => a - b); + if (currentIds.length !== reviewedIds.length || currentIds.some((id, index) => id !== reviewedIds[index])) { + return 'The fleet node roster changed after the dry run'; + } + const reachableIds = reviewedNodes.filter((node) => node.reachable).map((node) => node.nodeId).sort((a, b) => a - b); + const planIds = plans.map((plan) => plan.nodeId).sort((a, b) => a - b); + if (reachableIds.length !== planIds.length || reachableIds.some((id, index) => id !== planIds[index])) { + return 'Plans must exactly cover the reachable nodes from the dry run'; + } + return null; +} + +function targetRowsFromPlan(plan: PrunePlan, targets: FleetPruneTarget[]): FleetPruneTargetResult[] { + return targets.map((target) => ({ + target, + success: true, + reclaimedBytes: plan.items + .filter((item) => item.target === target) + .reduce((sum, item) => sum + (item.sizeBytes ?? 0), 0), + dryRun: true, + })); +} + +function failedTargetRows( + targets: FleetPruneTarget[], + error: string, + dryRun: boolean, +): FleetPruneTargetResult[] { + return targets.map((target) => ({ target, success: false, reclaimedBytes: 0, dryRun, error })); +} + +function isPrunePlan( + value: unknown, + targets: FleetPruneTarget[], + scope: PruneScope, +): value is PrunePlan { + if (!value || typeof value !== 'object') return false; + const plan = value as Partial; + const planTargets = Array.isArray(plan.targets) ? plan.targets : []; + const requestedTargets = new Set(targets); + const uniquePlanTargets = new Set(planTargets); + const itemKeys = new Set(); + const itemBytes = Array.isArray(plan.items) + ? plan.items.reduce((sum, item) => sum + (typeof item?.sizeBytes === 'number' ? item.sizeBytes : 0), 0) + : -1; + return plan.scope === scope + && planTargets.length === requestedTargets.size + && uniquePlanTargets.size === requestedTargets.size + && planTargets.every((target) => typeof target === 'string' && requestedTargets.has(target as FleetPruneTarget)) + && typeof plan.fingerprint === 'string' + && plan.fingerprint.length > 0 + && Number.isInteger(plan.nodeId) + && typeof plan.createdAt === 'number' && Number.isFinite(plan.createdAt) && plan.createdAt >= 0 + && typeof plan.reclaimableBytes === 'number' && Number.isFinite(plan.reclaimableBytes) && plan.reclaimableBytes >= 0 + && plan.reclaimableBytes === itemBytes + && Array.isArray(plan.items) + && plan.items.every((item) => { + if (!isPrunePlanItem(item) || !requestedTargets.has(item.target as FleetPruneTarget)) return false; + const key = `${item.target}\0${item.id}`; + if (itemKeys.has(key)) return false; + itemKeys.add(key); + return true; + }); +} + +function isPrunePlanItem(value: unknown): value is PrunePlanItem { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + const validTarget = typeof item.target === 'string' + && ['images', 'volumes', 'networks', 'containers'].includes(item.target); + const validSize = item.sizeBytes === undefined + || (typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes) && item.sizeBytes >= 0); + const targetMetadata = item.target === 'images' + ? Boolean(item.image && typeof item.image === 'object' + && Array.isArray((item.image as { references?: unknown }).references) + && (item.image as { references: unknown[] }).references.every((ref) => typeof ref === 'string')) + : item.target === 'volumes' + ? Boolean(item.volume && typeof item.volume === 'object') + : item.target === 'networks' + ? Boolean(item.network && typeof item.network === 'object') + : true; + return validTarget + && typeof item.id === 'string' + && typeof item.name === 'string' + && typeof item.managed === 'boolean' + && typeof item.reason === 'string' + && validSize + && targetMetadata + && hasOnlyPruneOwnershipLabels((item.volume as { ownershipLabels?: unknown } | undefined)?.ownershipLabels) + && hasOnlyPruneOwnershipLabels((item.network as { ownershipLabels?: unknown } | undefined)?.ownershipLabels); +} + +function projectRemoteItem(item: PrunePlanItem): PrunePlanItem { + const base = { + id: item.id, + name: item.name, + managed: item.managed, + reason: item.reason, + ...(typeof item.sizeBytes === 'number' ? { sizeBytes: item.sizeBytes } : {}), + ...(typeof item.stackName === 'string' ? { stackName: item.stackName } : {}), + }; + if (item.target === 'images') { + return { ...base, target: 'images', image: { + references: Array.isArray(item.image.references) + ? item.image.references.filter((reference): reference is string => typeof reference === 'string') + : [], + ...(typeof item.image.digest === 'string' ? { digest: item.image.digest } : {}), + ...(typeof item.image.createdAt === 'number' ? { createdAt: item.image.createdAt } : {}), + } }; + } + if (item.target === 'volumes') { + const ownershipLabels = projectPruneOwnershipLabels(item.volume.ownershipLabels); + return { ...base, target: 'volumes', volume: { + ...(typeof item.volume.driver === 'string' ? { driver: item.volume.driver } : {}), + ...(ownershipLabels ? { ownershipLabels } : {}), + } }; + } + if (item.target === 'networks') { + const ownershipLabels = projectPruneOwnershipLabels(item.network.ownershipLabels); + return { ...base, target: 'networks', network: { + ...(typeof item.network.driver === 'string' ? { driver: item.network.driver } : {}), + ...(typeof item.network.scope === 'string' ? { scope: item.network.scope } : {}), + ...(ownershipLabels ? { ownershipLabels } : {}), + } }; + } + return { ...base, target: 'containers' }; +} + +function projectRemotePlan(plan: PrunePlan): PrunePlan { + return { + scope: plan.scope, + targets: [...plan.targets], + items: plan.items.map(projectRemoteItem), + reclaimableBytes: plan.reclaimableBytes, + fingerprint: plan.fingerprint, + createdAt: plan.createdAt, + nodeId: plan.nodeId, + }; +} + +function isPruneItemOutcome(value: unknown): value is PruneItemOutcome { + if (!value || typeof value !== 'object') return false; + const outcome = value as { id?: unknown; target?: unknown; status?: unknown; sizeBytes?: unknown; reason?: unknown; error?: unknown }; + if (typeof outcome.id !== 'string' || typeof outcome.target !== 'string') return false; + if (outcome.status === 'removed') return outcome.sizeBytes === undefined + || (typeof outcome.sizeBytes === 'number' && Number.isFinite(outcome.sizeBytes) && outcome.sizeBytes >= 0); + if (outcome.status === 'skipped') return typeof outcome.reason === 'string'; + if (outcome.status === 'failed') return typeof outcome.error === 'string'; + return false; +} + +function validateRemoteOutcomes( + value: unknown, + plan: PrunePlan, + targets: FleetPruneTarget[], +): PruneItemOutcome[] | null { + if (!Array.isArray(value) || value.length !== plan.items.length) return null; + const requestedTargets = new Set(targets); + const expected = new Set(plan.items.map((item) => `${item.target}\0${item.id}`)); + const seen = new Set(); + for (const outcome of value) { + if (!isPruneItemOutcome(outcome) || !requestedTargets.has(outcome.target)) return null; + const key = `${outcome.target}\0${outcome.id}`; + if (!expected.has(key) || seen.has(key)) return null; + seen.add(key); + } + return seen.size === expected.size ? value : null; +} + +async function buildLocalPreflight(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + try { + const knownStacks = await FileSystemService.getInstance(node.id).getStacks(); + const controller = DockerController.getInstance(node.id); + const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(node.id); + const plan = await withTimeout( + controller.buildPrunePlan(targets, scope, knownStacks, node.id, isImageHeld), + PLAN_TIMEOUT_MS, + 'docker prune plan', + ); + return { node, reachable: true, plan }; + } catch (error) { + console.error(`[Fleet prune] Plan failed on ${sanitizeForLog(node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + return { + node, + reachable: true, + code: error instanceof TimeoutError ? 'DOCKER_DAEMON_BUSY' : 'PRUNE_PLAN_FAILED', + error: error instanceof TimeoutError ? BUSY_DAEMON_ERROR : getErrorMessage(error, 'Failed to build prune plan'), + }; + } +} + +async function fetchRemotePlan(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!proxyTarget) return { node, reachable: false, error: formatNoTargetError(node) }; + const headers: Record = { 'Content-Type': 'application/json' }; + if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; + try { + const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/plan`, { + method: 'POST', + headers, + body: JSON.stringify({ targets, scope }), + signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), + }); + const data: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = data && typeof data === 'object' && typeof (data as { error?: unknown }).error === 'string' + ? (data as { error: string }).error + : `Remote returned ${response.status}`; + return { node, reachable: true, code: 'REMOTE_PLAN_FAILED', error: message }; + } + if (!isPrunePlan(data, targets, scope)) { + return { node, reachable: true, code: 'REMOTE_PLAN_INVALID', error: 'Remote returned a malformed prune plan' }; + } + return { node, reachable: true, plan: projectRemotePlan(data) }; + } catch (error) { + console.error(`[Fleet prune] Remote plan transport failed for ${sanitizeForLog(node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + return { node, reachable: false, error: getErrorMessage(error, 'Failed to reach remote node') }; + } +} + +function buildPreflight(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + return node.type === 'local' + ? buildLocalPreflight(node, targets, scope) + : fetchRemotePlan(node, targets, scope); +} + +function preflightResult(entry: Preflight, targets: FleetPruneTarget[]): FleetPruneNodeResult { + if (entry.plan) { + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + fingerprint: entry.plan.fingerprint, + items: entry.plan.items, + reclaimableBytes: entry.plan.reclaimableBytes, + targets: targetRowsFromPlan(entry.plan, targets), + }; + } + const error = entry.error ?? 'Failed to build prune plan'; + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: entry.reachable, + code: entry.code, + error, + reclaimableBytes: 0, + targets: failedTargetRows(targets, error, true), + }; +} + +function outcomeTargetRows( + targets: FleetPruneTarget[], + outcomes: PruneItemOutcome[], + fallbackSuccess = true, +): FleetPruneTargetResult[] { + return targets.map((target) => { + const targetOutcomes = outcomes.filter((outcome) => outcome.target === target); + const failed = targetOutcomes.filter((outcome) => outcome.status === 'failed'); + const skipped = targetOutcomes.filter((outcome) => outcome.status === 'skipped'); + const removed = targetOutcomes.filter((outcome) => outcome.status === 'removed'); + const removedBytes = removed.reduce((sum, outcome) => sum + (outcome.status === 'removed' ? outcome.sizeBytes ?? 0 : 0), 0); + return { + target, + success: outcomes.length === 0 ? fallbackSuccess : failed.length === 0, + reclaimedBytes: outcomes.length === 0 ? 0 : removedBytes, + dryRun: false, + removed: removed.length, + skipped: skipped.length, + failed: failed.length, + error: failed.length > 0 ? failed.map((outcome) => outcome.status === 'failed' ? outcome.error : '').filter(Boolean).join('; ') : undefined, + }; + }); +} + +async function executeLocal(entry: Preflight, targets: FleetPruneTarget[]): Promise { + try { + const plan = entry.plan; + if (!plan) throw new Error('Local prune preflight is missing'); + const knownStacks = await FileSystemService.getInstance(entry.node.id).getStacks(); + const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(entry.node.id); + const result = await DockerController.getInstance(entry.node.id).executePrunePlan(plan, knownStacks, isImageHeld); + if (result.outcomes.some((outcome) => outcome.status === 'removed')) { + try { + invalidateNodeCaches(entry.node.id); + } catch (error) { + console.error(`[Fleet prune] Cache invalidation failed on ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + } + } + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + reclaimedBytes: result.reclaimedBytes, + outcomes: result.outcomes, + targets: outcomeTargetRows(targets, result.outcomes), + }; + } catch (error) { + console.error(`[Fleet prune] Execution failed on ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + const stale = error instanceof PrunePlanStaleError; + const message = getErrorMessage(error, stale ? 'Prune plan changed' : 'Prune failed'); + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + code: stale ? 'PRUNE_PLAN_STALE' : 'PRUNE_EXECUTE_FAILED', + error: message, + reclaimedBytes: 0, + targets: failedTargetRows(targets, message, false), + }; + } +} + +async function executeRemote(entry: Preflight, targets: FleetPruneTarget[], scope: PruneScope): Promise { + const plan = entry.plan; + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(entry.node.id); + if (!plan || !proxyTarget) { + const error = 'Node became unreachable after fleet preflight'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: false, error, + reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + const headers: Record = { 'Content-Type': 'application/json' }; + if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; + try { + const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/system`, { + method: 'POST', + headers, + body: JSON.stringify({ targets, scope, planFingerprint: plan.fingerprint }), + signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), + }); + const data: unknown = await response.json().catch(() => null); + const record = data && typeof data === 'object' ? data as Record : null; + if (!response.ok) { + const message = typeof record?.error === 'string' ? record.error : `Remote returned ${response.status}`; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: typeof record?.code === 'string' ? record.code : 'REMOTE_PRUNE_FAILED', + error: message, reclaimedBytes: 0, targets: failedTargetRows(targets, message, false), + }; + } + if (!record || typeof record.reclaimedBytes !== 'number' || !Number.isFinite(record.reclaimedBytes) + || record.reclaimedBytes < 0 || (record.success !== undefined && typeof record.success !== 'boolean')) { + const error = 'Remote returned a malformed prune result'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: 'REMOTE_PRUNE_INVALID', error, reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + const hasOutcomes = Object.prototype.hasOwnProperty.call(record, 'outcomes'); + const outcomes = hasOutcomes ? validateRemoteOutcomes(record.outcomes, plan, targets) : undefined; + if (hasOutcomes && !outcomes) { + const error = 'Remote returned malformed or incomplete prune outcomes'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: 'REMOTE_PRUNE_INVALID', error, reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + reclaimedBytes: record.reclaimedBytes, + outcomes: outcomes ?? undefined, + targets: outcomeTargetRows(targets, outcomes ?? [], record.success !== false), + }; + } catch (error) { + const message = getErrorMessage(error, 'Failed to reach remote node'); + console.error(`[Fleet prune] Remote execution transport failed for ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(message)}`); + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: false, + error: message, reclaimedBytes: 0, targets: failedTargetRows(targets, message, false), + }; + } +} + +function comparePreflight( + preflights: Preflight[], + reviewedNodes: ReviewedFleetNode[], + plans: ReviewedFleetPlan[], +): { code: string; error: string; nodeId?: number } | null { + const reviewedById = new Map(reviewedNodes.map((node) => [node.nodeId, node])); + const plansById = new Map(plans.map((plan) => [plan.nodeId, plan])); + for (const entry of preflights) { + const reviewed = reviewedById.get(entry.node.id); + if (!reviewed) return { code: 'PRUNE_NODE_ROSTER_CHANGED', error: 'The fleet node roster changed after the dry run' }; + if (entry.reachable !== reviewed.reachable) { + return { + code: 'PRUNE_NODE_REACHABILITY_CHANGED', + nodeId: entry.node.id, + error: `Reachability changed for ${entry.node.name} after the dry run`, + }; + } + if (!reviewed.reachable) continue; + if (!entry.plan) { + return { + code: entry.code ?? 'PRUNE_PLAN_FAILED', + nodeId: entry.node.id, + error: entry.error ?? `Failed to rebuild the plan for ${entry.node.name}`, + }; + } + if (entry.plan.fingerprint !== plansById.get(entry.node.id)?.fingerprint) { + return { + code: 'PRUNE_PLAN_STALE', + nodeId: entry.node.id, + error: `The prune plan changed on ${entry.node.name} after the dry run`, + }; + } + } + return null; +} + +export async function runFleetPrune( + nodes: Node[], + request: ParsedFleetPruneRequest, + activeLocks: Set, +): Promise { + if (request.dryRun) { + const preflights = await Promise.all(nodes.map((node) => buildPreflight(node, request.targets, request.scope))); + return { status: 200, body: { results: preflights.map((entry) => preflightResult(entry, request.targets)) } }; + } + + const rosterError = validateReviewedRoster(nodes, request.reviewedNodes, request.plans); + if (rosterError) return { status: 409, body: { code: 'PRUNE_NODE_ROSTER_CHANGED', error: rosterError } }; + + const lockKeys = nodes.filter((node) => node.type === 'local').map((node) => `bulk-prune:${node.id}`); + const busyKey = lockKeys.find((key) => activeLocks.has(key)); + if (busyKey) return { status: 409, body: { code: 'PRUNE_ALREADY_RUNNING', error: 'A prune is already running on a reviewed node' } }; + for (const key of lockKeys) activeLocks.add(key); + + try { + const preflights = await Promise.all(nodes.map((node) => buildPreflight(node, request.targets, request.scope))); + const conflict = comparePreflight(preflights, request.reviewedNodes, request.plans); + if (conflict) { + return { + status: 409, + body: { ...conflict, results: preflights.map((entry) => preflightResult(entry, request.targets)) }, + }; + } + const reviewedReachable = new Set(request.reviewedNodes.filter((node) => node.reachable).map((node) => node.nodeId)); + const executable = preflights.filter((entry) => reviewedReachable.has(entry.node.id)); + const results = await Promise.all(executable.map((entry) => entry.node.type === 'local' + ? executeLocal(entry, request.targets) + : executeRemote(entry, request.targets, request.scope))); + return { status: 200, body: { results } }; + } finally { + for (const key of lockKeys) activeLocks.delete(key); + } +} diff --git a/backend/src/helpers/hostMemory.ts b/backend/src/helpers/hostMemory.ts index d5858320..89b6174a 100644 --- a/backend/src/helpers/hostMemory.ts +++ b/backend/src/helpers/hostMemory.ts @@ -2,22 +2,30 @@ import si from 'systeminformation'; import { promises as fs } from 'fs'; /** - * Shared host-memory computation, ZFS ARC aware. + * Shared host-memory computation, ZFS ARC and VM-balloon aware. * * `systeminformation.mem()` derives `active` as `total - available` on * Linux/BSD/macOS, so keying usage off `active` already dodges page-cache - * inflation. It does NOT account for the OpenZFS ARC: the kernel's - * MemAvailable treats ARC as unavailable even though ARC shrinks under - * memory pressure, so on ZFS hosts a large ARC reads as hard-used memory - * and produces false host-memory alerts. + * inflation. It does NOT account for two sources of reclaimable memory: + * + * 1. **OpenZFS ARC**: the kernel's MemAvailable treats ARC as unavailable + * even though ARC shrinks under memory pressure. + * 2. **VM memory ballooning**: hypervisors (TrueNAS/KVM, Proxmox) reclaim + * guest memory through a balloon driver. The reclaimed amount appears in + * `/proc/meminfo` as `Balloon: N kB` but is invisible to + * `systeminformation.mem()`, so a ballooned VM can read as memory-critical + * when the guest is actually healthy. * * When ARC kstats are readable we add the reclaimable portion - * (`max(size - c_min, 0)`) back into available memory. On non-ZFS hosts, or - * when the kstat file is not readable inside the container, ARC is treated as - * zero and the result is identical to the previous `active / total` behavior. + * (`max(size - c_min, 0)`) back into available memory. When `/proc/meminfo` + * reports a nonzero `Balloon:` value we subtract the ballooned amount from + * used and recompute an effective usage percentage. On non-ZFS / non-VM + * hosts, or when the files are not readable inside the container, both + * adjustments resolve to zero and the result is identical to the previous + * `active / total` behavior. */ -/** Effective host memory after adding reclaimable ZFS ARC back into available. */ +/** Effective host memory after ARC and balloon adjustments. */ export interface HostMemory { total: number; /** Effective used bytes (ARC-adjusted). */ @@ -26,6 +34,20 @@ export interface HostMemory { free: number; /** Effective used as a percentage of total (0 when total is 0). */ usagePercent: number; + /** Reclaimable ARC bytes (from ZFS kstat). Present only when > 0. */ + arcReclaimable?: number; + /** Balloon-reclaimed bytes (from /proc/meminfo). Present only when > 0. */ + ballooned?: number; + /** Total memory (same as `total`; provided for symmetric UI code). */ + effectiveTotal?: number; + /** Used bytes after subtracting both ARC reclaim and balloon. */ + effectiveUsed?: number; + /** Free bytes after adding both ARC reclaim and balloon. */ + effectiveFree?: number; + /** Effective used as a percentage (balloon-adjusted). */ + effectiveUsagePercent?: number; + /** Source identifier for the balloon reading. */ + balloonSource?: 'linux_proc_meminfo'; } type MemData = Awaited>; @@ -40,18 +62,33 @@ export const ARCSTATS_FIXED_PATHS = [ '/proc/spl/kstat/zfs/arcstats', ]; -/** Bound reads of the operator-supplied override path; arcstats is a few KB. */ -const MAX_ARCSTATS_BYTES = 1024 * 1024; +/** Bound reads of the operator-supplied override path; both files are a few KB. */ +const MAX_CANDIDATE_BYTES = 1024 * 1024; + +/** + * Candidate /proc/meminfo paths in priority order. The operator override is + * only present when SENCHO_PROC_MEMINFO_PATH is set; the two fixed paths are + * the host-mounted and the standard container-visible locations. + */ +export const MEMINFO_FIXED_PATHS = [ + '/host/proc/meminfo', + '/proc/meminfo', +]; // Memoized so a 30s monitor tick / dashboard poll does not log on every cycle. const loggedSelectedPaths = new Set(); const loggedErrorCodes = new Set(); -function overridePath(): string | undefined { +function arcOverridePath(): string | undefined { const raw = process.env.SENCHO_ZFS_ARCSTATS_PATH?.trim(); return raw ? raw : undefined; } +function meminfoOverridePath(): string | undefined { + const raw = process.env.SENCHO_PROC_MEMINFO_PATH?.trim(); + return raw ? raw : undefined; +} + function isExpectedFsError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException)?.code; return ( @@ -64,11 +101,39 @@ function isExpectedFsError(err: unknown): boolean { ); } -function logUnexpected(context: string, err: unknown): void { - const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; - if (loggedErrorCodes.has(code)) return; - loggedErrorCodes.add(code); - console.warn(`[HostMemory] Unexpected error reading ARC stats (${context}, ${code}); treating ARC as reclaimable=0`); +function logSelectedPath(path: string, label: string): void { + if (loggedSelectedPaths.has(path)) return; + loggedSelectedPaths.add(path); + console.debug(`[HostMemory] Using ${label} from ${path}`); +} + +/** + * Read a single candidate file, applying the operator-override guard (regular + * file, bounded size) and the fail-open error contract. Returns the raw text, + * or undefined when the path is unusable so the caller falls through to the + * next candidate. Unexpected errors are logged once per code. + */ +async function readCandidateFile(path: string, isOverride: boolean): Promise { + try { + // The override path is operator-supplied: verify it is a regular file of + // bounded size before reading (guards against a named pipe or an + // accidentally huge target). The fixed paths are trusted. + if (isOverride) { + const info = await fs.stat(path); + if (!info.isFile() || info.size > MAX_CANDIDATE_BYTES) return undefined; + } + return await fs.readFile(path, 'utf8'); + } catch (err) { + // Fail open: a missing or unreadable file is the normal non-ZFS/non-VM + // case (expected fs errors); an unexpected error is logged once but still + // falls through so the adjustment can only lower a false positive. + if (isExpectedFsError(err)) return undefined; + const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + if (loggedErrorCodes.has(code)) return undefined; + loggedErrorCodes.add(code); + console.warn(`[HostMemory] Unexpected error reading ${path} (${code}); treating as 0`); + return undefined; + } } /** Parse the kstat table for the `size` and `c_min` rows (` `). */ @@ -84,41 +149,62 @@ function parseArcstats(raw: string): { size?: number; cMin?: number } { return { size, cMin }; } +/** + * Parse a /proc/meminfo body for the `Balloon:` line. Returns bytes, or + * undefined when the field is absent/malformed. Only the standard ` kB` + * format is recognized. + */ +function parseMeminfoBalloon(raw: string): number | undefined { + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('Balloon:')) continue; + const parts = trimmed.split(/\s+/); + // Expect "Balloon: kB" (3 tokens). + if (parts.length !== 3 || parts[2] !== 'kB') return undefined; + const value = Number(parts[1]); + if (!Number.isFinite(value) || value < 0) return undefined; + return value * 1024; + } + return undefined; +} + +/** + * Ballooned memory in bytes, or 0 when the meminfo field is absent/unusable. + * Never throws: any error resolves to 0 so balloon awareness can only lower a + * false-positive reading, never break host-memory reporting. + */ +async function readBalloonedMemory(): Promise { + const override = meminfoOverridePath(); + const candidates = override ? [override, ...MEMINFO_FIXED_PATHS] : MEMINFO_FIXED_PATHS; + for (const candidatePath of candidates) { + const raw = await readCandidateFile(candidatePath, candidatePath === override); + if (raw === undefined) continue; + const ballooned = parseMeminfoBalloon(raw); + if (ballooned === undefined) continue; + logSelectedPath(candidatePath, 'meminfo'); + return ballooned; + } + return 0; +} + /** * Reclaimable ARC in bytes, or 0 when ARC stats are unavailable/unusable. * Never throws: any error resolves to 0 so ARC awareness can only lower a * false-positive reading, never break host-memory reporting. */ async function readReclaimableArc(): Promise { - const override = overridePath(); + const override = arcOverridePath(); const candidates = override ? [override, ...ARCSTATS_FIXED_PATHS] : ARCSTATS_FIXED_PATHS; for (const candidatePath of candidates) { - try { - // The override path is operator-supplied: verify it is a regular file - // of bounded size before reading (guards against a named pipe or an - // accidentally huge target). The fixed kstat paths are trusted. - if (candidatePath === override) { - const info = await fs.stat(candidatePath); - if (!info.isFile() || info.size > MAX_ARCSTATS_BYTES) continue; - } - const raw = await fs.readFile(candidatePath, 'utf8'); - const { size, cMin } = parseArcstats(raw); - if (size === undefined || cMin === undefined) continue; - if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue; - // A valid record resolves the lookup, even when reclaimable is 0 - // (size < c_min means ARC is at its floor). - if (!loggedSelectedPaths.has(candidatePath)) { - loggedSelectedPaths.add(candidatePath); - console.debug(`[HostMemory] Using ZFS ARC stats from ${candidatePath}`); - } - return Math.max(size - cMin, 0); - } catch (err) { - // Fail open: a missing or unreadable kstat is the normal non-ZFS case - // (expected fs errors); an unexpected error is logged once but still - // falls through so ARC awareness can only lower a false positive. - if (isExpectedFsError(err)) continue; - logUnexpected(candidatePath, err); - } + const raw = await readCandidateFile(candidatePath, candidatePath === override); + if (raw === undefined) continue; + const { size, cMin } = parseArcstats(raw); + if (size === undefined || cMin === undefined) continue; + if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue; + // A valid record resolves the lookup, even when reclaimable is 0 + // (size < c_min means ARC is at its floor). + logSelectedPath(candidatePath, 'ZFS ARC stats'); + return Math.max(size - cMin, 0); } return 0; } @@ -131,11 +217,79 @@ export function adjustForArc(mem: Pick, arcRecla const effectiveAvailable = Math.min(mem.total, mem.available + Math.max(arcReclaimable, 0)); const effectiveUsed = Math.max(mem.total - effectiveAvailable, 0); const usagePercent = mem.total > 0 ? (effectiveUsed / mem.total) * 100 : 0; - return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent }; + if (arcReclaimable <= 0) return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent }; + return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent, arcReclaimable }; } -/** Fetch host memory and reclaimable ARC concurrently, return the adjusted view. */ -export async function getHostMemory(): Promise { - const [mem, arcReclaimable] = await Promise.all([si.mem(), readReclaimableArc()]); - return adjustForArc(mem, arcReclaimable); +/** + * Balloon adjustment layer. Applies on top of the ARC-adjusted result. + * When `ballooned <= 0` the input is returned unchanged, preserving exact + * `.toEqual()` backward compatibility for every existing test assertion. + */ +export function adjustForBalloon(hostMem: HostMemory, ballooned: number): HostMemory { + if (ballooned <= 0) return hostMem; + const effectiveUsed = Math.max(hostMem.used - ballooned, 0); + const effectiveFree = Math.min(hostMem.free + ballooned, hostMem.total); + const effectiveUsagePercent = hostMem.total > 0 + ? (effectiveUsed / hostMem.total) * 100 + : 0; + return { + ...hostMem, + ballooned, + effectiveTotal: hostMem.total, + effectiveUsed, + effectiveFree, + effectiveUsagePercent, + balloonSource: 'linux_proc_meminfo' as const, + }; +} + +/** Wire shape of host memory as served by /api/system/stats and fleet overviews. */ +export interface MemoryWire { + total: number; + used: number; + free: number; + usagePercent: string; + arcReclaimable?: number; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; +} + +/** + * Map adjusted host memory to the wire shape. Balloon fields are only + * included when a balloon reading was present, so the non-VM shape stays + * identical to the pre-balloon wire format. + */ +export function memoryToWire(hostMem: HostMemory): MemoryWire { + return { + total: hostMem.total, + used: hostMem.used, + free: hostMem.free, + usagePercent: hostMem.usagePercent.toFixed(1), + ...(hostMem.arcReclaimable !== undefined ? { arcReclaimable: hostMem.arcReclaimable } : {}), + ...(hostMem.ballooned !== undefined + ? { + ballooned: hostMem.ballooned, + effectiveTotal: hostMem.effectiveTotal, + effectiveUsed: hostMem.effectiveUsed, + effectiveFree: hostMem.effectiveFree, + effectiveUsagePercent: hostMem.effectiveUsagePercent?.toFixed(1), + balloonSource: hostMem.balloonSource, + } + : {}), + }; +} + +/** Fetch host memory, reclaimable ARC, and ballooned memory concurrently. */ +export async function getHostMemory(): Promise { + const [mem, arcReclaimable, ballooned] = await Promise.all([ + si.mem(), + readReclaimableArc(), + readBalloonedMemory(), + ]); + return adjustForBalloon(adjustForArc(mem, arcReclaimable), ballooned); } diff --git a/backend/src/helpers/stackRouteAuth.ts b/backend/src/helpers/stackRouteAuth.ts new file mode 100644 index 00000000..8d567a59 --- /dev/null +++ b/backend/src/helpers/stackRouteAuth.ts @@ -0,0 +1,239 @@ +import { isPermissionAction, type PermissionAction } from '../middleware/permissions'; +import { isValidStackName } from '../utils/validation'; + +export type StackRouteClassify = + | { kind: 'named-stack'; stackName: string; action: PermissionAction } + | { kind: 'static' } + | { kind: 'unknown-named' }; + +/** Static collection / create paths under /stacks (no stack-scoped resource). */ +const STATIC_STACK_PATHS = new Set([ + '/stacks', + '/stacks/', + '/stacks/statuses', + '/stacks/discovery', + '/stacks/import/scan', + '/stacks/import/move', + '/stacks/bulk', + '/stacks/from-git', +]); + +/** + * Exact relative suffixes under `/stacks/:name` mapped to the primary hub + * pre-check action. Service-name paths are matched separately via regex. + */ +type SuffixRule = { method: string; suffix: string; action: PermissionAction }; + +const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [ + // Read + { method: 'GET', suffix: '', action: 'stack:read' }, + { method: 'GET', suffix: '/envs', action: 'stack:read' }, + { method: 'GET', suffix: '/env', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files/candidates', action: 'stack:read' }, + { method: 'GET', suffix: '/dossier', action: 'stack:read' }, + { method: 'GET', suffix: '/containers', action: 'stack:read' }, + { method: 'GET', suffix: '/services', action: 'stack:read' }, + { method: 'GET', suffix: '/drift', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight', action: 'stack:read' }, + { method: 'GET', suffix: '/missing-external-networks', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight/acknowledgements', action: 'stack:read' }, + { method: 'GET', suffix: '/networking', action: 'stack:read' }, + { method: 'GET', suffix: '/storage', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-anatomy', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-services', action: 'stack:read' }, + { method: 'GET', suffix: '/env-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/label-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/exposure', action: 'stack:read' }, + { method: 'GET', suffix: '/update-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/rollback-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/health-gate', action: 'stack:read' }, + { method: 'GET', suffix: '/update-preview', action: 'stack:read' }, + { method: 'GET', suffix: '/backup', action: 'stack:read' }, + { method: 'GET', suffix: '/scan-status', action: 'stack:read' }, + { method: 'GET', suffix: '/file-roots', action: 'stack:read' }, + { method: 'GET', suffix: '/files', action: 'stack:read' }, + { method: 'GET', suffix: '/files/content', action: 'stack:read' }, + { method: 'GET', suffix: '/files/download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/bulk-download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/permissions', action: 'stack:read' }, + { method: 'GET', suffix: '/activity', action: 'stack:read' }, + { method: 'GET', suffix: '/git-source', action: 'stack:read' }, + + // Edit + { method: 'PUT', suffix: '', action: 'stack:edit' }, + { method: 'PUT', suffix: '/env', action: 'stack:edit' }, + { method: 'PUT', suffix: '/project-env-files', action: 'stack:edit' }, + { method: 'PUT', suffix: '/dossier', action: 'stack:edit' }, + { method: 'POST', suffix: '/drift/recheck', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/run', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/acknowledgements', action: 'stack:edit' }, + { method: 'PUT', suffix: '/exposure', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/upload', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/content', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/files', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/folder', action: 'stack:edit' }, + { method: 'PATCH', suffix: '/files/rename', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/copy', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-delete', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-move', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/permissions', action: 'stack:edit' }, + { method: 'PUT', suffix: '/labels', action: 'stack:edit' }, + { method: 'PUT', suffix: '/git-source', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/git-source', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/apply', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' }, + + // Deploy + { method: 'POST', suffix: '/deploy', action: 'stack:deploy' }, + { method: 'POST', suffix: '/down', action: 'stack:deploy' }, + { method: 'POST', suffix: '/restart', action: 'stack:deploy' }, + { method: 'POST', suffix: '/stop', action: 'stack:deploy' }, + { method: 'POST', suffix: '/start', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update-preview', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update', action: 'stack:deploy' }, + { method: 'POST', suffix: '/rollback', action: 'stack:deploy' }, + { method: 'POST', suffix: '/backup', action: 'stack:deploy' }, + + // Delete + { method: 'DELETE', suffix: '', action: 'stack:delete' }, +]; + +const EXACT_SUFFIX_INDEX = new Map( + EXACT_SUFFIX_RULES.map((r) => [`${r.method} ${r.suffix}`, r.action]), +); + +/** `/services/:serviceName/{restart|stop|start|update|restore|recovery}` */ +const SERVICE_SUFFIX_RE = + /^\/services\/[^/]+\/(restart|stop|start|update|restore|recovery)$/; + +/** `/preflight/acknowledgements/:id` */ +const PREFLIGHT_ACK_DELETE_RE = /^\/preflight\/acknowledgements\/[^/]+$/; + +function normalizePath(pathAfterApiStrip: string): string { + const withoutQuery = pathAfterApiStrip.split('?')[0] ?? pathAfterApiStrip; + if (withoutQuery.length > 1 && withoutQuery.endsWith('/')) { + return withoutQuery.slice(0, -1); + } + return withoutQuery; +} + +function decodeStackSegment(raw: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + if (!isValidStackName(decoded)) return null; + return decoded; +} + +/** + * Classify a post-/api path for hub stack RBAC gating and evidence. + * Paths outside `/stacks` and `/image-updates/refresh/` (and static + * `/stacks` collection routes) are `static`. Known named-stack families + * return the primary pre-check action. An unrecognized + * `/stacks//...` path fails closed as `unknown-named`. + */ +export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify { + const methodUpper = method.toUpperCase(); + const path = normalizePath(pathAfterApiStrip); + + if (!path.startsWith('/stacks') && !path.startsWith('/image-updates/refresh/')) { + return { kind: 'static' }; + } + + if (STATIC_STACK_PATHS.has(path) || (methodUpper === 'POST' && path === '/stacks')) { + return { kind: 'static' }; + } + + // Reserved first segments that look like names but are collection routes. + if ( + path === '/stacks/statuses' + || path === '/stacks/discovery' + || path.startsWith('/stacks/import/') + || path === '/stacks/bulk' + || path === '/stacks/from-git' + ) { + return { kind: 'static' }; + } + + // /image-updates/refresh/:stackName → per-stack image check (stack:deploy). + // This branch runs before the /stacks/-only regex, which would never match. + // Unknown sub-paths under this prefix fail closed (unknown-named), matching + // the fail-closed behavior for unknown /stacks//... paths. + if (path.startsWith('/image-updates/refresh/')) { + const imageRefreshMatch = /^\/image-updates\/refresh\/([^/]+)$/.exec(path); + if (imageRefreshMatch) { + const stackName = decodeStackSegment(imageRefreshMatch[1]); + if (!stackName) return { kind: 'unknown-named' }; + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + return { kind: 'unknown-named' }; + } + + const match = /^\/stacks\/([^/]+)(.*)$/.exec(path); + if (!match) { + return { kind: 'static' }; + } + + const stackName = decodeStackSegment(match[1]); + if (!stackName) { + return { kind: 'unknown-named' }; + } + + const suffix = match[2] ?? ''; + + const exact = EXACT_SUFFIX_INDEX.get(`${methodUpper} ${suffix}`); + if (exact) { + return { kind: 'named-stack', stackName, action: exact }; + } + + if (methodUpper === 'POST' && SERVICE_SUFFIX_RE.test(suffix)) { + const op = SERVICE_SUFFIX_RE.exec(suffix)?.[1]; + if (op === 'recovery') { + // recovery is GET-only in stacks.ts; POST recovery is unknown + return { kind: 'unknown-named' }; + } + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'GET' && /^\/services\/[^/]+\/recovery$/.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'DELETE' && PREFLIGHT_ACK_DELETE_RE.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:edit' }; + } + + return { kind: 'unknown-named' }; +} + +/** + * Parse the comma-separated scoped-actions header. Returns null when empty + * or when any token is not a known PermissionAction. + */ +export function parseScopedStackActionsHeader(value: string): PermissionAction[] | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parts = trimmed.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return null; + const actions: PermissionAction[] = []; + const seen = new Set(); + for (const part of parts) { + if (!isPermissionAction(part)) return null; + if (seen.has(part)) continue; + seen.add(part); + actions.push(part); + } + return actions; +} + +/** Serialize PermissionAction values for the scoped-actions proxy header. */ +export function formatScopedStackActionsHeader(actions: Iterable): string { + return [...new Set(actions)].join(','); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 86434dc2..c23da8ed 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,10 +8,20 @@ import { type ApiTokenScope, } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, + isDeploySourceHeader, +} from '../services/license-headers'; import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError'; import { isLicenseTier, normalizeTier } from '../services/license-normalize'; import { isDebugEnabled } from '../utils/debug'; +import { parseScopedStackActionsHeader } from '../helpers/stackRouteAuth'; +import { isValidStackName } from '../utils/validation'; import { COOKIE_NAME, MFA_PENDING_COOKIE_NAME, @@ -150,6 +160,19 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response req.deployContext = ctx; } + // Scoped stack auth evidence: only trust on this machine-auth path. + // Malformed or incomplete pairs are treated as absent (never as auth). + const scopedNameRaw = req.headers[PROXY_SCOPED_STACK_NAME_HEADER]; + const scopedActionsRaw = req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER]; + const scopedName = typeof scopedNameRaw === 'string' ? scopedNameRaw.trim() : ''; + const scopedActionsStr = typeof scopedActionsRaw === 'string' ? scopedActionsRaw : ''; + if (scopedName && isValidStackName(scopedName) && scopedActionsStr) { + const actions = parseScopedStackActionsHeader(scopedActionsStr); + if (actions && actions.length > 0) { + req.scopedStackEvidence = { stackName: scopedName, actions: new Set(actions) }; + } + } + next(); return; } diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 3c558205..a8c3bd41 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -1,11 +1,15 @@ import type { Request, Response } from 'express'; import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService'; +import type { LicenseTier } from '../services/license-types'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { effectiveTier } from './tierGates'; // --- Scoped RBAC Permission Engine (paid) --- +/** Permission subject decoupled from Express Request; used by in-process callers like the scheduler. */ +export interface PermissionSubject { username: string; role: UserRole; userId: number; } + export type PermissionAction = | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete' | 'node:read' | 'node:manage' @@ -34,12 +38,110 @@ export const ROLE_PERMISSIONS: Record = { ], }; +/** Canonical PermissionAction set (admin matrix covers every action). */ +export const ALL_PERMISSION_ACTIONS: readonly PermissionAction[] = ROLE_PERMISSIONS.admin; + +export function isPermissionAction(value: string): value is PermissionAction { + return (ALL_PERMISSION_ACTIONS as readonly string[]).includes(value); +} + +/** + * Collect stack:* actions from role matrices. Used for remote evidence so + * node:/system: never leave the hub on a machine-auth hop. + */ +function addStackActionsFromRole( + actions: Set, + role: UserRole, +): void { + for (const action of ROLE_PERMISSIONS[role] ?? []) { + if (action.startsWith('stack:')) { + actions.add(action); + } + } +} + +/** + * Union of PermissionAction values conferred by the user's exact stack + * grant for (nodeId, stackName), plus any node-scoped grant on that node + * (node-wide roles cover every stack on the node). Used when the hub + * builds bound evidence for a remote hop. + */ +export function scopedActionsForStack( + userId: number, + nodeId: number, + stackName: string, +): PermissionAction[] { + const db = DatabaseService.getInstance(); + const actions = new Set(); + for (const assignment of db.getRoleAssignments(userId, 'stack', stackName, nodeId)) { + addStackActionsFromRole(actions, assignment.role); + } + for (const assignment of db.getRoleAssignments(userId, 'node', String(nodeId))) { + addStackActionsFromRole(actions, assignment.role); + } + return [...actions]; +} + +/** + * Core permission resolver without a Request dependency. Admin bypasses + * all checks; scoped assignments only apply on the paid tier. Used by + * in-process callers (e.g. the scheduler) that have a subject + tier but + * no HTTP context. Does NOT handle scopedStackEvidence (machine-auth hop + * elevation) — that path requires a Request. + */ +export function checkPermissionForSubject( + subject: PermissionSubject, + tier: LicenseTier, + action: PermissionAction, + resourceType?: ResourceType, + resourceId?: string, + resourceNodeId?: number | null, +): boolean { + if (isDebugEnabled()) console.log('[RBAC:diag] checkPermissionForSubject:', sanitizeForLog(action), 'user:', sanitizeForLog(subject.username), 'globalRole:', sanitizeForLog(subject.role), 'resource:', sanitizeForLog(resourceType), sanitizeForLog(resourceId)); + + if (subject.role === 'admin') return true; + if (ROLE_PERMISSIONS[subject.role]?.includes(action)) return true; + + if (!resourceType || !resourceId) return false; + + if (tier !== 'paid') return false; + + const db = DatabaseService.getInstance(); + const nodeId = resourceType === 'stack' ? (resourceNodeId ?? undefined) : null; + const assignments = db.getRoleAssignments( + subject.userId, + resourceType, + resourceId, + nodeId as number | undefined, + ); + if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', subject.userId); + for (const assignment of assignments) { + if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; + } + + // Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on + // node N authorizes that role's stack actions for every stack on N. + if (resourceType === 'stack' && nodeId != null) { + const nodeAssignments = db.getRoleAssignments( + subject.userId, + 'node', + String(nodeId), + ); + for (const assignment of nodeAssignments) { + if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; + } + } + + return false; +} + /** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */ export function checkPermission( req: Request, action: PermissionAction, resourceType?: ResourceType, resourceId?: string, + resourceNodeId?: number | null, ): boolean { if (!req.user) return false; @@ -51,14 +153,54 @@ export function checkPermission( if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true; if (!resourceType || !resourceId) return false; - if (effectiveTier(req) !== 'paid') return false; - const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId); - if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId); + // Bound machine evidence from the hub (node_proxy / pilot_tunnel only). + // Authorizes exact stack + action members of the evidenced set without a + // local role_assignments row (remote userId is 0). + const evidence = req.scopedStackEvidence; + if ( + evidence + && resourceType === 'stack' + && resourceId === evidence.stackName + && action.startsWith('stack:') + && evidence.actions.has(action) + ) { + return true; + } + + const tier = effectiveTier(req); + if (tier !== 'paid') { + console.warn('[RBAC] Scoped assignment check blocked: effective tier is', sanitizeForLog(tier), 'license_status:', sanitizeForLog(DatabaseService.getInstance().getSystemState('license_status') ?? '')); + return false; + } + + const db = DatabaseService.getInstance(); + const nodeId = resourceType === 'stack' + ? (resourceNodeId === undefined ? req.nodeId : resourceNodeId) + : null; + const assignments = db.getRoleAssignments( + req.user.userId, + resourceType, + resourceId, + nodeId, + ); for (const assignment of assignments) { if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; } + // Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on + // node N authorizes that role's stack actions for every stack on N. + if (resourceType === 'stack' && nodeId != null) { + const nodeAssignments = db.getRoleAssignments( + req.user.userId, + 'node', + String(nodeId), + ); + for (const assignment of nodeAssignments) { + if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; + } + } + return false; } @@ -69,8 +211,9 @@ export function requirePermission( action: PermissionAction, resourceType?: ResourceType, resourceId?: string, + resourceNodeId?: number | null, ): boolean { - if (checkPermission(req, action, resourceType, resourceId)) return true; + if (checkPermission(req, action, resourceType, resourceId, resourceNodeId)) return true; res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); return false; } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index abef7123..bbb29531 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -1,17 +1,41 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { NodeRegistry } from '../services/NodeRegistry'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities'; -import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } from '../services/CapabilityRegistry'; +import { + STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, + SERVICE_SCOPED_UPDATE_CAPABILITY, + SERVICE_SCOPED_STACK_ALERT_CAPABILITY, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, +} from '../services/CapabilityRegistry'; import { getErrorMessage } from '../utils/errors'; import { DatabaseService } from '../services/DatabaseService'; import { redactSensitiveText } from '../utils/safeLog'; +import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming'; import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import { + checkPermission, + ROLE_PERMISSIONS, + scopedActionsForStack, +} from '../middleware/permissions'; +import type { PermissionAction } from '../middleware/permissions'; +import { SETTING_WRITE_PERMISSIONS } from '../routes/settings'; /** * Per-request hop timing for the critical hydration GETs, kept off the Request @@ -119,7 +143,9 @@ export function createRemoteProxyMiddleware(): RequestHandler { // re-set from the authenticated session (authGate runs before this proxy, // so req.user is always resolved here). proxyReq.removeHeader(PROXY_ROLE_HEADER); - if (req.user?.role) { + if (req.proxyElevatedRole) { + proxyReq.setHeader(PROXY_ROLE_HEADER, req.proxyElevatedRole); + } else if (req.user?.role) { proxyReq.setHeader(PROXY_ROLE_HEADER, req.user.role); } // Deploy provenance: always strip client-supplied values, then set @@ -132,6 +158,17 @@ export function createRemoteProxyMiddleware(): RequestHandler { if (req.user?.username) { proxyReq.setHeader(PROXY_DEPLOY_ACTOR_HEADER, req.user.username); } + // Scoped stack evidence: always strip client-supplied values, then + // attach hub-built evidence when the gate stashed elevation for this hop. + proxyReq.removeHeader(PROXY_SCOPED_STACK_NAME_HEADER); + proxyReq.removeHeader(PROXY_SCOPED_STACK_ACTIONS_HEADER); + if (req.proxyScopedStackEvidence) { + proxyReq.setHeader(PROXY_SCOPED_STACK_NAME_HEADER, req.proxyScopedStackEvidence.stackName); + proxyReq.setHeader( + PROXY_SCOPED_STACK_ACTIONS_HEADER, + formatScopedStackActionsHeader(req.proxyScopedStackEvidence.actions), + ); + } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request @@ -188,6 +225,23 @@ export function createRemoteProxyMiddleware(): RequestHandler { ) { invalidateFleetUpdateCache(); } + // Successful remote stack DELETE: clear hub grants for this (node, stack) + // only. Failed / non-2xx responses must preserve assignments. Use the + // gate-stashed classification: pathRewrite mutates req.url before this + // callback, so re-running classifyStackApiPath(req.path) would miss. + if (req.method === 'DELETE' && status >= 200 && status < 300) { + const route = req.proxyNamedStackRoute; + if (route?.action === 'stack:delete') { + try { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(req.nodeId, route.stackName); + } catch (cleanupErr) { + console.warn( + '[Proxy] Failed to clear role assignments after remote stack delete:', + getErrorMessage(cleanupErr, 'unknown'), + ); + } + } + } }, error: (err, req, proxyRes) => { // Finalize the hop timing with an error outcome before the existing @@ -282,6 +336,49 @@ export function createRemoteProxyMiddleware(): RequestHandler { } } + // Named-stack hub pre-check + optional scoped-evidence elevation. + const classified = classifyStackApiPath(req.method, req.path); + if (classified.kind === 'unknown-named') { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if (classified.kind === 'named-stack') { + if (!checkPermission(req, classified.action, 'stack', classified.stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + // Stash before pathRewrite so proxyRes DELETE cleanup can see the route. + req.proxyNamedStackRoute = { + stackName: classified.stackName, + action: classified.action, + }; + const globalRole = req.user?.role; + const globalGrantsPrimary = + globalRole === 'admin' + || (globalRole != null && (ROLE_PERMISSIONS[globalRole]?.includes(classified.action) ?? false)); + if (!globalGrantsPrimary) { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return; + } + if (!req.user) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + const actions = scopedActionsForStack(req.user.userId, req.nodeId, classified.stackName); + req.proxyScopedStackEvidence = { + stackName: classified.stackName, + actions, + }; + } + } + // POST /alerts bodies are not on req.body for remote hops (JSON parsing // is skipped so the stream can be piped). Buffer once under the same // 100 KB cap as express.json(), gate on service_name, then rewrite @@ -325,6 +422,169 @@ export function createRemoteProxyMiddleware(): RequestHandler { } } + // Settings-write pre-auth gate: when a non-admin, non-global-node-admin + // user writes settings on a remote node, the remote only sees the global + // role header and cannot verify scoped assignments. Check hub-side first + // and elevate PROXY_ROLE_HEADER to node-admin when the scoped check passes. + if (isSettingsWrite(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { + if (hasNonIdentityContentEncoding(req)) { + await drainRequestBody(req); + res.status(415).json({ + error: 'Compressed request bodies are not supported for remote settings writes', + code: 'encoding_unsupported', + }); + return; + } + try { + req.rawBody = await bufferRequestBody(req, SETTINGS_PROXY_BODY_LIMIT); + } catch (err) { + const status = Number((err as { status?: number }).status); + if (status === 413) { + res.status(413).json({ error: 'Settings payload too large', code: 'entity_too_large' }); + return; + } + if (status === 400) { + res.status(400).json({ error: 'Incomplete request body' }); + return; + } + throw err; + } + const needed = settingsBodyPermissions(req.rawBody); + // Fail-closed on empty/unparseable body: require hub-side node:manage on + // the target node (mirrors requireSettingsWritePermission's empty-keys + // branch in routes/settings.ts:62-68). A user with no scoped grant is + // denied; a user with a scoped grant passes through elevated. + let preAuthOk = true; + if (needed.length === 0) { + preAuthOk = checkNodeManageOnHub(req); + } else { + for (const action of needed) { + const ok = action === 'node:manage' + ? checkNodeManageOnHub(req) + : checkPermission(req, action); + if (!ok) { + preAuthOk = false; + break; + } + } + } + if (!preAuthOk) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + req.proxyElevatedRole = 'node-admin'; + } + + // Alerts POST scoped-evidence gate: when a non-admin, non-node-admin user + // creates a stack-scoped alert on a remote node, forward the scoped grant + // as evidence so the remote can authorize the write. The body was already + // buffered by the isAlertCreateRoute block above; this gate only inspects + // the stack_name field from the buffered JSON. Non-stack-scoped creates + // (no stack_name in body) pass through without evidence. + if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { + const globalGrantsEdit = + req.user?.role != null + && (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false); + if (!globalGrantsEdit) { + const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null; + if (stackName === undefined) { + // Body is non-empty but not valid JSON; client error, not auth. + console.error('[remoteNodeProxy] alert body is not valid JSON'); + res.status(400).json({ error: 'Request body is not valid JSON' }); + return; + } + if (stackName) { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return; + } + if (!checkPermission(req, 'stack:edit', 'stack', stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] }; + } + } + } + + // Auto-heal POST scoped-evidence gate: same pattern as alerts but + // auto-heal has no pre-existing body buffering, so this gate handles + // its own encoding rejection and buffering. + if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { + const globalGrantsEdit = + req.user?.role != null + && (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false); + if (!globalGrantsEdit) { + if (hasNonIdentityContentEncoding(req)) { + await drainRequestBody(req); + console.error('[remoteNodeProxy] auto-heal body rejected: compressed encoding'); + res.status(415).json({ + error: 'Compressed request bodies are not supported for remote auto-heal creates', + code: 'encoding_unsupported', + }); + return; + } + try { + req.rawBody = await bufferRequestBody(req, AUTO_HEAL_PROXY_BODY_LIMIT); + } catch (err) { + const status = Number((err as { status?: number }).status); + if (status === 413) { + console.error('[remoteNodeProxy] auto-heal body rejected as too large:', err); + res.status(413).json({ error: 'Auto-heal payload too large', code: 'entity_too_large' }); + return; + } + if (status === 400) { + console.error('[remoteNodeProxy] auto-heal body incomplete:', err); + res.status(400).json({ error: 'Incomplete request body' }); + return; + } + throw err; + } + const stackName = parseBodyStackName(req.rawBody); + if (stackName === undefined) { + console.error('[remoteNodeProxy] auto-heal body is not valid JSON'); + res.status(400).json({ error: 'Request body is not valid JSON' }); + return; + } + if (stackName) { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return; + } + if (!checkPermission(req, 'stack:edit', 'stack', stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] }; + } + } + } + + // Node-wide image refresh elevation gate: when a non-admin, non-node-admin + // user triggers a manual refresh on a remote node, check the hub-side + // scoped node:manage grant and elevate PROXY_ROLE_HEADER so the remote + // sees the user as node-admin for this hop (matching the pattern used + // for scoped Settings writes). + if (isImageRefreshNodeWide(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { + if (!checkNodeManageOnHub(req)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + req.proxyElevatedRole = 'node-admin'; + } + req.proxyTarget = target; beginProxyTiming(req, res); proxy(req, res, next); @@ -334,6 +594,50 @@ export function createRemoteProxyMiddleware(): RequestHandler { }; } +/** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */ +const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024; + +/** True when the request is a settings write destined for a remote node (path is post-/api strip). */ +function isSettingsWrite(req: Request): boolean { + if (req.method !== 'POST' && req.method !== 'PATCH') return false; + return /^\/settings\/?$/.test(req.path); +} + +/** + * Hub-side `node:manage` resolve against the active node so scoped Node Admin + * grants on the target remote node are detected before the hop. + */ +function checkNodeManageOnHub(req: Request): boolean { + if (typeof req.nodeId === 'number') { + return checkPermission(req, 'node:manage', 'node', String(req.nodeId)); + } + return checkPermission(req, 'node:manage'); +} + +/** + * Extract the set of required PermissionAction values from a buffered settings + * body. Returns the distinct actions for a valid body, or an empty array when + * the body is empty or JSON.parse fails (caller must then fall back to requiring + * checkNodeManageOnHub, fail-closed). + */ +function settingsBodyPermissions(rawBody: Buffer): PermissionAction[] { + if (rawBody.length === 0) return []; + try { + const parsed = JSON.parse(rawBody.toString('utf-8')) as Record; + // POST /api/settings sends { key, value }; PATCH sends a flat key/value map. + const keys = typeof parsed.key === 'string' ? [parsed.key] : Object.keys(parsed); + if (keys.length === 0) return []; + const needed = new Set(); + for (const key of keys) { + const action = SETTING_WRITE_PERMISSIONS[key]; + if (action) needed.add(action); + } + return [...needed]; + } catch { + return []; + } +} + /** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */ function isStackDownWithRemoveVolumes(req: Request): boolean { if (req.method !== 'POST') return false; @@ -360,6 +664,37 @@ function isAlertCreateRoute(req: Request): boolean { /** Same default as express.json(); remote alert creates must not exceed it. */ const ALERT_PROXY_BODY_LIMIT = 100 * 1024; +/** Same limit for auto-heal policy creates. */ +const AUTO_HEAL_PROXY_BODY_LIMIT = 100 * 1024; + +/** POST /auto-heal/policies (path is post-/api strip). */ +function isAutoHealCreateRoute(req: Request): boolean { + return req.method === 'POST' && /^\/auto-heal\/policies\/?$/.test(req.path); +} + +/** POST /image-updates/refresh with no stack-name segment (node-wide, not per-stack). */ +function isImageRefreshNodeWide(req: Request): boolean { + return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path); +} + +/** + * Extract the stack_name from a buffered JSON POST body. + * Returns a valid stack name, `null` when the field is absent or + * invalid (pass-through, remote enforces), or `undefined` when the + * body is not valid JSON (fail-closed, callers must 403). + */ +function parseBodyStackName(rawBody: Buffer): string | null | undefined { + if (rawBody.length === 0) return null; + try { + const parsed = JSON.parse(rawBody.toString('utf-8')) as { stack_name?: unknown }; + const raw = typeof parsed.stack_name === 'string' ? parsed.stack_name.trim() : ''; + if (!raw || !isValidStackName(raw)) return null; + return raw; + } catch { + return undefined; + } +} + /** Max time to wait for leftover body bytes after a size/encoding reject. */ const DRAIN_TIMEOUT_MS = 5_000; diff --git a/backend/src/routes/OPERATIONAL_PERMISSIONS.md b/backend/src/routes/OPERATIONAL_PERMISSIONS.md new file mode 100644 index 00000000..8ba06238 --- /dev/null +++ b/backend/src/routes/OPERATIONAL_PERMISSIONS.md @@ -0,0 +1,52 @@ +# Operational permission inventory + +This inventory is the authority for ordinary operational API authorization. A +route marked `exact` must include the target resource identity in the permission +check. Bulk routes must authorize every valid target before starting any work. + +| Route family | Read | Execute | Edit | Create | Delete | Scope | +| --- | --- | --- | --- | --- | --- | --- | +| `/api/stacks/:stackName` and named subroutes | `stack:read` | `stack:deploy` | `stack:edit` | n/a | `stack:delete` | exact stack and request node | +| `/api/stacks/bulk` | n/a | `stack:deploy` | n/a | n/a | n/a | every exact stack before execution | +| `/api/containers`, logs, and `/api/ports/in-use` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/containers/:containerId/start|stop|restart` | n/a | Admin | n/a | n/a | n/a | arbitrary container IDs can include unmanaged or Sencho containers | +| `/api/volumes/browse/*` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/templates` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/templates/:id/deploy` | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy | +| `/api/blueprints` | `node:read` | n/a | `stack:edit` | `stack:create` | `stack:delete` | global blueprint definition | +| Blueprint apply | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy | +| Blueprint accept or withdraw | n/a | exact `stack:deploy` | n/a | n/a | exact `stack:delete` | blueprint stack name and target node | +| Blueprint pin | n/a | n/a | exact `node:manage` | n/a | n/a | target node; unpin uses global node manage | +| `/api/nodes`, labels, metadata, and scheduling reads | `node:read` | n/a | n/a | n/a | n/a | exact node when a node ID is present | +| Node metadata, labels, cordon, and mesh enablement | n/a | n/a | exact `node:manage` | n/a | n/a | target node | +| Dependency map and networking reads | `node:read` | n/a | n/a | n/a | n/a | global or exact node as exposed by the route | +| Fleet label suggestions and match preview | `node:read` | n/a | n/a | n/a | n/a | global fleet discovery | +| Fleet stop by confirmed labels | n/a | exact `stack:deploy` | n/a | n/a | n/a | every target stack and node before fanout | +| Fleet bulk label assignment | n/a | n/a | exact `stack:edit` | n/a | n/a | every target stack and node before fanout | +| `/api/labels/:id/action` | n/a | exact `stack:deploy` | n/a | n/a | n/a | every resolved stack before mutation | +| Mesh status, aliases, diagnostics, and activity | `node:read` | n/a | n/a | n/a | n/a | global read | +| Mesh stack and override reads | exact `stack:read` | n/a | n/a | n/a | n/a | target stack and node | +| Mesh local override writes | n/a | n/a | exact `stack:edit` | n/a | n/a | target stack and node | +| Mesh membership changes | n/a | Admin | n/a | n/a | n/a | membership changes cascade redeploys across mesh stacks | +| Security scans for an image or stack | n/a | `stack:deploy` | n/a | n/a | n/a | exact stack when named, otherwise global | +| Node-wide security scan | n/a | `node:manage` | n/a | n/a | n/a | global, including remote proxy parity | +| SBOM, SARIF, VEX, and predeploy security reports | `stack:read` | n/a | n/a | n/a | n/a | exact stack when named, otherwise global | +| Security policies, suppressions, and acknowledgements | `stack:read` | n/a | `stack:edit` | n/a | n/a | global collection | +| Docker resource inventory and orphan reads | `stack:read` | n/a | n/a | n/a | n/a | global read | +| Network topology and inspection | `node:read` | n/a | n/a | n/a | n/a | global read | + +## Preserved system boundaries + +Literal Admin or the existing `system:*` permission remains required for user, +license, credential, API token, recovery, self-update, and sensitive system +settings. Host-destructive Docker operations also remain Admin-only, including +image, volume, network, resource, and fleet pruning. Reset-anchor and mesh-wide +membership cascades remain Admin-only because their effects are broader than one +ordinary node or stack permission check can safely authorize. + +## Frontend parity + +Navigation and controls use `can()` with the same action and resource identity. +Exact stack checks pass the stack name and node ID. Exact node checks pass the +node ID. System-only controls continue to use the Admin or `system:*` gate. UI +visibility is advisory; every backend route in this inventory enforces its gate. diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index 527ca960..b7994307 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { @@ -27,7 +27,8 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi }); agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + const nodeId = req.nodeId ?? 0; + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; try { const { type, url, enabled, config } = req.body; if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) { @@ -38,7 +39,6 @@ agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom res.status(400).json({ error: 'enabled must be a boolean' }); return; } - const nodeId = req.nodeId ?? 0; const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type); const effectiveUrl = url === undefined ? existing?.url : url; diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index 1b707c1a..3f319d74 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { isValidServiceName } from '../utils/validation'; import { getActiveCapabilities, @@ -28,10 +28,16 @@ const AlertCreateSchema = z.object({ export const alertsRouter = Router(); alertsRouter.get('/', authMiddleware, async (req: Request, res: Response) => { - try { - let stackName = req.query.stackName as string | undefined; - if (Array.isArray(stackName)) stackName = stackName[0] as string; + let stackName = req.query.stackName as string | undefined; + if (Array.isArray(stackName)) stackName = stackName[0] as string; + if (stackName) { + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + } else { + if (!requirePermission(req, res, 'stack:read')) return; + } + + try { const alerts = DatabaseService.getInstance().getStackAlerts(stackName); res.json(alerts); } catch (error) { @@ -41,12 +47,12 @@ alertsRouter.get('/', authMiddleware, async (req: Request, res: Response) => { }); alertsRouter.post('/', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; const parsed = AlertCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', parsed.data.stack_name)) return; const { service_name, ...alertFields } = parsed.data; const serviceName = service_name ?? null; if ( @@ -72,7 +78,6 @@ alertsRouter.post('/', authMiddleware, async (req: Request, res: Response) => { }); alertsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; // Reject leading-junk / fractional ids (parseInt("1abc") === 1, parseInt("2.5") === 2). const rawId = String(req.params.id ?? ''); const id = /^\d+$/.test(rawId) ? Number.parseInt(rawId, 10) : NaN; @@ -80,8 +85,15 @@ alertsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) res.status(400).json({ error: 'Invalid alert id' }); return; } + const db = DatabaseService.getInstance(); + const alert = db.getStackAlert(id); + if (!alert) { + res.status(404).json({ error: 'Alert not found' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', alert.stack_name)) return; try { - DatabaseService.getInstance().deleteStackAlert(id); + db.deleteStackAlert(id); res.json({ success: true }); } catch (error) { console.error('Failed to delete alert:', error); diff --git a/backend/src/routes/apiTokens.ts b/backend/src/routes/apiTokens.ts index 3267ec67..b03ea19b 100644 --- a/backend/src/routes/apiTokens.ts +++ b/backend/src/routes/apiTokens.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import crypto from 'crypto'; import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { isDebugEnabled } from '../utils/debug'; import { parseIntParam } from '../utils/parseIntParam'; @@ -16,7 +16,7 @@ export const apiTokensRouter = Router(); apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:tokens')) return; try { const { name, scope, expires_in } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { @@ -79,7 +79,7 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:tokens')) return; try { const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); if (!user) { res.status(500).json({ error: 'User not found.' }); return; } @@ -95,7 +95,7 @@ apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Pr apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:tokens')) return; try { const id = parseIntParam(req, res, 'id', 'token ID'); if (id === null) return; diff --git a/backend/src/routes/autoHeal.ts b/backend/src/routes/autoHeal.ts index edf06442..4320107a 100644 --- a/backend/src/routes/autoHeal.ts +++ b/backend/src/routes/autoHeal.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; @@ -31,6 +31,11 @@ function proxyEntitlementUntil(req: Request): number { autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined; + if (stackName) { + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + } else { + if (!requirePermission(req, res, 'stack:read')) return; + } try { const db = DatabaseService.getInstance(); const policies = db.getAutoHealPolicies(stackName, req.nodeId); @@ -50,12 +55,12 @@ autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): v }); autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const parsed = AutoHealPolicyCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', parsed.data.stack_name)) return; const { stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures } = parsed.data; const now = Date.now(); try { @@ -82,7 +87,6 @@ autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): }); autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body); @@ -94,6 +98,7 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.updateAutoHealPolicy(id, { ...parsed.data, proxy_entitled_until: Math.max(policy.proxy_entitled_until, proxyEntitlementUntil(req)) }); res.json(db.getAutoHealPolicy(id)); } catch (err) { @@ -103,13 +108,13 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon }); autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.deleteAutoHealPolicy(id); res.json({ success: true }); } catch (err) { @@ -126,6 +131,7 @@ autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res: const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', policy.stack_name)) return; res.json(db.getAutoHealHistory(id, limit)); } catch (err) { console.error('[AutoHeal] Failed to fetch history:', getErrorMessage(err, 'unknown')); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 84c113d8..76a53e59 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { DatabaseService, @@ -136,6 +136,7 @@ function summarizeBlueprint(blueprintId: number) { } blueprintsRouter.get('/', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const blueprints = DatabaseService.getInstance().listBlueprints(); const summaries = blueprints.map(b => { @@ -159,7 +160,7 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => { }); blueprintsRouter.post('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:create')) return; if (!requireBody(req, res)) return; const body = req.body as BlueprintBody; const nameError = validateName(body.name); @@ -198,6 +199,7 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => { }); blueprintsRouter.get('/:id', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -211,7 +213,7 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.put('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -293,7 +295,7 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:delete')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -461,7 +463,8 @@ blueprintsRouter.post('/withdraw-local', async (req: Request, res: Response): Pr }); blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:create')) return; + if (!requirePermission(req, res, 'stack:deploy')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -547,7 +550,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise }); blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const nodeId = parseIntParam(req, res, 'nodeId'); @@ -560,6 +562,7 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons try { const blueprint = DatabaseService.getInstance().getBlueprint(id); if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; } + if (!requirePermission(req, res, 'stack:delete', 'stack', blueprint.name, nodeId)) return; const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); return; } const isStateful = blueprint.classification === 'stateful' || blueprint.classification === 'unknown'; @@ -636,7 +639,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons }); blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const nodeId = parseIntParam(req, res, 'nodeId'); @@ -649,6 +651,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response) try { const blueprint = DatabaseService.getInstance().getBlueprint(id); if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; } + if (!requirePermission(req, res, 'stack:deploy', 'stack', blueprint.name, nodeId)) return; const guard = BlueprintReconciler.getInstance().validateGuardConfirmation(id, nodeId, 'accept'); if (!guard.ok) { res.status(409).json({ error: guard.error, code: guard.code }); @@ -665,6 +668,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response) }); blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -678,7 +682,6 @@ blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promis }); blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -692,6 +695,7 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; try { const nodeId = req.nodeId; const name = DatabaseService.getInstance().getNodes().find((n) => n.id === nodeId)?.name ?? 'This node'; diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index dc9c6091..2947df29 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -10,8 +10,7 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD import { NodeRegistry } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; -import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; -import { getHostMemory } from '../helpers/hostMemory'; +import { getHostMemory, memoryToWire, type MemoryWire } from '../helpers/hostMemory'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import { StackOpLockService } from '../services/StackOpLockService'; @@ -19,7 +18,7 @@ import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService'; import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates'; -import { requirePermission } from '../middleware/permissions'; +import { checkPermission, requirePermission } from '../middleware/permissions'; import { respondSelfUpdatePreflight } from './license'; import { ImageOperationService } from '../services/ImageOperationService'; import { classifyImageChannel } from '../helpers/imageChannel'; @@ -47,8 +46,14 @@ import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { formatNoTargetError } from '../utils/remoteTarget'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; -import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; +import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; import { activeBulkActions } from './labels'; +import { + FLEET_PRUNE_TARGETS, + parseFleetPruneRequest, + runFleetPrune, + type FleetPruneTarget, +} from '../helpers/fleetPrune'; import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop'; import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary'; import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign'; @@ -227,7 +232,7 @@ interface FleetNodeOverview { } | null; systemStats: { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: MemoryWire; disk: { total: number; used: number; free: number; usagePercent: string } | null; } | null; stacks: string[] | null; @@ -296,14 +301,9 @@ async function fetchLocalNodeOverview(node: Node): Promise { stats: { active, managed, unmanaged, exited, total }, systemStats: { cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length }, - memory: { - total: hostMem.total, - // ZFS ARC aware: reclaimable ARC is added back into available so a - // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. - used: hostMem.used, - free: hostMem.free, - usagePercent: hostMem.usagePercent.toFixed(1), - }, + // ARC/balloon aware: reclaimable ARC is added back into available, + // and ballooned memory is subtracted from used. See helpers/hostMemory.ts. + memory: memoryToWire(hostMem), disk: mainDisk ? { total: mainDisk.size, used: mainDisk.used, @@ -384,7 +384,7 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise interface RemoteSystemStats { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: MemoryWire; disk?: { total: number; used: number; free: number; usagePercent: string } | null; } @@ -1883,9 +1883,9 @@ type FleetStopNodeResult = { // on its own Docker via its local-stop receiver. Remote label rows are never // mirrored to the control, so there is no central pre-check; unreachable nodes // are reported at the node level and never block the reachable ones. -// Tier: requireAdmin (admin-only fleet plumbing; available on every license). +// Permission: every confirmed stack requires stack:deploy. Discovery-only dry +// runs require node:read. fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const body = req.body as { labelName?: unknown; dryRun?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -1929,6 +1929,19 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: } const trimmed = labelName.trim(); const isDryRun = dryRun === true; + if (confirmedStacksByNode) { + const denied = [...confirmedStacksByNode].some(([nodeId, stackNames]) => + [...stackNames].some(stackName => + !checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId))); + if (denied) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + } else if (isDryRun) { + if (!requirePermission(req, res, 'node:read')) return; + } else if (!requirePermission(req, res, 'stack:deploy')) { + return; + } try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -1962,7 +1975,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: } } - // Remote node. Ask the remote authoritatively via its admin-only local-stop + // Remote node. Ask the remote authoritatively via its permission-checked local-stop // receiver, which name-matches under the remote's own bulk lock. There is no // control-side pre-check: remote label rows are never mirrored to the // control, so a mirror lookup would skip every remote. A node we cannot @@ -2082,9 +2095,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: // Per-node failures (unknown node, no proxy target, unreachable, mixed-version // remote, malformed response) degrade that node only and never discard the rest // of the fan-out. -// Tier: requireAdmin (admin-only fleet plumbing; available on every license). +// Permission: every target stack requires stack:edit. fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const body = req.body as { label?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -2131,6 +2143,12 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` }); return; } + const denied = targets.some(target => target.stackNames.some(stackName => + !checkPermission(req, 'stack:edit', 'stack', stackName, target.nodeId))); + if (denied) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } const { template } = validated; try { const db = DatabaseService.getInstance(); @@ -2202,167 +2220,23 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res } }); -// Fleet-wide Docker prune. Fans out to every node, running per-target prune -// (images/volumes/networks) under the chosen scope. Local nodes call -// DockerController directly under a per-node bulk-prune lock; remote nodes -// receive one POST /api/system/prune/system per target via the standard -// Bearer-token path. Concurrent execution against the per-node prune route in -// systemMaintenance.ts is safe because Docker's prune API is internally -// serialized and idempotent (the worst case is a duplicate call returning 0 -// reclaimed bytes). +// Fleet-wide Docker prune. Dry runs collect one itemized plan per node. Execute +// validates the reviewed roster, preflights every plan, then starts mutation. // Tier: requireAdmin (admin-only fleet plumbing; available on every license). -const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; -type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; - fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - - const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined; - if (!body || typeof body !== 'object') { - res.status(400).json({ error: 'Request body is required' }); - return; - } - const rawTargets = Array.isArray(body.targets) ? body.targets : null; - if (!rawTargets || rawTargets.length === 0) { - res.status(400).json({ error: 'targets must be a non-empty array' }); - return; - } - const dedup = new Set(); - for (const t of rawTargets) { - if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { - res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); + try { + const parsed = parseFleetPruneRequest(req.body); + if ('error' in parsed) { + res.status(400).json({ error: parsed.error }); return; } - dedup.add(t as FleetPruneTarget); - } - const targets: FleetPruneTarget[] = Array.from(dedup); - const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; - const isDryRun = body.dryRun === true; - - type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }; - type NodeResult = { - nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; - }; - - try { - const db = DatabaseService.getInstance(); - const nodes = db.getNodes(); - if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-prune:', { targets, scope, dryRun: isDryRun, nodes: nodes.length }); - - const results: NodeResult[] = await Promise.all(nodes.map(async (node): Promise => { - if (node.type === 'local') { - const lockKey = `bulk-prune:${node.id}`; - if (activeBulkActions.has(lockKey)) { - return { - nodeId: node.id, nodeName: node.name, reachable: true, - targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' })), - }; - } - activeBulkActions.add(lockKey); - try { - const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; - const dockerController = DockerController.getInstance(node.id); - const targetResults: TargetResult[] = []; - let anySuccess = false; - for (const target of targets) { - try { - if (isDryRun) { - // estimateSystemReclaim hits `docker system df`; bound it - // so a slow local daemon doesn't hang the fleet admin tab - // (F-6). estimateManagedReclaim is fast (no df) and stays - // unwrapped. - const estimate = scope === 'managed' - ? await dockerController.estimateManagedReclaim(target, knownStacks) - : await withTimeout( - dockerController.estimateSystemReclaim(target, knownStacks), - FLEET_DF_TIMEOUT_MS, - 'docker disk usage', - ); - targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true }); - continue; - } - const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(node.id); - const result = scope === 'managed' - ? await dockerController.pruneManagedOnly(target, knownStacks, isImageHeld) - : await dockerController.pruneSystem(target, undefined, isImageHeld); - targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes }); - if (result.reclaimedBytes > 0 || result.success) anySuccess = true; - } catch (err) { - const error = err instanceof TimeoutError - ? 'Docker daemon is busy. Please try again in a moment.' - : getErrorMessage(err, 'Prune failed'); - targetResults.push({ target, success: false, reclaimedBytes: 0, error }); - } - } - if (anySuccess && !isDryRun) invalidateNodeCaches(node.id); - return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults }; - } finally { - activeBulkActions.delete(lockKey); - } - } - - // Remote node: POST /api/system/prune/system per target, short-circuiting - // on the first transport-level failure so we don't hammer a dead node. - const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!proxyTarget) { - const error = formatNoTargetError(node); - return { - nodeId: node.id, nodeName: node.name, reachable: false, error, - targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error })), - }; - } - const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); - const remoteHeaders: Record = { 'Content-Type': 'application/json' }; - if (proxyTarget.apiToken) remoteHeaders.Authorization = `Bearer ${proxyTarget.apiToken}`; - const targetResults: TargetResult[] = []; - let nodeUnreachable: string | null = null; - for (const target of targets) { - if (nodeUnreachable) { - targetResults.push({ target, success: false, reclaimedBytes: 0, error: nodeUnreachable }); - continue; - } - try { - const response = await fetch(`${baseUrl}/api/system/prune/system`, { - method: 'POST', - headers: remoteHeaders, - body: JSON.stringify({ target, scope, dryRun: isDryRun }), - signal: AbortSignal.timeout(120000), - }); - if (!response.ok) { - const errBody = (await response.json().catch(() => ({}))) as { error?: string }; - const message = errBody.error || `Remote returned ${response.status}`; - nodeUnreachable = message; - targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); - continue; - } - const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null; - if (!remote || typeof remote.reclaimedBytes !== 'number') { - targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' }); - continue; - } - const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes }; - if (remote.dryRun) entry.dryRun = true; - targetResults.push(entry); - } catch (err) { - const message = getErrorMessage(err, 'Failed to reach remote node'); - nodeUnreachable = message; - targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); - } - } - return { - nodeId: node.id, nodeName: node.name, - reachable: nodeUnreachable === null, - error: nodeUnreachable ?? undefined, - targets: targetResults, - }; - })); - - if (isDebugEnabled()) { - const reachable = results.filter(r => r.reachable).length; - const reclaimed = results.reduce((n, r) => n + r.targets.reduce((m, t) => m + t.reclaimedBytes, 0), 0); - console.debug('[Fleet:debug] fleet-prune complete:', { reachable, unreachable: results.length - reachable, reclaimedBytes: reclaimed }); - } - res.json({ results }); + const response = await runFleetPrune( + DatabaseService.getInstance().getNodes(), + parsed.request, + activeBulkActions, + ); + res.status(response.status).json(response.body); } catch (error) { console.error('[Fleet] fleet-prune error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet prune') }); @@ -2381,7 +2255,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res // uses these to distinguish "0 matching stacks" from "label exists but no // stacks assigned" from "remote unavailable". fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; const body = req.body as { labelName?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -2429,7 +2303,7 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r // them is a no-op). `unreachableNodes`/`partial` tell the card the counts cover // only the nodes it could reach. fleetRouter.get('/labels/suggestions', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const summaries = await collectFleetLabelSummaries(); const agg = new Map(); diff --git a/backend/src/routes/fleetActions.ts b/backend/src/routes/fleetActions.ts index 699196a6..69ab0728 100644 --- a/backend/src/routes/fleetActions.ts +++ b/backend/src/routes/fleetActions.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; +import { checkPermission, requirePermission, type PermissionAction } from '../middleware/permissions'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { MAX_ASSIGNMENTS } from '../helpers/constants'; @@ -14,10 +15,26 @@ import { runLocalLabelAssign, validateLabelTemplate, type LabelLocalAssignRespon // because their path must sit behind the `/api/fleet/` proxy-exempt prefix. export const fleetActionsRouter = Router(); +function requireExactStacks( + req: Request, + res: Response, + action: PermissionAction, + stackNames: Iterable, + nodeId: number, +): boolean { + for (const stackName of stackNames) { + if (!checkPermission(req, action, 'stack', stackName, nodeId)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return false; + } + } + return true; +} + // Per-node label-matched stop. A control instance calls this on each remote // node during a fleet-wide stop-by-label so the destructive work runs under the -// remote's own admin auth and per-node bulk lock. Admin-only and available on -// every license, matching the rest of the Fleet Actions surface. The paid +// remote's own auth and per-node bulk lock. Every confirmed stack requires +// stack:deploy. The paid // label-driven action lives at `POST /api/labels/:id/action`; this receiver is // the fleet-plumbing equivalent the control fans out to, so a fleet-stop on a // Community fleet stops remote stacks instead of 403'ing on the remote leg. @@ -25,7 +42,6 @@ fleetActionsRouter.post( '/labels/local-stop', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const { labelName, dryRun, stackNames } = req.body as { labelName?: unknown; dryRun?: unknown; stackNames?: unknown }; if (typeof labelName !== 'string' || labelName.trim().length === 0) { @@ -44,6 +60,12 @@ fleetActionsRouter.post( allowedStacks = new Set(stackNames as string[]); } const nodeId = req.nodeId ?? 0; + if (allowedStacks) { + if (allowedStacks.size === 0 && !requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) return; + if (!requireExactStacks(req, res, 'stack:deploy', allowedStacks, nodeId)) return; + } else if (!requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) { + return; + } const trimmedLabel = labelName.trim(); try { const outcome = await runLocalLabelStop(nodeId, trimmedLabel, dryRun === true, allowedStacks); @@ -60,15 +82,14 @@ fleetActionsRouter.post( // Per-node label assign. A control instance calls this on each target node // during a fleet-wide bulk label assign so the label is resolved or created // under the node's own database, by name, and assigned to the given stacks while -// preserving their existing labels (add semantics). Admin-only and available on -// every license, matching the rest of the Fleet Actions surface. Labels are +// preserving their existing labels (add semantics). Every target stack requires +// stack:edit. Labels are // node-local, so the control never reuses a local label id on a remote: the // receiver owns label resolution for its own node. fleetActionsRouter.post( '/labels/local-assign', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const { label, stackNames } = req.body as { label?: unknown; stackNames?: unknown }; const validated = validateLabelTemplate(label); @@ -85,8 +106,11 @@ fleetActionsRouter.post( return; } const nodeId = req.nodeId ?? 0; + const uniqueStacks = new Set(stackNames as string[]); + if (uniqueStacks.size === 0 && !requirePermission(req, res, 'stack:edit')) return; + if (!requireExactStacks(req, res, 'stack:edit', uniqueStacks, nodeId)) return; try { - const outcome = await runLocalLabelAssign(nodeId, validated.template, stackNames as string[]); + const outcome = await runLocalLabelAssign(nodeId, validated.template, [...uniqueStacks]); if (isDebugEnabled()) console.debug('[FleetActions:debug] local-assign:', { nodeId, label: validated.template.name, created: outcome.created, stacks: outcome.stackResults.length }); const body: LabelLocalAssignResponse = { created: outcome.created, results: outcome.stackResults }; res.json(body); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index e3c000ae..c6ea4af5 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -19,7 +19,7 @@ import { NotificationService } from '../services/NotificationService'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { HealthGateService } from '../services/HealthGateService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { checkPermission, requirePermission, type PermissionAction } from '../middleware/permissions'; import { buildPolicyGateOptions } from '../helpers/policyGate'; import { FLEET_UPDATE_CACHE_KEY, invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -75,7 +75,7 @@ imageUpdatesRouter.get('/detail', authMiddleware, (req: Request, res: Response): }); imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:manage', 'node', String(req.nodeId ?? 0))) return; try { if (!ImageUpdateService.isChecksEnabled()) { res.status(409).json({ @@ -97,6 +97,40 @@ imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response } }); +// Per-stack manual recheck, distinct from the node-wide /refresh above. Reuses +// the same registry probe ImageUpdateService runs after an applied update. +imageUpdatesRouter.post('/refresh/:stackName', authMiddleware, async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + try { + if (!ImageUpdateService.isChecksEnabled()) { + res.status(409).json({ + enabled: false, + error: 'Image update detection is disabled for this node.', + }); + return; + } + const iu = ImageUpdateService.getInstance(); + if (!iu.tryMarkStackRecheck(req.nodeId, stackName)) { + const remainingMs = iu.getStackRecheckCooldownRemainingMs(req.nodeId, stackName); + const remainingSec = Math.ceil(remainingMs / 1000); + res.status(429).json({ + error: `Per-stack check was started too recently. Please wait ${remainingSec} second${remainingSec !== 1 ? 's' : ''}.`, + }); + return; + } + const result = await iu.recheckStack(req.nodeId, stackName); + res.json(result); + } catch (error) { + console.error('Failed to recheck stack for image updates:', error); + res.status(500).json({ error: 'Failed to recheck stack for image updates' }); + } +}); + imageUpdatesRouter.get('/status', authMiddleware, (req: Request, res: Response): void => { const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; @@ -146,7 +180,7 @@ const IntervalPatchSchema = z.object({ }); imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:settings')) return; const parsed = IntervalPatchSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'minutes must be an integer between 15 and 1440' }); @@ -193,7 +227,7 @@ const EnabledPatchSchema = z.object({ }); imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:settings')) return; const parsed = EnabledPatchSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'enabled must be a boolean' }); @@ -209,7 +243,6 @@ imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response) }); imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; try { const result = await CacheService.getInstance().getOrFetch>>( FLEET_UPDATE_CACHE_KEY, @@ -273,7 +306,7 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo }); imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, res: Response): Promise => { - if (!requireAdmin(_req, res)) return; + if (!requirePermission(_req, res, 'node:manage')) return; const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -356,14 +389,30 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, */ export const autoUpdateRouter = Router(); -autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; - try { - // Honor the node-scoped image-update detection opt-out before any work. - if (!ImageUpdateService.isChecksEnabled()) { - res.json({ result: 'Image update detection is disabled for this node; skipped.' }); - return; +/** + * Deny the whole request on the first stack that fails `action`, writing the + * 403 itself. Used to pre-check every resolved target before any auto-update + * work starts, so a denied stack in a bulk request never leaves partial work + * behind. + */ +function requireExactStacks( + req: Request, + res: Response, + action: PermissionAction, + stackNames: Iterable, + nodeId: number, +): boolean { + for (const stackName of stackNames) { + if (!checkPermission(req, action, 'stack', stackName, nodeId)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return false; } + } + return true; +} + +autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise => { + try { const { target, targets } = req.body as { target?: string; targets?: unknown }; let stackNames: string[]; @@ -392,6 +441,12 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp } else if (typeof target === 'string' && target.length > 0) { console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target)}"`); if (target === '*') { + // The wildcard expands to every stack on the node, including a set + // this handler cannot enumerate permission against ahead of time + // when it turns out to be empty. Require global stack:deploy rather + // than a scoped grant, so an unauthorized caller cannot reach the + // "no stacks found" no-op without ever being permission-checked. + if (!requirePermission(req, res, 'stack:deploy')) return; stackNames = await FileSystemService.getInstance(req.nodeId).getStacks(); if (stackNames.length === 0) { res.json({ result: 'No stacks found on node; skipped.' }); @@ -409,6 +464,21 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp return; } + // Pre-check every resolved target before any work starts: a denied stack + // anywhere in the set (including a "*" expansion) fails the whole request + // rather than running some stacks and skipping others. Permission is + // evaluated unconditionally, before the node's checks-enabled setting is + // even consulted, so a disabled node never gives an unauthorized caller + // a free pass. + if (!requireExactStacks(req, res, 'stack:deploy', stackNames, req.nodeId)) return; + + // Honor the node-scoped image-update detection opt-out, now that every + // resolved target has cleared the permission gate above. + if (!ImageUpdateService.isChecksEnabled()) { + res.json({ result: 'Image update detection is disabled for this node; skipped.' }); + return; + } + const docker = DockerController.getInstance(req.nodeId); const imageUpdateService = ImageUpdateService.getInstance(); const atomic = true; diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index d63fe40c..c43b3a55 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -6,8 +6,8 @@ import { StackOpLockService, stackOpSkipMessage } from '../services/StackOpLockS import DockerController from '../services/DockerController'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { authMiddleware } from '../middleware/auth'; -import { requirePermission } from '../middleware/permissions'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { checkPermission, requirePermission } from '../middleware/permissions'; +import { requireBody } from '../middleware/tierGates'; import { buildPolicyGateOptions, describePolicyBlock } from '../helpers/policyGate'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants'; @@ -16,16 +16,16 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; +import { activeBulkActions } from '../helpers/bulkActionLocks'; -// Module-scope lock shared by `POST /api/labels/:id/action` and the fleet-wide -// bulk endpoints in `routes/fleet.ts`. Keyed by `${nodeId}` so concurrent bulk -// actions targeting the same node serialize and a fleet-stop cannot race a -// per-label action on the same containers. -export const activeBulkActions = new Set(); +// Shared with the fleet-wide bulk endpoints. Label actions use `${nodeId}` so +// a fleet stop cannot race a per-label action on the same containers. +export { activeBulkActions }; export const labelsRouter = Router(); labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = req.nodeId ?? 0; const labels = DatabaseService.getInstance().getLabels(nodeId); @@ -78,6 +78,7 @@ labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom }); labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = req.nodeId ?? 0; const db = DatabaseService.getInstance(); @@ -167,7 +168,6 @@ labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): }); labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'label ID'); @@ -188,6 +188,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo return; } + const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId); + const fsStacks = await FileSystemService.getInstance(nodeId).getStacks(); + const fsStackNames = new Set(fsStacks); + const validStacks = stackNames.filter(name => fsStackNames.has(name)); + const deniedStack = validStacks.find(stackName => + !checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId)); + if (deniedStack) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + const lockKey = `bulk:${nodeId}`; if (activeBulkActions.has(lockKey)) { res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' }); @@ -196,11 +207,6 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo activeBulkActions.add(lockKey); try { - const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId); - const fsStacks = await FileSystemService.getInstance(nodeId).getStacks(); - const fsStackNames = new Set(fsStacks); - const validStacks = stackNames.filter(name => fsStackNames.has(name)); - if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun }); const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = []; diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts index e640c6c9..54fd3acb 100644 --- a/backend/src/routes/license.ts +++ b/backend/src/routes/license.ts @@ -1,7 +1,8 @@ import { Router, type Request, type Response } from 'express'; import { LicenseService } from '../services/LicenseService'; import SelfUpdateService from '../services/SelfUpdateService'; -import { requireAdmin, requireUserSession } from '../middleware/tierGates'; +import { requireUserSession } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { parseRequestedTargetVersion } from '../utils/targetVersion'; import type { SelfUpdatePreflight } from '../services/SelfUpdateService'; @@ -26,7 +27,7 @@ licenseRouter.get('/', (_req: Request, res: Response): void => { licenseRouter.post('/activate', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:license')) return; try { const { license_key } = req.body; if (!license_key || typeof license_key !== 'string') { @@ -47,7 +48,7 @@ licenseRouter.post('/activate', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:license')) return; try { const result = await LicenseService.getInstance().deactivate(); if (result.success) { @@ -121,7 +122,7 @@ export function scheduleLocalUpdate(res: Response, message: string, targetVersio export const systemUpdateRouter = Router(); systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:license')) return; const selfUpdate = SelfUpdateService.getInstance(); if (!selfUpdate.isAvailable()) { res.status(503).json({ error: 'Self-update unavailable. Sencho must be deployed via Docker Compose.' }); @@ -177,7 +178,7 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise< }); systemUpdateRouter.post('/reapply-compose', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:license')) return; const selfUpdate = SelfUpdateService.getInstance(); if (!selfUpdate.isAvailable()) { res.status(503).json({ error: 'Compose reapply unavailable. Sencho must be deployed via Docker Compose.' }); diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index 9057e38a..d0d7447c 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService'; import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { sanitizeForLog } from '../utils/safeLog'; import { isValidStackName } from '../utils/validation'; @@ -15,6 +16,7 @@ function actorFor(req: Request): string { meshRouter.get('/status', async (_req: Request, res: Response): Promise => { if (!requirePaid(_req, res)) return; + if (!requirePermission(_req, res, 'node:read')) return; try { const mesh = MeshService.getInstance(); const status = await mesh.getStatus(); @@ -34,7 +36,7 @@ meshRouter.get('/status', async (_req: Request, res: Response): Promise => */ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:manage')) return; const actor = actorFor(req); let summary: MeshRegenSummary | null = null; let outcome: 'success' | 'skipped' | 'partial' | 'error' = 'error'; @@ -68,9 +70,9 @@ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; try { await MeshService.getInstance().enableForNode(nodeId); res.json({ ok: true }); @@ -81,9 +83,9 @@ meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Pr meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; try { await MeshService.getInstance().disableForNode(nodeId, actorFor(req)); res.json({ ok: true }); @@ -103,6 +105,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; try { const services = await MeshService.getInstance().inspectLocalStackServices(stackName); res.json({ services }); @@ -121,6 +124,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) */ meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const stacks = await MeshService.getInstance().listLocalStacks(); res.json({ stacks }); @@ -158,6 +162,7 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response) if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; const body = req.body as { aliases?: unknown; portAliases?: unknown }; if (!Array.isArray(body?.aliases)) { res.status(400).json({ error: 'Missing aliases array in body' }); return; } if (body.aliases.length > MAX_ALIASES_PER_PUSH) { @@ -210,6 +215,7 @@ meshRouter.delete('/local-override/:stackName', async (req: Request, res: Respon if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; try { await MeshService.getInstance().removeLocalOverride(stackName); res.json({ ok: true }); @@ -223,6 +229,7 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro if (!requirePaid(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const db = DatabaseService.getInstance(); const optedIn = new Set(db.listMeshStacks(nodeId).map((s) => s.stack_name)); @@ -241,10 +248,10 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); const stackName = req.params.stackName as string; if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; } + if (!requireAdmin(req, res)) return; try { await MeshService.getInstance().optInStack(nodeId, stackName, actorFor(req)); res.json({ ok: true }); @@ -268,10 +275,10 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); const stackName = req.params.stackName as string; if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; } + if (!requireAdmin(req, res)) return; try { await MeshService.getInstance().optOutStack(nodeId, stackName, actorFor(req)); res.json({ ok: true }); @@ -283,6 +290,7 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, meshRouter.get('/aliases', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aliases = await MeshService.getInstance().listAliases(); res.json({ aliases }); @@ -294,6 +302,7 @@ meshRouter.get('/aliases', async (req: Request, res: Response): Promise => meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const diag = await MeshService.getInstance().getRouteDiagnostic(req.params.alias as string); res.json(diag); @@ -304,6 +313,7 @@ meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response) meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const sourceNodeId = NodeRegistry.getInstance().getDefaultNodeId(); const result = await MeshService.getInstance().testUpstream(req.params.alias as string, sourceNodeId); @@ -317,6 +327,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): if (!requirePaid(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const diag = await MeshService.getInstance().getNodeDiagnostic(nodeId); res.json(diag); @@ -327,6 +338,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): meshRouter.get('/activity', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined; const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined; const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined; @@ -337,6 +349,7 @@ meshRouter.get('/activity', (req: Request, res: Response): void => { meshRouter.get('/activity/stream', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts index c71ec0d0..6fb917c4 100644 --- a/backend/src/routes/metrics.ts +++ b/backend/src/routes/metrics.ts @@ -9,7 +9,7 @@ import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants'; -import { getHostMemory } from '../helpers/hostMemory'; +import { getHostMemory, memoryToWire } from '../helpers/hostMemory'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { isManagedByComposeDir } from '../utils/managed-containers'; @@ -310,14 +310,9 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length, }, - memory: { - total: hostMem.total, - // ZFS ARC aware: reclaimable ARC is added back into available so a - // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. - used: hostMem.used, - free: hostMem.free, - usagePercent: hostMem.usagePercent.toFixed(1), - }, + // ARC/balloon aware: reclaimable ARC is added back into available, + // and ballooned memory is subtracted from used. See helpers/hostMemory.ts. + memory: memoryToWire(hostMem), disk: mainDisk ? { fs: mainDisk.fs, mount: mainDisk.mount, diff --git a/backend/src/routes/networking.ts b/backend/src/routes/networking.ts index fa6817d5..d052ef9e 100644 --- a/backend/src/routes/networking.ts +++ b/backend/src/routes/networking.ts @@ -19,6 +19,7 @@ export const networkingRouter = Router(); // calling the underlying service in-process and reaches each remote through // this route, so a remote is summarized on the node that owns its stacks. networkingRouter.get('/summary', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; try { res.json(await computeNodeNetworkingSummary(req.nodeId)); } catch (error) { @@ -28,7 +29,7 @@ networkingRouter.get('/summary', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { @@ -44,7 +45,7 @@ networkingRouter.get('/overview', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { networks: aggregate.networks })); @@ -55,7 +56,7 @@ networkingRouter.get('/networks', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const id = req.params.id as string; if (!id || (!isValidDockerResourceId(id) && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id))) { res.status(400).json({ error: 'Invalid network ID format' }); @@ -82,7 +83,7 @@ networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promi }); networkingRouter.get('/topology', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const includeSystem = req.query.includeSystem === 'true'; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, { @@ -100,7 +101,7 @@ networkingRouter.get('/topology', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { findings: aggregate.findings })); @@ -111,7 +112,7 @@ networkingRouter.get('/findings', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const findingId = req.params.id as string; if (!findingId) { res.status(400).json({ error: 'Finding ID is required' }); diff --git a/backend/src/routes/nodeLabels.ts b/backend/src/routes/nodeLabels.ts index 08edb4ea..8f4b8058 100644 --- a/backend/src/routes/nodeLabels.ts +++ b/backend/src/routes/nodeLabels.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { DatabaseService } from '../services/DatabaseService'; import { NodeLabelService } from '../services/NodeLabelService'; import { parseIntParam } from '../utils/parseIntParam'; @@ -10,6 +11,7 @@ export const nodeLabelsRouter = Router(); nodeLabelsRouter.use(authMiddleware); nodeLabelsRouter.get('/', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const map = NodeLabelService.getInstance().listAll(); res.json(map); @@ -20,6 +22,7 @@ nodeLabelsRouter.get('/', (req: Request, res: Response): void => { }); nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const labels = NodeLabelService.getInstance().listDistinct(); res.json({ labels }); @@ -32,6 +35,7 @@ nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { @@ -47,10 +51,10 @@ nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; const label = typeof req.body.label === 'string' ? req.body.label : ''; try { const node = DatabaseService.getInstance().getNode(nodeId); @@ -71,9 +75,9 @@ nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.delete('/:nodeId/:label', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; const labelParam = req.params.label; const label = typeof labelParam === 'string' ? labelParam : ''; if (!label) { diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 61c36f5e..30b0eda5 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -118,6 +118,7 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp export const nodesRouter = Router(); nodesRouter.get('/', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; let count = 0; @@ -140,7 +141,8 @@ nodesRouter.get('/', async (req: Request, res: Response) => { } }); -nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Response) => { +nodesRouter.get('/scheduling-summary', authMiddleware, (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const db = DatabaseService.getInstance(); const scheduleSummary = db.getNodeSchedulingSummary(); @@ -182,6 +184,7 @@ nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Resp }); nodesRouter.get('/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return; try { const id = parseInt(req.params.id as string); const node = DatabaseService.getInstance().getNode(id); @@ -600,6 +603,7 @@ nodesRouter.post('/:id/test', async (req: Request, res: Response) => { }); nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return; const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; let id = NaN; diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index f2ef92a1..d229cb01 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void const scopedPermissions: Record = {}; if (effectiveTier(req) === 'paid') { for (const a of db.getAllRoleAssignments(req.user.userId)) { - const key = `${a.resource_type}:${a.resource_id}`; + const key = a.resource_type === 'stack' + ? `stack:${a.node_id}:${a.resource_id}` + : `node:${a.resource_id}`; const perms = ROLE_PERMISSIONS[a.role] || []; const existing = scopedPermissions[key] || []; scopedPermissions[key] = [...new Set([...existing, ...perms])]; diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index 42cbef4f..c25ec61e 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -1,7 +1,8 @@ import { Router, type Request, type Response } from 'express'; import { RegistryService } from '../services/RegistryService'; import { listRegistryTagsResult, type TagListCode } from '../services/registry-api'; -import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { requirePaid } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; @@ -70,7 +71,7 @@ export const registriesRouter = Router(); registriesRouter.get('/', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { res.json(RegistryService.getInstance().getAll()); } catch (error) { @@ -81,7 +82,7 @@ registriesRouter.get('/', (req: Request, res: Response): void => { registriesRouter.post('/', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const { name, url, type, username, secret, aws_region } = req.body; @@ -118,7 +119,7 @@ registriesRouter.post('/', (req: Request, res: Response): void => { registriesRouter.put('/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -156,7 +157,7 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => { registriesRouter.delete('/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -178,7 +179,7 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => { // log the browser session out via the frontend unauthorized handler). registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -248,7 +249,7 @@ registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -267,7 +268,7 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise< registriesRouter.post('/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:registries')) return; try { const { type, url, username, secret, aws_region } = req.body; diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index ca503995..5ed9e0cb 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -7,12 +7,15 @@ import { INVALID_ACTION_MESSAGE, validateActionTarget, getScheduledActionDefinition, + resolveTaskPermissionScope, type TargetType, type BackendScheduledAction, } from '../services/scheduledActionRegistry'; import { SchedulerService } from '../services/SchedulerService'; import { NotificationService } from '../services/NotificationService'; -import { requireAdmin } from '../middleware/tierGates'; +import DockerController from '../services/DockerController'; +import { FileSystemService } from '../services/FileSystemService'; +import { checkPermission, requirePermission } from '../middleware/permissions'; import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; @@ -107,6 +110,29 @@ function validateContainerTarget(targetType: TargetType, targetId: unknown, node return null; } +/** + * Validate that a stack or container target actually exists on the target + * node. Skipped for remote nodes (would require a proxy call). + */ +async function validateTargetExists( + targetType: TargetType, + targetId: string | null, + nodeId: number | null, +): Promise { + const isStack = targetType === 'stack'; + if ((!isStack && targetType !== 'container') || !targetId || nodeId == null) return null; + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) return `${isStack ? 'Stack' : 'Container'} operations require an existing node.`; + if (node.type === 'remote') return null; // Skip existence check (would need proxy). + const exists = isStack + ? (await FileSystemService.getInstance(nodeId).getStacks()).includes(targetId) + : (await DockerController.getInstance(nodeId).findContainerByName(targetId)) != null; + if (!exists) { + return `${isStack ? 'Stack' : 'Container'} "${targetId}" not found on the target node.`; + } + return null; +} + /** * Shared guard for non-stack actions that require a node. Stack actions use * validateStackTarget because they also require target_id. Label-targeted @@ -120,6 +146,7 @@ function validateActionNode( selectorType?: unknown, ): string | null { if (targetType === 'stack' || targetType === 'container') return null; + const def = getScheduledActionDefinition(action); if (!def?.requiresNode) return null; @@ -136,11 +163,14 @@ function validateActionNode( const parsedNodeId = parsePositiveNodeId(nodeId); if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`; - if (def.nodeScope !== 'local') return null; + // Validate node existence for every action, not only local-scoped ones. const node = DatabaseService.getInstance().getNode(parsedNodeId); - if (!node) return `${labelPlural} require an existing local node.`; - if (node.type === 'remote') return `${labelPlural} currently require a local node.`; + if (!node) return `${labelSingular} action requires an existing node.`; + + if (def.nodeScope === 'local' && node.type === 'remote') { + return `${labelPlural} currently require a local node.`; + } return null; } @@ -252,14 +282,71 @@ function validateRunAt(runAt: unknown): string | null { return null; } +/** + * Check whether the authenticated user can manage (create, edit, run, delete) + * the given task. Consumes the centralized permission scope resolver so the + * registry remains the single source of truth for action→permission mapping. + */ +function checkTaskPermission( + req: Request, + task: Pick, +): boolean { + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + return checkPermission(req, scope.action, scope.resourceType, scope.resourceId, scope.resourceNodeId); +} + +/** + * Require permission for a task. Sends 403 if denied; callers must `return;` on false. + */ +function requireTaskPermission( + req: Request, + res: Response, + task: Pick, +): boolean { + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + return requirePermission(req, res, scope.action, scope.resourceType, scope.resourceId, scope.resourceNodeId); +} + +/** + * Require permission to access an existing task. Returns 404 (not 403) when + * denied, so an unauthorized caller cannot distinguish "task does not exist" + * from "task exists but you are not authorized." Used on by-ID endpoints + * where the task's existence has already been confirmed. + */ +function requireTaskExistsPermission( + req: Request, + res: Response, + task: Pick, +): boolean { + if (checkTaskPermission(req, task)) return true; + res.status(404).json({ error: 'Scheduled task not found' }); + return false; +} + export const scheduledTasksRouter = Router(); scheduledTasksRouter.get('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { let tasks = DatabaseService.getInstance().getScheduledTasks(); - // The Scheduled Operations view manages every task type, so it lists all of - // them. `action` / `exclude_action` exist for the read-only consumers that + + // Permission-filter the full list so a scoped deployer sees only tasks + // targeting their authorized resources. Admin sees every task (checkTaskPermission + // always returns true for admin via checkPermission's admin bypass). + tasks = tasks.filter(t => checkTaskPermission(req, t)); + + // `action` / `exclude_action` exist for the read-only consumers that // want a slice: the Auto-Update readiness card and the sidebar next-run // indicator both request `?action=update`. const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; @@ -287,8 +374,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { } }); -scheduledTasksRouter.post('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; +scheduledTasksRouter.post('/', async (req: Request, res: Response): Promise => { try { const { name, target_type, target_id, node_id, action, cron_expression, enabled, @@ -309,8 +395,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const targetErr = validateActionTarget(action, target_type); if (targetErr) { res.status(400).json({ error: targetErr }); return; } - const nodeErr = validateActionNode(action, target_type, node_id, selector_type); - if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } const stackTargetErr = validateStackTarget(target_type, target_id, node_id); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } const containerTargetErr = validateContainerTarget(target_type, target_id, node_id); @@ -327,19 +411,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const runAtErr = validateRunAt(run_at); if (runAtErr) { res.status(400).json({ error: runAtErr }); return; } - const scheduler = SchedulerService.getInstance(); - const now = Date.now(); - // Persist the one-shot's pinned instant in its own column so it survives a - // disabled state and edit (the yearless cron cannot reconstruct the year). - // next_run_at is the cron-derived run unless a run_at pins it, and is null - // while disabled; the pinned run_at is retained regardless so enabling later - // restores the exact instant. - const pinnedRunAt = typeof run_at === 'number' ? run_at : null; - const nextRun = (enabled === false) - ? null - : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); - const normalizedTargetId = - target_type === 'stack' || target_type === 'container' ? target_id : null; + // Compute normalized IDs early so existence validators can use them. const labelSelector = usesStackLabelSelector(action, target_type, selector_type); const normalizedNodeId = labelSelector ? (node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id)) @@ -347,6 +419,36 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (labelSelector && node_id != null && node_id !== '' && normalizedNodeId === null) { res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return; } + const normalizedTargetId = + target_type === 'stack' || target_type === 'container' ? target_id : null; + + // Permission check on the resolved action+target scope. Runs before + // existence validators so unauthorized callers cannot probe whether a + // stack or container exists on a node they should not reach. + if (!requireTaskPermission(req, res, { + action, + target_type, + target_id: normalizedTargetId, + node_id: normalizedNodeId, + selector_type: labelSelector ? STACK_LABEL_SELECTOR : null, + })) return; + + // Node existence validation for fleet and system actions. Runs after + // permission so unauthorized callers cannot probe node IDs via the error + // code difference (400 "node doesn't exist" vs 403 "permission denied"). + const nodeErr = validateActionNode(action, target_type, node_id, selector_type); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } + + // Validate target existence for stack and container targets on local nodes. + const targetExistErr = await validateTargetExists(target_type, normalizedTargetId, normalizedNodeId); + if (targetExistErr) { res.status(400).json({ error: targetExistErr }); return; } + + const scheduler = SchedulerService.getInstance(); + const now = Date.now(); + const pinnedRunAt = typeof run_at === 'number' ? run_at : null; + const nextRun = (enabled === false) + ? null + : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); const selectors = normalizeSelectorFields(action, target_type, selector_type, selector_value); const id = DatabaseService.getInstance().createScheduledTask({ @@ -358,6 +460,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { cron_expression, enabled: enabled !== false ? 1 : 0, created_by: req.user?.username || 'admin', + creator_user_id: req.user?.userId ?? null, created_at: now, updated_at: now, last_run_at: null, @@ -384,12 +487,12 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { }); scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const task = DatabaseService.getInstance().getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, task)) return; res.json(task); } catch (error) { console.error('[ScheduledTasks] Get error:', error); @@ -397,8 +500,7 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { } }); -scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; +scheduledTasksRouter.put('/:id', async (req: Request, res: Response): Promise => { try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -407,6 +509,14 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + // Two-phase check: (1) the caller must be authorized for the existing task + // (prevents task take-over; returns 404 so task ID existence is not + // disclosed), and (2) the merged target must also be authorized (prevents + // retargeting escalation, like flipping restart→prune; returns 403 since + // this is a permission denial on the requested change, not an ownership + // check). + if (!requireTaskExistsPermission(req, res, existing)) return; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, selector_type, selector_value, @@ -436,9 +546,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const targetErr = validateActionTarget(finalAction, finalTargetType); if (targetErr) { res.status(400).json({ error: targetErr }); return; } - const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId, finalSelectorType); - if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } - const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } @@ -529,6 +636,27 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { updates.next_run_at = null; } + // Second phase: the caller must have permission for the merged scope. + const parsedFinalNodeId = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null; + if (!requireTaskPermission(req, res, { + action: finalAction, + target_type: finalTargetType, + target_id: finalTargetId, + node_id: parsedFinalNodeId, + selector_type: finalSelectorType, + })) return; + + // Node existence validation for fleet and system actions. Runs after + // both permission phases so unauthorized callers cannot probe node IDs. + const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId, finalSelectorType); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } + + // Validate target existence for stack and container targets on local nodes. + // Runs after both permission phases so unauthorized callers cannot probe + // whether a stack or container exists on a target they cannot access. + const targetExistErr = await validateTargetExists(finalTargetType, finalTargetId, parsedFinalNodeId); + if (targetExistErr) { res.status(400).json({ error: targetExistErr }); return; } + db.updateScheduledTask(id, updates); console.log(`[ScheduledTasks] Updated task id=${id}`); const task = db.getScheduledTask(id); @@ -541,7 +669,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { }); scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -549,6 +676,7 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; db.deleteScheduledTask(id); console.log(`[ScheduledTasks] Deleted task id=${id}`); @@ -561,7 +689,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { }); scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -569,6 +696,7 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const newEnabled = existing.enabled ? 0 : 1; // On enable, a one-shot's persisted run_at restores the exact pinned instant @@ -596,7 +724,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => }); scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -604,6 +731,7 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const scheduler = SchedulerService.getInstance(); if (scheduler.isTaskRunning(id)) { @@ -625,7 +753,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { }); scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -633,6 +760,7 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void const db = DatabaseService.getInstance(); const task = db.getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, task)) return; const runs = db.getAllScheduledTaskRuns(id); @@ -659,7 +787,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void }); scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -667,6 +794,7 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); diff --git a/backend/src/routes/secrets.ts b/backend/src/routes/secrets.ts index 78c839f1..d7ec31c1 100644 --- a/backend/src/routes/secrets.ts +++ b/backend/src/routes/secrets.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates'; +import { requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates'; import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService'; import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService'; import { isValidStackName } from '../utils/validation'; @@ -66,7 +66,6 @@ function parsePushBody(body: unknown): PushBody | { error: string } { secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const items = SecretsService.getInstance().list(); @@ -79,7 +78,6 @@ secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Prom secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -121,7 +119,6 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -141,7 +138,6 @@ secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): P secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -181,7 +177,6 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -201,7 +196,6 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -219,7 +213,6 @@ secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Res secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -249,7 +242,6 @@ secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -274,7 +266,6 @@ secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 29a2392f..38e15385 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { trivyInstallLimiter } from '../middleware/rateLimiters'; import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyInstaller from '../services/TrivyInstaller'; @@ -391,6 +392,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const nodeId = req.nodeId; try { const db = DatabaseService.getInstance(); @@ -425,7 +427,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn }); securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:deploy')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); @@ -462,7 +464,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void }); securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); return; @@ -471,6 +472,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (svc.isScanningStack(req.nodeId, stackName)) { res.status(409).json({ error: 'Already scanning this stack' }); return; } @@ -494,7 +496,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res // when requested, every stack's compose config for misconfigurations. Streams // sanitized progress to the deploy-feedback terminal when the client opened one. securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:manage')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); @@ -1001,7 +1003,7 @@ securityRouter.get('/policy-packs', authMiddleware, (_req: Request, res: Respons }); securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); return; @@ -1034,7 +1036,7 @@ securityRouter.get( '/scans/:scanId/sarif', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const scanId = Number(req.params.scanId); if (!Number.isFinite(scanId)) { res.status(400).json({ error: 'Invalid scan id' }); return; @@ -1095,10 +1097,10 @@ securityRouter.get( }, ); -// Export the instance's CVE triage decisions as an OpenVEX document. Admin-only, -// mirroring the SARIF export. +// Export the instance's CVE triage decisions as an OpenVEX document. This is a +// read operation, mirroring the SARIF export permission. securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const suppressions = DatabaseService.getInstance().getCveSuppressions(); const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString()); @@ -1123,7 +1125,7 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v }); securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'security policies')) return; const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable } = req.body ?? {}; if (!name || typeof name !== 'string' || !name.trim()) { @@ -1173,7 +1175,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): }); securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1231,7 +1233,7 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response }); securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1252,7 +1254,7 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response }); securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const body = req.body ?? {}; const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; @@ -1316,7 +1318,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons }); securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1371,7 +1373,7 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp }); securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1403,7 +1405,7 @@ securityRouter.get('/misconfig-acks', authMiddleware, (req: Request, res: Respon }); securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const body = req.body ?? {}; const ruleId = typeof body.rule_id === 'string' ? body.rule_id.trim() : ''; @@ -1459,7 +1461,7 @@ securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Respo }); securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1511,7 +1513,7 @@ securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Re }); securityRouter.delete('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1370c8e7..a6bf4740 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -2,46 +2,93 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { requirePaid } from '../middleware/tierGates'; +import { requirePermission, checkPermission, type PermissionAction } from '../middleware/permissions'; import { parseNotificationDispatchRetries } from '../helpers/notificationDispatchRetries'; -// Strict allowlist of keys readable and writable via the generic settings -// API. This is the single source of truth for what the endpoint exposes: -// reads project only these keys, so secrets written to global_settings by -// other subsystems (the cloud_backup_* credentials stored by the cloud-backup -// route, the auth_* login secrets) are never returned here; writes are -// rejected for anything outside the list. -const ALLOWED_SETTING_KEYS = new Set([ - 'host_cpu_limit', - 'host_ram_limit', - 'host_disk_limit', - 'host_alerts_enabled', - 'host_alert_suppression_mins', - 'docker_janitor_gb', - 'global_crash', - 'developer_mode', - 'template_registry_url', - 'metrics_retention_hours', - 'log_retention_days', - 'audit_retention_days', - 'mesh_auto_recreate', - 'scan_history_per_image_limit', - 'prune_orphaned_scans', - 'prune_on_update', - 'reclaim_hero', - 'snapshot_documentation', - 'health_gate_enabled', - 'health_gate_window_seconds', - 'env_block_deploy_on_missing_required', - 'auto_create_missing_external_networks', - 'image_update_sidebar_indicators', - 'notification_dispatch_retries', - 'session_sliding_refresh', -]); +// Allowlist of keys readable/writable via the generic settings API, each +// mapped to the permission required to write it. Reads project only these +// keys so secrets written to global_settings by other subsystems (cloud +// backup credentials, auth_* login secrets) are never returned; writes +// outside the map are rejected. +export const SETTING_WRITE_PERMISSIONS: Record = { + host_cpu_limit: 'node:manage', + host_ram_limit: 'node:manage', + host_disk_limit: 'node:manage', + host_alerts_enabled: 'node:manage', + host_alert_suppression_mins: 'node:manage', + docker_janitor_gb: 'node:manage', + global_crash: 'node:manage', + template_registry_url: 'node:manage', + prune_on_update: 'node:manage', + reclaim_hero: 'node:manage', + health_gate_enabled: 'node:manage', + health_gate_window_seconds: 'node:manage', + env_block_deploy_on_missing_required: 'node:manage', + auto_create_missing_external_networks: 'node:manage', + notification_dispatch_retries: 'node:manage', + recovery_retention_days: 'node:manage', + recovery_max_generations: 'node:manage', + developer_mode: 'system:settings', + metrics_retention_hours: 'system:settings', + log_retention_days: 'system:settings', + audit_retention_days: 'system:settings', + mesh_auto_recreate: 'system:settings', + scan_history_per_image_limit: 'system:settings', + prune_orphaned_scans: 'system:settings', + snapshot_documentation: 'system:settings', + image_update_sidebar_indicators: 'system:settings', + session_sliding_refresh: 'system:settings', +}; -// Keys whose write requires a paid license, not just an admin role. +const ALLOWED_SETTING_KEYS = new Set(Object.keys(SETTING_WRITE_PERMISSIONS)); + +/** Resolve node:manage against the active node so scoped Node Admin grants apply. */ +function checkNodeManage(req: Request): boolean { + const nodeId = req.nodeId; + if (typeof nodeId === 'number') { + return checkPermission(req, 'node:manage', 'node', String(nodeId)); + } + return checkPermission(req, 'node:manage'); +} + +function requireNodeManage(req: Request, res: Response): boolean { + const nodeId = req.nodeId; + if (typeof nodeId === 'number') { + return requirePermission(req, res, 'node:manage', 'node', String(nodeId)); + } + return requirePermission(req, res, 'node:manage'); +} + +/** Fail closed if any key lacks its required permission. */ +function requireSettingsWritePermission(req: Request, res: Response, keys: string[]): boolean { + // Empty no-op still requires write capability (prior requireAdmin behavior). + if (keys.length === 0) { + if (checkNodeManage(req) || checkPermission(req, 'system:settings')) return true; + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return false; + } + const needed = new Set(); + for (const key of keys) { + const action = SETTING_WRITE_PERMISSIONS[key]; + if (!action) { + res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); + return false; + } + needed.add(action); + } + for (const action of needed) { + const ok = action === 'node:manage' + ? requireNodeManage(req, res) + : requirePermission(req, res, action); + if (!ok) return false; + } + return true; +} + +// Keys whose write requires a paid license, not just a permission. // audit_retention_days configures the paid audit log, so a Community admin -// must not be able to set it. +// must not be able to set it. Checked after the permission bucket. const PAID_ONLY_SETTING_KEYS = new Set(['audit_retention_days']); // Bulk PATCH schema. All keys optional; present keys are fully validated. @@ -69,6 +116,8 @@ const SettingsPatchSchema = z.object({ env_block_deploy_on_missing_required: z.enum(['0', '1']), auto_create_missing_external_networks: z.enum(['0', '1']), image_update_sidebar_indicators: z.enum(['0', '1']), + recovery_retention_days: z.coerce.number().int().min(1).max(90).transform(String), + recovery_max_generations: z.coerce.number().int().min(0).max(50).transform(String), // Strict: do not use bare z.coerce.number() (null/false/'' become 0; true becomes 1). notification_dispatch_retries: z.unknown().superRefine((v, ctx) => { if (parseNotificationDispatchRetries(v) === null) { @@ -102,13 +151,13 @@ settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Pr }); settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; try { const { key, value } = req.body; if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); return; } + if (!requireSettingsWritePermission(req, res, [key])) return; if (PAID_ONLY_SETTING_KEYS.has(key) && !requirePaid(req, res)) return; if (value === undefined || value === null) { res.status(400).json({ error: 'Setting value is required' }); @@ -146,7 +195,6 @@ settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr }); settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; try { // Reject unknown/disallowed keys outright rather than letting Zod silently // strip them. This keeps the bulk path fail-closed and consistent with the @@ -165,7 +213,9 @@ settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): P res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }); return; } - if (Object.keys(parsed.data).some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return; + const keys = Object.keys(parsed.data); + if (!requireSettingsWritePermission(req, res, keys)) return; + if (keys.some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return; const db = DatabaseService.getInstance(); const updateMany = db.getDb().transaction((entries: [string, string][]) => { for (const [k, v] of entries) { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 9e311177..05d46574 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -72,6 +72,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard'; import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY } from '../services/CapabilityRegistry'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { classifyStackApiPath } from '../helpers/stackRouteAuth'; // Authenticated users with edit permission can write arbitrarily large compose // files. Refuse to YAML.parse anything beyond this bound so a malformed (or @@ -278,6 +279,24 @@ function getRelPath(req: Request): string { export const stacksRouter = Router(); +stacksRouter.use((req: Request, res: Response, next: NextFunction): void => { + const classified = classifyStackApiPath(req.method, `/stacks${req.path}`); + if (classified.kind === 'static') { + next(); + return; + } + if (classified.kind === 'unknown-named') { + if (req.user?.role === 'admin') { + next(); + return; + } + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if (!requirePermission(req, res, classified.action, 'stack', classified.stackName)) return; + next(); +}); + stacksRouter.param('stackName', (req, res, next, stackName) => { if (typeof stackName !== 'string' || !isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -607,6 +626,11 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => { const typedAction = action as BulkLifecycleAction; const typedNames = Array.from(new Set(stackNames as string[])); + const denied = typedNames.some(name => + isValidStackName(name) && !checkPermission(req, 'stack:deploy', 'stack', name)); + if (denied) { + return res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + } const results = await runWithBoundedParallelism( typedNames, diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index e86afda0..5e1d248d 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -9,9 +9,13 @@ import DockerController, { import { isPruneTarget } from '../services/prunePlan'; import { FileSystemService } from '../services/FileSystemService'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { StackUpdateRecoveryService, shortGenerationId } from '../services/StackUpdateRecoveryService'; +import { buildUnifiedHeldImagePredicate } from '../services/recoveryHeldImages'; +import { DatabaseService } from '../services/DatabaseService'; import SelfIdentityService from '../services/SelfIdentityService'; import { requireAdmin } from '../middleware/tierGates'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import { activeBulkActions } from '../helpers/bulkActionLocks'; import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; @@ -58,6 +62,7 @@ function rejectIfSelf(kind: 'image' | 'volume' | 'network', id: string, res: Res } systemMaintenanceRouter.get('/orphans', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const dockerController = DockerController.getInstance(req.nodeId); @@ -155,6 +160,7 @@ systemMaintenanceRouter.post('/prune/plan', async (req: Request, res: Response) systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; + let pruneLockHeld = false; try { const body = req.body as { target?: unknown; @@ -200,8 +206,17 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response return; } - // Resources path: fingerprint-bound execute. Fleet still calls without a - // fingerprint and keeps the legacy pruneManagedOnly / pruneSystem path. + const pruneLockKey = `bulk-prune:${req.nodeId}`; + if (activeBulkActions.has(pruneLockKey)) { + return res.status(409).json({ + error: 'A prune is already running on this node', + code: 'PRUNE_ALREADY_RUNNING', + }); + } + activeBulkActions.add(pruneLockKey); + pruneLockHeld = true; + + // Fingerprint-bound execute used by Resources and Fleet. if (planFingerprint) { const built = await withTimeout( dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId, isImageHeld), @@ -231,7 +246,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response success: result.success, }); } - if (built.targets.includes('containers')) { + if (result.outcomes.some((outcome) => outcome.status === 'removed')) { invalidateNodeCaches(req.nodeId); } res.json({ @@ -250,7 +265,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response }); } const target = targets[0]; - console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); + console.log(`[Resources] System prune: ${sanitizeForLog(target)} (scope: ${pruneScope})`); const pruneStartedAt = Date.now(); let result: { success: boolean; reclaimedBytes: number }; if (pruneScope === 'managed' && target !== 'containers') { @@ -268,7 +283,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response result = await dockerController.pruneSystem(target, undefined, isImageHeld); } - console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`); + console.log(`[Resources] System prune completed: ${sanitizeForLog(target)}, reclaimed ${result.reclaimedBytes} bytes`); if (isDebugEnabled()) { console.debug('[Resources:debug] System prune', { target, scope: pruneScope, ms: Date.now() - pruneStartedAt, reclaimedBytes: result.reclaimedBytes, @@ -288,6 +303,8 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response } console.error('System prune error:', error); res.status(500).json({ error: 'System prune failed' }); + } finally { + if (pruneLockHeld) activeBulkActions.delete(`bulk-prune:${req.nodeId}`); } }); @@ -337,6 +354,7 @@ systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Respon }); systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); @@ -361,6 +379,7 @@ systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Respo }); systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -372,6 +391,7 @@ systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => }); systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -383,6 +403,7 @@ systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => { }); systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -394,6 +415,7 @@ systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => { }); systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -405,6 +427,7 @@ systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => }); systemMaintenanceRouter.get('/images/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const rawId = req.params.id as string; if (!rawId) return res.status(400).json({ error: 'Invalid image ID format' }); @@ -439,9 +462,23 @@ systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Respons return res.status(400).json({ error: 'Invalid image ID format' }); } if (rejectIfSelf('image', id, res)) return; - console.log(`[Resources] Delete image: ${hexId.substring(0, 12)}`); const dockerController = DockerController.getInstance(req.nodeId); - await dockerController.removeImage(id); + // Resolve to the canonical full image ID before the held-image check: the + // submitted id can be a short/truncated form (isValidDockerResourceId + // accepts 12-64 hex chars), which a full-64-char held-set lookup would miss. + const canonicalId = await dockerController.resolveImageId(id); + if (!canonicalId) { + return res.status(404).json({ error: 'Image not found' }); + } + const isImageHeld = buildUnifiedHeldImagePredicate(req.nodeId); + if (isImageHeld(canonicalId)) { + return res.status(409).json({ + error: 'Image is held for a pending update rollback and cannot be deleted manually. It is removed automatically once the rollback window expires, or can be released from Resources → Rollback.', + code: 'IMAGE_HELD_FOR_ROLLBACK', + }); + } + console.log(`[Resources] Delete image: ${hexId.substring(0, 12)}`); + await dockerController.removeImage(canonicalId); invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Image deleted' }); } catch (error: unknown) { @@ -450,6 +487,79 @@ systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Respons } }); +// Full-stack rollback generations (the sencho-rb//:hold images). +// Global read under stack:read, matching the rest of the Docker resource +// inventory on this page (/system/resources, /system/images); release is +// requireAdmin, matching every other host-destructive Docker action here. +systemMaintenanceRouter.get('/rollback/generations', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; + try { + const service = StackUpdateRecoveryService.getInstance(); + const rows = DatabaseService.getInstance() + .listStackUpdateRecoveryGenerationsForNode(req.nodeId) + .filter((row) => row.artifacts_retired === 0 + && (row.status === 'active' || row.status === 'restored_current' + || row.status === 'superseded' || row.status === 'recovery_required')); + res.json(rows.map((row) => ({ + id: row.id, + shortId: shortGenerationId(row.id), + stackName: row.stack_name, + status: row.status, + isCurrent: row.is_current === 1, + phase: row.phase, + createdAt: row.created_at, + artifactExpiresAt: row.artifact_expires_at, + releasable: service.isReleaseEligible(row), + }))); + } catch (error) { + console.error('Failed to fetch rollback generations:', error); + res.status(500).json({ error: 'Failed to fetch rollback generations' }); + } +}); + +systemMaintenanceRouter.post('/rollback/generations/:id/release', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const id = req.params.id as string; + const service = StackUpdateRecoveryService.getInstance(); + const row = service.get(id); + if (!row || row.node_id !== req.nodeId) { + return res.status(404).json({ error: 'Rollback generation not found' }); + } + const result = await service.releaseGeneration(id, req.user?.username ?? null); + if (!result.ok) { + switch (result.reason) { + case 'not_found': + return res.status(404).json({ error: 'Rollback generation not found' }); + case 'already_released': + return res.status(409).json({ + error: 'Rollback protection was already released for this generation.', + code: 'ALREADY_RELEASED', + }); + case 'not_eligible': + return res.status(409).json({ + error: 'This rollback generation cannot be released right now (it may be observing a health gate, mid-recovery, or already in progress).', + code: 'NOT_ELIGIBLE', + }); + default: { + const _exhaustive: never = result.reason; + throw new Error(`Unhandled release reason: ${_exhaustive}`); + } + } + } + console.log(`[Resources] Released rollback generation ${sanitizeForLog(shortGenerationId(id))} for ${sanitizeForLog(result.row.stack_name)}`); + invalidateNodeCaches(req.nodeId); + res.json({ + success: true, + message: result.artifactsCleaned ? 'Rollback protection released' : 'Rollback protection released; cleanup will finish shortly', + artifactsCleaned: result.artifactsCleaned, + }); + } catch (error) { + console.error('Failed to release rollback generation:', error); + res.status(500).json({ error: 'Failed to release rollback generation' }); + } +}); + systemMaintenanceRouter.post('/volumes/delete', async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { @@ -503,6 +613,7 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo }); systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const includeSystem = req.query.includeSystem === 'true'; const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); @@ -518,6 +629,7 @@ systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Resp }); systemMaintenanceRouter.get('/networks/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const id = req.params.id as string; if (!id) return res.status(400).json({ error: 'Network ID is required' }); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index cb15873a..364158e1 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -19,6 +19,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; export const templatesRouter = Router(); templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const templates = await templateService.getTemplates(); @@ -66,6 +67,7 @@ templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Respo templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:create')) return; + if (!requirePermission(req, res, 'stack:deploy')) return; try { const { stackName, template, envVars, skip_scan } = req.body; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 191e3a38..0856edb1 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -2,7 +2,8 @@ import { Router, type Request, type Response } from 'express'; import bcrypt from 'bcrypt'; import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin } from '../middleware/tierGates'; +import { requirePaid } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants'; import { isDebugEnabled } from '../utils/debug'; @@ -10,6 +11,8 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; import { validateUsername } from '../helpers/validateUsername'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; +import { isValidStackName } from '../utils/validation'; const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.'; const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; @@ -27,7 +30,7 @@ export const usersRouter = Router(); usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; try { const db = DatabaseService.getInstance(); const users = db.getUsers(); @@ -45,7 +48,7 @@ usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promis usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; try { const { username, password, role } = req.body; @@ -89,7 +92,7 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi // to manage existing users even if their license lapses. usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; try { const id = parseInt(req.params.id as string, 10); const db = DatabaseService.getInstance(); @@ -164,7 +167,7 @@ usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pro usersRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; try { const id = parseInt(req.params.id as string, 10); const db = DatabaseService.getInstance(); @@ -200,7 +203,7 @@ usersRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): */ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; try { const id = parseIntParam(req, res, 'id', 'user id'); if (id === null) return; @@ -230,7 +233,7 @@ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response) usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; if (!requirePaid(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); @@ -247,13 +250,13 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi } }); -usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => { +usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; if (!requirePaid(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); - const { role, resource_type, resource_id } = req.body; + const { role, resource_type, resource_id, node_id: rawNodeId } = req.body; if (!VALID_ASSIGNMENT_ROLES.includes(role)) { res.status(400).json({ error: 'Invalid role' }); @@ -274,10 +277,72 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo return; } + let nodeId: number | null = null; + + if (resource_type === 'stack') { + if (typeof rawNodeId !== 'number' || !Number.isInteger(rawNodeId)) { + res.status(400).json({ error: 'node_id is required for stack role assignments' }); + return; + } + if (!isValidStackName(resource_id)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + const exists = await assertStackExistsOnNode(rawNodeId, resource_id); + if (!exists.ok) { + res.status(400).json({ error: exists.error }); + return; + } + nodeId = rawNodeId; + } else { + // node resource: reject a stack-style node_id qualifier + if (rawNodeId !== undefined && rawNodeId !== null) { + res.status(400).json({ error: 'node_id must not be set for node role assignments' }); + return; + } + if (!/^\d+$/.test(resource_id)) { + res.status(400).json({ error: 'resource_id must be a numeric node id' }); + return; + } + const parsedNodeId = parseInt(resource_id, 10); + if (String(parsedNodeId) !== resource_id) { + res.status(400).json({ error: 'resource_id must be a canonical node id' }); + return; + } + if (!db.getNode(parsedNodeId)) { + res.status(400).json({ error: 'Node not found' }); + return; + } + } + try { - const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id }); - console.log('[Roles] Assigned', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(req.user!.username)); - res.status(201).json({ id, user_id: userId, role, resource_type, resource_id }); + const id = db.addRoleAssignment({ + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); + console.log( + '[Roles] Assigned', + sanitizeForLog(role), + 'on', + sanitizeForLog(resource_type), + sanitizeForLog(resource_id), + sanitizeForLog(nodeId != null ? `node ${nodeId}` : ''), + 'to user', + userId, + 'by:', + sanitizeForLog(req.user!.username), + ); + res.status(201).json({ + id, + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); } catch (err: unknown) { if (isSqliteUniqueViolation(err)) { res.status(409).json({ error: 'This role assignment already exists' }); @@ -293,7 +358,7 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo usersRouter.delete('/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:users')) return; if (!requirePaid(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); diff --git a/backend/src/routes/volumes.ts b/backend/src/routes/volumes.ts index 1392fdd0..5997c5df 100644 --- a/backend/src/routes/volumes.ts +++ b/backend/src/routes/volumes.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService'; import { DatabaseService } from '../services/DatabaseService'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { sanitizeForLog } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; @@ -23,7 +23,7 @@ function mapServiceError(error: unknown, res: Response, fallback: string): Respo } volumesRouter.get('/:name/list', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const name = req.params.name as string; if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); @@ -42,7 +42,7 @@ volumesRouter.get('/:name/list', async (req: Request, res: Response) => { }); volumesRouter.get('/:name/stat', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const name = req.params.name as string; if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); @@ -55,7 +55,7 @@ volumesRouter.get('/:name/stat', async (req: Request, res: Response) => { }); volumesRouter.get('/:name/read', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const name = req.params.name as string; const requestPath = readPathParam(req); let outcome: 'success' | 'error' = 'error'; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index e7435e83..f9b97d62 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService, type WebhookAction } from '../services/DatabaseService'; import { WebhookService } from '../services/WebhookService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { webhookTriggerLimiter } from '../middleware/rateLimiters'; const VALID_WEBHOOK_ACTIONS: readonly WebhookAction[] = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull']; @@ -26,7 +26,7 @@ webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Pro }); webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:webhooks')) return; try { const { name, stack_name, action, enabled, node_id } = req.body; if (!name || !stack_name || !action) { @@ -71,7 +71,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr }); webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:webhooks')) return; try { const id = parseInt(req.params.id as string, 10); const webhook = DatabaseService.getInstance().getWebhook(id); @@ -114,7 +114,7 @@ webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): }); webhooksRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'system:webhooks')) return; try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteWebhook(id); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index adddbee8..73f4b161 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -657,6 +657,7 @@ export class BlueprintService { ); } if (res.status === 200) { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(node.id, blueprint.name); return { status: 'withdrawn' }; } if (res.status === 409) { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 1ef69b2f..3d385ce7 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -62,6 +62,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; /** @@ -103,6 +104,15 @@ export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +/** + * Remotes that consume hub-bound scoped stack auth evidence headers + * (`x-sencho-scoped-stack-name` / `x-sencho-scoped-stack-actions`) under + * machine auth. Hubs fail closed when scoped elevation is needed and the + * remote lacks this flag. + */ +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = + 'scoped-stack-auth-evidence' as const satisfies Capability; + /** Returns true when the string is a usable semver version. */ export function isValidVersion(v: string | null | undefined): v is string { return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 4648b1d3..badaed7b 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -30,6 +30,7 @@ import { MissingExternalNetworksError, type DeployInvocationContext, } from './network/missingExternalNetworksError'; +import { buildUnifiedHeldImagePredicate } from './recoveryHeldImages'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import type { NotificationCategory } from './NotificationService'; @@ -1019,7 +1020,7 @@ export class ComposeService { try { const pruneOnUpdate = DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'; if (pruneOnUpdate) { - const isImageHeld = recoverySvc.buildUnifiedHeldImagePredicate(this.nodeId); + const isImageHeld = buildUnifiedHeldImagePredicate(this.nodeId); const result = await DockerController.getInstance(this.nodeId).pruneDanglingImages(isImageHeld); const reclaimed = result.reclaimedBytes > 0 ? ` · reclaimed ${(result.reclaimedBytes / (1024 * 1024)).toFixed(1)} MB` diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5eea339a..fdd0a315 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -71,6 +71,8 @@ export interface StackUpdateDetail { } const SERVICES_JSON_VERSION = 1; +const DEFAULT_RECOVERY_RETENTION_DAYS = 7; +const DEFAULT_RECOVERY_MAX_GENERATIONS = 0; function isStackServiceStatus(value: unknown): value is StackServiceStatus { if (!value || typeof value !== 'object') return false; @@ -253,6 +255,9 @@ export interface StackUpdateRecoveryGenerationRow { updated_at: number; created_by: string | null; artifacts_retired: number; + /** Set when an operator manually released rollback protection early (see releaseStackUpdateRecoveryGeneration). */ + released_at: number | null; + released_by: string | null; } /** Durable cleanup tombstone for stack/node deletion artifact sweep. */ @@ -508,6 +513,8 @@ export interface RoleAssignment { role: UserRole; resource_type: ResourceType; resource_id: string; + /** Required for stack scopes; null for node scopes. */ + node_id: number | null; created_at: number; } @@ -705,6 +712,8 @@ export interface ScheduledTask { cron_expression: string; enabled: number; created_by: string; + /** The user ID who created this schedule. Null for legacy rows (pre-RBAC) where username resolution failed at migration time. */ + creator_user_id: number | null; created_at: number; updated_at: number; last_run_at: number | null; @@ -1894,6 +1903,12 @@ export class DatabaseService { `); maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0'); + // Manual release (operator gave up rollback protection early). Additive + // columns rather than a new `status` enum value, since `status` carries a + // CHECK constraint that would need the heavier table-rebuild migration + // pattern used for health_gate_runs below. + maybeAddCol('stack_update_recovery_generations', 'released_at', 'INTEGER'); + maybeAddCol('stack_update_recovery_generations', 'released_by', 'TEXT'); maybeAddCol('stack_update_cleanup_pending', 'required_blueprint_id', 'INTEGER'); // Distributed API model columns @@ -1944,6 +1959,18 @@ export class DatabaseService { maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL'); maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0'); maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL'); + maybeAddCol('scheduled_tasks', 'creator_user_id', 'INTEGER DEFAULT NULL'); + + // Backfill creator_user_id from the created_by username column. + // Rows whose username no longer matches a user stay NULL (legacy, + // unrevalidated path — they were created under the old requireAdmin gate). + this.db.exec(` + UPDATE scheduled_tasks + SET creator_user_id = ( + SELECT id FROM users WHERE username = scheduled_tasks.created_by + ) + WHERE creator_user_id IS NULL + `); // Recreate stack_update_status with composite PK (node_id, stack_name). // Original table had stack_name as sole PK which breaks when multiple nodes share stack names. @@ -2024,6 +2051,11 @@ export class DatabaseService { stmt.run('reclaim_hero', '0'); stmt.run('health_gate_enabled', '1'); stmt.run('health_gate_window_seconds', '90'); + // Superseded-generation retention (days) and a per-stack cap on total + // retained generations (0 = unlimited). Never applies to the current + // generation, which stays protected until superseded or released. + stmt.run('recovery_retention_days', '7'); + stmt.run('recovery_max_generations', '0'); stmt.run('image_update_check_interval_minutes', '120'); stmt.run('image_update_check_mode', 'interval'); stmt.run('image_update_check_cron', ''); @@ -2245,11 +2277,109 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); `); - try { - this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)'); - } catch (e) { - console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message); - } + this.migrateRoleAssignmentsNodeQualified(); + } + + /** + * Rebuild role_assignments with Mesh-style stack identity (node_id, resource_id). + * Idempotent: probes sqlite_master for the final CHECK and both partial unique indexes. + */ + private migrateRoleAssignmentsNodeQualified(): void { + const tableSql = (this.db.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'" + ).get() as { sql: string } | undefined)?.sql ?? ''; + const indexRows = this.db.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'" + ).all() as Array<{ name: string; sql: string | null }>; + const hasNodeIdColumn = (this.db.prepare( + 'PRAGMA table_info(role_assignments)' + ).all() as Array<{ name: string }>).some((column) => column.name === 'node_id'); + const indexSqlByName = new Map(indexRows.map((r) => [r.name, r.sql ?? ''])); + const checkOk = + tableSql.includes("resource_type = 'stack' AND node_id IS NOT NULL") && + tableSql.includes("resource_type = 'node' AND node_id IS NULL"); + const stackUniqueSql = indexSqlByName.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUniqueSql = indexSqlByName.get('idx_role_assignments_node_unique') ?? ''; + const stackUniqueOk = + stackUniqueSql.includes('user_id') && + stackUniqueSql.includes('role') && + stackUniqueSql.includes('resource_type') && + stackUniqueSql.includes('resource_id') && + stackUniqueSql.includes('node_id') && + /WHERE\s+resource_type\s*=\s*'stack'/i.test(stackUniqueSql); + const nodeUniqueOk = + nodeUniqueSql.includes('user_id') && + nodeUniqueSql.includes('role') && + nodeUniqueSql.includes('resource_type') && + nodeUniqueSql.includes('resource_id') && + /WHERE\s+resource_type\s*=\s*'node'/i.test(nodeUniqueSql) && + !/node_id/.test(nodeUniqueSql.replace(/WHERE[\s\S]*/i, '')); + if (checkOk && stackUniqueOk && nodeUniqueOk) return; + + this.db.exec('DROP TABLE IF EXISTS role_assignments_new'); + + // Do not call getDefaultNode(): NODE_COLUMNS may include columns not + // yet added when this migration runs early in the constructor chain. + const defaultNodeId = ( + this.db.prepare('SELECT id FROM nodes WHERE is_default = 1 LIMIT 1').get() as { id: number } | undefined + )?.id ?? null; + + this.db.transaction(() => { + this.db.exec(` + CREATE TABLE role_assignments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + CHECK ( + (resource_type = 'stack' AND node_id IS NOT NULL) + OR (resource_type = 'node' AND node_id IS NULL) + ), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + `); + + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, NULL, created_at + FROM role_assignments + WHERE resource_type = 'node'; + `); + + if (hasNodeIdColumn) { + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, node_id, created_at + FROM role_assignments + WHERE resource_type = 'stack' AND node_id IS NOT NULL + `); + } else if (defaultNodeId !== null) { + this.db.prepare(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, ?, created_at + FROM role_assignments + WHERE resource_type = 'stack' + `).run(defaultNodeId); + } + // No default node: legacy stack rows are intentionally omitted (fail closed). + + this.db.exec(` + DROP TABLE role_assignments; + ALTER TABLE role_assignments_new RENAME TO role_assignments; + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); + })(); } private migrateNotificationRoutes(): void { @@ -3406,6 +3536,10 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM stack_alerts').all() as StackAlert[]; } + public getStackAlert(id: number): StackAlert | undefined { + return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(id) as StackAlert | undefined; + } + public addStackAlert(alert: StackAlert): StackAlert { const stmt = this.db.prepare( 'INSERT INTO stack_alerts (stack_name, service_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' @@ -4095,16 +4229,39 @@ export class DatabaseService { return result.changes === 1; } + /** Days a superseded generation's Docker/FS artifacts are retained before automatic cleanup. Never applies to the current generation. */ + public getRecoveryRetentionDays(): number { + try { + const raw = parseInt(this.getGlobalSettings()['recovery_retention_days'] ?? '', 10); + return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 90) : DEFAULT_RECOVERY_RETENTION_DAYS; + } catch (e) { + console.warn('[DatabaseService] recovery_retention_days read failed; using default:', (e as Error).message); + return DEFAULT_RECOVERY_RETENTION_DAYS; + } + } + + /** Total generations retained per stack, current included (0 = unlimited). */ + public getRecoveryMaxGenerations(): number { + try { + const raw = parseInt(this.getGlobalSettings()['recovery_max_generations'] ?? '', 10); + return Number.isFinite(raw) && raw >= 0 ? Math.min(raw, 50) : DEFAULT_RECOVERY_MAX_GENERATIONS; + } catch (e) { + console.warn('[DatabaseService] recovery_max_generations read failed; using default:', (e as Error).message); + return DEFAULT_RECOVERY_MAX_GENERATIONS; + } + } + public casHandoffGeneration(candidateId: string, nodeId: number, stackName: string): boolean { const handoff = this.db.transaction(() => { const candidate = this.getStackUpdateRecoveryGeneration(candidateId); if (!candidate || candidate.node_id !== nodeId || candidate.stack_name !== stackName) return false; if (candidate.status !== 'candidate' || candidate.phase !== 'acquired') return false; + const retentionMs = this.getRecoveryRetentionDays() * 24 * 60 * 60 * 1000; this.db.prepare( `UPDATE stack_update_recovery_generations SET status = 'superseded', is_current = 0, artifact_expires_at = ?, updated_at = ? WHERE node_id = ? AND stack_name = ? AND is_current = 1 AND id != ?` - ).run(Date.now() + 7 * 24 * 60 * 60 * 1000, Date.now(), nodeId, stackName, candidateId); + ).run(Date.now() + retentionMs, Date.now(), nodeId, stackName, candidateId); const result = this.db.prepare( `UPDATE stack_update_recovery_generations SET status = 'active', is_current = 1, phase = 'handoff_committed', updated_at = ? @@ -4116,18 +4273,41 @@ export class DatabaseService { } - /** Generations whose Docker/FS artifacts can be retired (not actively held). */ + /** + * Generations whose Docker/FS artifacts can be retired (not actively held). + * A manually released row (released_at set) is swept immediately regardless + * of its expiry timers; a naturally abandoned/superseded row still waits out + * artifact_expires_at / gate_retain_until. + */ public listStackUpdateRecoveryGenerationsForArtifactRetirement(now: number): StackUpdateRecoveryGenerationRow[] { return this.db.prepare( `SELECT * FROM stack_update_recovery_generations WHERE artifacts_retired = 0 AND is_current = 0 - AND status IN ('abandoned', 'superseded') - AND (artifact_expires_at IS NULL OR artifact_expires_at <= ?) - AND (gate_retain_until IS NULL OR gate_retain_until <= ?)` + AND ( + released_at IS NOT NULL + OR ( + status IN ('abandoned', 'superseded') + AND (artifact_expires_at IS NULL OR artifact_expires_at <= ?) + AND (gate_retain_until IS NULL OR gate_retain_until <= ?) + ) + )` ).all(now, now) as StackUpdateRecoveryGenerationRow[]; } + /** + * Superseded, not-yet-retired, not-released generations across every node + * for cap enforcement (mirrors the other reconcile-sweep list methods, + * which are also unscoped by node), newest first per (node_id, stack_name). + */ + public listActiveSupersededGenerations(): StackUpdateRecoveryGenerationRow[] { + return this.db.prepare( + `SELECT * FROM stack_update_recovery_generations + WHERE status = 'superseded' AND artifacts_retired = 0 AND released_at IS NULL + ORDER BY node_id, stack_name, created_at DESC, id DESC` + ).all() as StackUpdateRecoveryGenerationRow[]; + } + public markStackUpdateRecoveryArtifactsRetired(id: string): boolean { const result = this.db.prepare( `UPDATE stack_update_recovery_generations @@ -4148,6 +4328,33 @@ export class DatabaseService { return result.changes === 1; } + /** + * Operator-initiated release of rollback protection. A single conditional + * UPDATE both revalidates eligibility and performs the transition + * atomically, so a stale caller can never release a row that has since + * become ineligible (e.g. it started a health gate observation, or moved + * to recovery_required). Only clears is_current/timestamps; Docker tag and + * override-file cleanup is the caller's job via retireGenerationArtifacts, + * matching how abandon() already separates the DB transition from cleanup. + */ + public releaseStackUpdateRecoveryGeneration(id: string, releasedBy: string | null): boolean { + const now = Date.now(); + const result = this.db.prepare( + `UPDATE stack_update_recovery_generations + SET released_at = ?, released_by = ?, is_current = 0, updated_at = ? + WHERE id = ? + AND released_at IS NULL + AND artifacts_retired = 0 + AND phase = 'immediate_verified' + AND status IN ('active', 'restored_current', 'superseded') + AND (health_gate_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM health_gate_runs g + WHERE g.id = stack_update_recovery_generations.health_gate_id AND g.status = 'observing' + ))` + ).run(now, releasedBy, now, id); + return result.changes === 1; + } + /** Pre-handoff candidates whose operation lease has expired. */ public listStaleStackUpdateRecoveryCandidates(now: number): StackUpdateRecoveryGenerationRow[] { return this.db.prepare( @@ -4185,6 +4392,7 @@ export class DatabaseService { const rows = this.db.prepare( `SELECT services_json FROM stack_update_recovery_generations WHERE node_id = ? + AND released_at IS NULL AND status IN ('candidate','active','restored_current','recovery_required') AND (artifact_expires_at IS NULL OR artifact_expires_at > ? OR gate_retain_until > ? OR is_current = 1)` ).all(nodeId, now, now) as Array<{ services_json: string }>; @@ -4320,7 +4528,7 @@ export class DatabaseService { const categories = [ 'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed', - 'network_auto_created', + 'network_auto_created', 'rollback_generation_released', ]; const placeholders = categories.map(() => '?').join(', '); const sql = ` @@ -4723,6 +4931,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id); this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); + this.deleteRoleAssignmentsByStackNode(id); this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id); this.db.prepare( @@ -5405,23 +5614,44 @@ export class DatabaseService { // --- Role Assignments --- - public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] { + public getRoleAssignments( + userId: number, + resourceType: ResourceType, + resourceId: string, + nodeId?: number | null, + ): RoleAssignment[] { + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return []; + return this.db.prepare( + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id = ?' + ).all(userId, resourceType, resourceId, nodeId) as RoleAssignment[]; + } return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?' + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id IS NULL' ).all(userId, resourceType, resourceId) as RoleAssignment[]; } public getAllRoleAssignments(userId: number): RoleAssignment[] { return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id' + 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id, node_id' ).all(userId) as RoleAssignment[]; } - public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number { + public addRoleAssignment(assignment: { + user_id: number; + role: UserRole; + resource_type: ResourceType; + resource_id: string; + node_id?: number | null; + }): number { const now = Date.now(); + const nodeId = assignment.resource_type === 'stack' ? assignment.node_id ?? null : null; + if (assignment.resource_type === 'stack' && (nodeId === null || nodeId === undefined)) { + throw new Error('node_id is required for stack role assignments'); + } const result = this.db.prepare( - 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)' - ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now); + 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, node_id, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, nodeId, now); return result.lastInsertRowid as number; } @@ -5441,6 +5671,20 @@ export class DatabaseService { this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId); } + /** Clear stack-scoped grants for one (nodeId, stackName) tuple. */ + public deleteRoleAssignmentsByStack(nodeId: number, stackName: string): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ? AND resource_id = ?" + ).run(nodeId, stackName); + } + + /** Clear all stack-scoped grants for a node (explicit cleanup; FK CASCADE is not enforced). */ + public deleteRoleAssignmentsByStackNode(nodeId: number): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ?" + ).run(nodeId); + } + // --- SSO Config --- public getSSOConfigs(): SSOConfig[] { @@ -6045,12 +6289,13 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined; } - public createScheduledTask(task: Omit): number { + public createScheduledTask(task: Omit & { creator_user_id?: number | null }): number { const result = this.db.prepare( - 'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( task.name, task.target_type, task.target_id, task.node_id, task.action, task.cron_expression, task.enabled, task.created_by, + task.creator_user_id ?? null, task.created_at, task.updated_at, task.last_run_at, task.next_run_at, task.last_status, task.last_error, task.prune_targets, task.target_services, task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null, diff --git a/backend/src/services/DeployedStackDeletionService.ts b/backend/src/services/DeployedStackDeletionService.ts index b9ca7b92..340a6294 100644 --- a/backend/src/services/DeployedStackDeletionService.ts +++ b/backend/src/services/DeployedStackDeletionService.ts @@ -359,7 +359,7 @@ export class DeployedStackDeletionService { try { db.clearStackUpdateStatus(nodeId, stackName); db.clearStackScanAttempts(nodeId, stackName); - db.deleteRoleAssignmentsByResource('stack', stackName); + db.deleteRoleAssignmentsByStack(nodeId, stackName); db.deleteGitSource(stackName); db.deleteStackDossier(nodeId, stackName); db.deleteStackDriftFindings(nodeId, stackName); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 00ae367e..919d1237 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -13,6 +13,7 @@ import SelfIdentityService from './SelfIdentityService'; import { fingerprintPrunePlan, normalizePruneTargets, + projectPruneOwnershipLabels, PRUNEABLE_CONTAINER_STATES, PrunePlanStaleError, type PruneItemOutcome, @@ -142,6 +143,9 @@ export interface ClassifiedImage { managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; + /** True when a StackUpdateRecoveryService/ServiceUpdateRecoveryService hold protects this image from pruning. Additive: does not change managedStatus semantics. */ + rollbackProtected: boolean; + rollbackProtectionKind?: 'stack' | 'service'; } export interface PortInUseInfo { @@ -560,23 +564,52 @@ class DockerController { const selfIdentity = SelfIdentityService.getInstance(); - const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { - const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); - const managedBy = usedByStacks[0] ?? null; - const managedStatus: ClassifiedImage['managedStatus'] = - img.Containers === 0 ? 'unused' : - managedBy ? 'managed' : 'unmanaged'; - return { - Id: img.Id, - RepoTags: img.RepoTags ?? [], - Size: img.Size ?? 0, - Containers: img.Containers ?? 0, - usedByStacks, - managedBy, - managedStatus, - isSencho: selfIdentity.isOwnImage(img.Id), - }; - }); + // Dynamic (async) imports avoid a static cycle: StackUpdateRecoveryService + // imports DockerController directly, and ServiceUpdateRecoveryService + // reaches it transitively through ComposeService. It must be `await import` + // rather than require(), which does not resolve under Vitest's loader. + const { StackUpdateRecoveryService } = await import('./StackUpdateRecoveryService'); + const { ServiceUpdateRecoveryService } = await import('./ServiceUpdateRecoveryService'); + const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(this.nodeId); + const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(this.nodeId); + // A null lookup means "held state unknown" (the DB read failed); treat it + // as held so the badge never disagrees with the delete guard, which fails + // the same way (recoveryHeldImages.ts's buildUnifiedHeldImagePredicate). + const rollbackKind = (imageId: string): ClassifiedImage['rollbackProtectionKind'] => { + if (stackHeld === null || stackHeld.has(imageId)) return 'stack'; + if (serviceHeld === null || serviceHeld.has(imageId)) return 'service'; + return undefined; + }; + + // Only hide an image from the generic inventory when every visible tag is + // a synthetic sencho-rb hold tag; an image that also carries a normal + // registry tag stays visible here (with the badge below) so the generic + // inventory stays complete. Its generation still surfaces in the Rollback tab. + const isFullySyntheticHoldImage = (repoTags: string[]): boolean => + repoTags.length > 0 && repoTags.every((tag) => tag.startsWith('sencho-rb/')); + + const images: ClassifiedImage[] = this.validateApiData(rawImages) + .map((img: any) => { + const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); + const managedBy = usedByStacks[0] ?? null; + const managedStatus: ClassifiedImage['managedStatus'] = + img.Containers === 0 ? 'unused' : + managedBy ? 'managed' : 'unmanaged'; + const rollbackProtectionKind = rollbackKind(img.Id); + return { + Id: img.Id, + RepoTags: img.RepoTags ?? [], + Size: img.Size ?? 0, + Containers: img.Containers ?? 0, + usedByStacks, + managedBy, + managedStatus, + isSencho: selfIdentity.isOwnImage(img.Id), + rollbackProtected: rollbackProtectionKind !== undefined, + rollbackProtectionKind, + }; + }) + .filter((img) => !isFullySyntheticHoldImage(img.RepoTags)); const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => { const stack = DockerController.resolveProjectLabel(vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack); @@ -921,17 +954,18 @@ class DockerController { if (selfIdentity.isOwnContainer(c.Id)) continue; const state = String(c.State ?? '').toLowerCase(); if (!PRUNEABLE_CONTAINER_STATES.has(state)) continue; - if (scope === 'managed') { - const stack = DockerController.resolveContainerStack( - c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, - ); - if (!stack) continue; - } + const stack = DockerController.resolveContainerStack( + c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; items.push({ target: 'containers', id: c.Id, name: containerName(c), sizeBytes: typeof c.SizeRw === 'number' && c.SizeRw > 0 ? c.SizeRw : undefined, + managed: Boolean(stack), + reason: `Container is ${state} and no longer running`, + stackName: stack ?? undefined, }); } } @@ -946,23 +980,29 @@ class DockerController { const rawVolumeData = await this.docker.listVolumes(); const rawVolumes = (this.validateApiData<{ Volumes?: Array<{ Name: string; + Driver?: string; Labels?: Record; }> }>(rawVolumeData)).Volumes || []; for (const vol of rawVolumes) { if (selfIdentity.isOwnVolume(vol.Name)) continue; const usage = volumeUsage.get(vol.Name); if (!usage || usage.refCount !== 0) continue; - if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); - if (!stack) continue; - } + const stack = DockerController.resolveContainerStack( + vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; items.push({ target: 'volumes', id: vol.Name, name: vol.Name, sizeBytes: usage.size > 0 ? usage.size : undefined, + managed: Boolean(stack), + reason: 'Volume is not referenced by any container', + stackName: stack ?? undefined, + volume: { + driver: vol.Driver, + ownershipLabels: projectPruneOwnershipLabels(vol.Labels), + }, }); } } @@ -971,6 +1011,8 @@ class DockerController { const rawNetworks = await this.docker.listNetworks() as Array<{ Id: string; Name: string; + Driver?: string; + Scope?: string; Labels?: Record; }>; const networksInUse = new Set(); @@ -998,19 +1040,30 @@ class DockerController { ); continue; } - if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - net.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); - if (!stack) continue; - } - items.push({ target: 'networks', id: net.Id, name: net.Name }); + const stack = DockerController.resolveContainerStack( + net.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; + items.push({ + target: 'networks', + id: net.Id, + name: net.Name, + managed: Boolean(stack), + reason: 'Network has no attached containers', + stackName: stack ?? undefined, + network: { + driver: net.Driver, + scope: net.Scope, + ownershipLabels: projectPruneOwnershipLabels(net.Labels), + }, + }); } } if (ordered.includes('images')) { const unmanagedImageIds = new Set(); const managedImageIds = new Set(); + const imageToStack = new Map(); const imageToContainerIds = new Map(); for (const c of allContainers) { if (!c.ImageID) continue; @@ -1020,7 +1073,10 @@ class DockerController { const stack = DockerController.resolveContainerStack( c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); - if (stack) managedImageIds.add(c.ImageID); + if (stack) { + managedImageIds.add(c.ImageID); + if (!imageToStack.has(c.ImageID)) imageToStack.set(c.ImageID, stack); + } else unmanagedImageIds.add(c.ImageID); } const plannedContainerIds = new Set( @@ -1029,10 +1085,12 @@ class DockerController { const rawImages = await this.docker.listImages({ all: false }) as Array<{ Id: string; RepoTags?: string[] | null; + RepoDigests?: string[] | null; Labels?: Record; Size?: number; VirtualSize?: number; Containers?: number; + Created?: number; }>; // An image becomes free only when every container that references it is // also in this plan (not merely when any planned container uses it). @@ -1040,31 +1098,39 @@ class DockerController { for (const img of rawImages) { if (selfIdentity.isOwnImage(img.Id)) continue; if (isImageHeld?.(img.Id)) continue; - const containers = img.Containers ?? 0; const refs = imageToContainerIds.get(img.Id) ?? []; const becomesFree = freeingImages && refs.length > 0 - && refs.length >= containers && refs.every((id) => plannedContainerIds.has(id)); - if (containers > 0 && !becomesFree) continue; + if (refs.length > 0 && !becomesFree) continue; + const labeled = DockerController.resolveContainerStack( + img.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + const stack = labeled ?? imageToStack.get(img.Id) ?? null; if (scope === 'managed') { if (unmanagedImageIds.has(img.Id)) continue; - const labeled = DockerController.resolveProjectLabel( - img.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); // Unattributed unused images (no managed container, no compose label) // are not Sencho-managed; keep them out of managed prune. - if (!becomesFree && !labeled && !managedImageIds.has(img.Id)) continue; + if (!becomesFree && !stack && !managedImageIds.has(img.Id)) continue; } - const name = img.RepoTags?.[0] && img.RepoTags[0] !== ':' - ? img.RepoTags[0] - : img.Id.slice(0, 12); + const references = (img.RepoTags ?? []).filter((ref) => ref && ref !== ':'); + const name = references[0] ?? ':'; const unique = DockerController.imageUniqueBytes(img, sharedSizes); items.push({ target: 'images', id: img.Id, name, sizeBytes: unique > 0 ? unique : undefined, + managed: Boolean(stack || managedImageIds.has(img.Id)), + reason: becomesFree + ? 'Image becomes unused after planned container removal' + : 'Image is not used by any container', + stackName: stack ?? undefined, + image: { + references, + digest: img.RepoDigests?.find((digest) => Boolean(digest)), + createdAt: typeof img.Created === 'number' ? img.Created : undefined, + }, }); } } @@ -1093,7 +1159,8 @@ class DockerController { /** * Rebuild the plan with the same targets/scope. Returns the fresh plan when - * the fingerprint still matches, otherwise null (caller maps to 409). + * the fingerprint still matches, otherwise null so the caller can report + * staleness through its route-specific response contract. */ public async assertPlanFresh( plan: PrunePlan, @@ -1173,14 +1240,18 @@ class DockerController { } if (target === 'volumes') { - const outcome = await this.executePlannedVolume(item, fresh.scope, knownSet, projectToStack, selfIdentity); + const outcome = await this.executePlannedVolume( + item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + ); outcomes.push(outcome); if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0; continue; } if (target === 'networks') { - outcomes.push(await this.executePlannedNetwork(item, fresh.scope, knownSet, projectToStack, selfIdentity)); + outcomes.push(await this.executePlannedNetwork( + item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + )); continue; } @@ -1207,7 +1278,7 @@ class DockerController { } } const outcome = await this.executePlannedImage( - item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + item, selfIdentity, ); outcomes.push(outcome); if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0; @@ -1238,9 +1309,10 @@ class DockerController { private async imageStillReferenced(imageId: string): Promise { try { - const images = await this.docker.listImages({ all: false }) as Array<{ Id: string; Containers?: number }>; - const match = images.find((img) => img.Id === imageId || img.Id.startsWith(imageId) || imageId.startsWith(img.Id)); - return (match?.Containers ?? 0) > 0; + const containers = await this.docker.listContainers({ all: true }) as Array<{ ImageID?: string }>; + return containers.some((container) => container.ImageID === imageId + || Boolean(container.ImageID?.startsWith(imageId)) + || imageId.startsWith(container.ImageID ?? '')); } catch { return true; } @@ -1250,8 +1322,8 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, - absDirToStack: Record, + projectToStack: Map, + absDirToStack: Map, resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise< @@ -1305,7 +1377,9 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, + projectToStack: Map, + absDirToStack: Map, + resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnVolume(item.id)) { @@ -1325,8 +1399,8 @@ class DockerController { return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume is in use' }; } if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack, + const stack = DockerController.resolveContainerStack( + vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); if (!stack) { return { id: item.id, target: 'volumes', status: 'skipped', reason: 'No longer a managed volume' }; @@ -1340,7 +1414,9 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, + projectToStack: Map, + absDirToStack: Map, + resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnNetwork(item.id)) { @@ -1363,8 +1439,8 @@ class DockerController { return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network is in use' }; } if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - inspected.Labels?.['com.docker.compose.project'], knownSet, projectToStack, + const stack = DockerController.resolveContainerStack( + inspected.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); if (!stack) { return { id: item.id, target: 'networks', status: 'skipped', reason: 'No longer a managed network' }; @@ -1376,11 +1452,6 @@ class DockerController { private async executePlannedImage( item: PrunePlanItem, - scope: PruneScope, - knownSet: Set, - projectToStack: Record, - absDirToStack: Record, - resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnImage(item.id)) { @@ -1389,30 +1460,21 @@ class DockerController { const rawImages = await this.docker.listImages({ all: false }) as Array<{ Id: string; Size?: number; - Containers?: number; }>; const img = rawImages.find((i) => i.Id === item.id || i.Id.startsWith(item.id) || item.id.startsWith(i.Id)); if (!img) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' }; } - if ((img.Containers ?? 0) > 0) { + const allContainers = await this.docker.listContainers({ all: true }) as Array<{ + ImageID?: string; + Labels?: Record; + }>; + const references = allContainers.filter((container) => container.ImageID === img.Id + || Boolean(container.ImageID?.startsWith(img.Id)) + || img.Id.startsWith(container.ImageID ?? '')); + if (references.length > 0) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' }; } - if (scope === 'managed') { - const allContainers = await this.docker.listContainers({ all: true }) as Array<{ - ImageID?: string; - Labels?: Record; - }>; - for (const c of allContainers) { - if (c.ImageID !== img.Id) continue; - const stack = DockerController.resolveContainerStack( - c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, - ); - if (!stack) { - return { id: item.id, target: 'images', status: 'skipped', reason: 'Image referenced by unmanaged container' }; - } - } - } await this.docker.getImage(item.id).remove({ force: false }); // Prefer plan unique-bytes; do not fall back to full Size (shared layers). return { id: item.id, target: 'images', status: 'removed', sizeBytes: item.sizeBytes ?? 0 }; @@ -1474,6 +1536,23 @@ class DockerController { return { inspect, history }; } + /** + * Resolve any valid Docker image reference (full ID, short ID, digest, or + * tag) to its canonical full sha256 ID. isValidDockerResourceId accepts + * short IDs down to 12 hex chars, which a held-image-id set lookup (always + * keyed on the full 64-char form) would miss without this resolve step. + * Returns null when the image does not exist. + */ + public async resolveImageId(id: string): Promise { + try { + const info = await this.docker.getImage(id).inspect(); + return info.Id; + } catch (error) { + if ((error as { statusCode?: number })?.statusCode === 404) return null; + throw error; + } + } + public async removeVolume(name: string) { const volume = this.docker.getVolume(name); await volume.remove({ force: true }); @@ -1925,19 +2004,20 @@ class DockerController { private static resolveProjectLabel( project: string | undefined, knownSet: Set, - projectToStack: Record, + projectToStack: Map, ): string | null { if (!project) return null; if (knownSet.has(project)) return project; - if (projectToStack[project]) return projectToStack[project]; - return null; + return projectToStack.get(project) ?? null; } /** Builds a map from absolute stack directory paths to stack names. */ - private static buildAbsDirMap(stackNames: string[]): Record { - const map: Record = {}; + private static buildAbsDirMap(stackNames: string[]): Map { + const map = new Map(); for (const stackDir of stackNames) { - map[path.join(COMPOSE_DIR, stackDir)] = stackDir; + const stackPath = path.join(COMPOSE_DIR, stackDir); + map.set(stackPath, stackDir); + map.set(path.resolve(stackPath), stackDir); } return map; } @@ -1948,21 +2028,24 @@ class DockerController { */ private static resolveContainerStack( containerLabels: Record | undefined, - projectToStack: Record, + projectToStack: Map, knownStackSet: Set, - absDirToStack: Record, + absDirToStack: Map, resolvedBase: string, ): string | null { if (!containerLabels) return null; // Primary: match by project name (handles name: overrides and standard directory-based names) const project = containerLabels['com.docker.compose.project']; - if (project && projectToStack[project]) return projectToStack[project]; + if (project) { + const stack = projectToStack.get(project); + if (stack) return stack; + } // Fallback 1: match by working_dir const workingDir = containerLabels['com.docker.compose.project.working_dir']; if (workingDir) { - const match = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)]; + const match = absDirToStack.get(workingDir) ?? absDirToStack.get(path.resolve(workingDir)); if (match) return match; } @@ -1989,15 +2072,15 @@ class DockerController { * Builds (or returns cached) mapping from Docker project name to Sencho stack directory name. * Compose files with a top-level `name:` field override the default project name. */ - private static async resolveProjectNameMap(stackNames: string[]): Promise> { + private static async resolveProjectNameMap(stackNames: string[]): Promise> { return CacheService.getInstance().getOrFetch( PROJECT_NAME_CACHE_KEY, PROJECT_NAME_CACHE_TTL_MS, async () => { - const map: Record = {}; + const map = new Map(); await Promise.all(stackNames.map(async (stackDir) => { - map[stackDir] = stackDir; + map.set(stackDir, stackDir); for (const fileName of COMPOSE_FILE_NAMES) { const filePath = path.join(COMPOSE_DIR, stackDir, fileName); @@ -2005,7 +2088,7 @@ class DockerController { const content = await fs.readFile(filePath, 'utf-8'); const parsed = yaml.parse(content); if (parsed?.name && typeof parsed.name === 'string') { - map[parsed.name] = stackDir; + map.set(parsed.name, stackDir); } break; } catch (err: unknown) { diff --git a/backend/src/services/DriftDetectionService.ts b/backend/src/services/DriftDetectionService.ts index 3151b4d2..69bd9da4 100644 --- a/backend/src/services/DriftDetectionService.ts +++ b/backend/src/services/DriftDetectionService.ts @@ -5,7 +5,12 @@ import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompos import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse'; import { parseMissingRequiredVars } from '../helpers/envVarParse'; import { parseEffectiveModel } from './preflight/effectiveModel'; -import { compareStackNetworks, fromDeclaredCompose } from './network/normalize'; +import { + compareStackNetworks, + fromDeclaredCompose, + type ManagedNetworkAttachmentPredicate, +} from './network/normalize'; +import { resolveManagedMeshAttachment } from './network/managedMeshAttachment'; import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; import { isCleanOneShotCompletion } from '../utils/oneShotCompletion'; @@ -111,6 +116,8 @@ export interface AssembleStackDriftInput { containers: DependencyContainer[]; /** Every network on the node (for resolving foreign vs stack-owned attachments). */ networks?: DependencyNetwork[]; + /** Authoritative runtime attachments that are intentionally absent from authored Compose. */ + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate; /** Set when the compose file could not be parsed. */ parseError?: string; } @@ -137,12 +144,18 @@ function networkDriftFindings( declared: DeclaredCompose, containers: DependencyContainer[], networks: DependencyNetwork[], + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate, ): StackDriftFinding[] { // Runtime resource names use the Compose project (top-level `name:` when set), // not the stack directory, so a stack with `name:` resolves its networks the // same way Docker does. Containers are still attributed to the stack directory. const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack); - const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack); + const facts = compareStackNetworks( + normalized, + { containers, networks, volumes: [] }, + stack, + managedNetworkAttachment, + ); const findings: StackDriftFinding[] = []; const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name])); @@ -293,7 +306,13 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe } } - findings.push(...networkDriftFindings(stack, declared, containers, networks)); + findings.push(...networkDriftFindings( + stack, + declared, + containers, + networks, + input.managedNetworkAttachment, + )); const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync'; return { stack, status, hasComposeFile: true, hasContainers, findings }; @@ -384,5 +403,12 @@ export async function buildStackDriftReport(nodeId: number, stackName: string): }; } - return assembleStackDrift({ stack: stackName, declared: render.declared, containers, networks }); + const managedNetworkAttachment = await resolveManagedMeshAttachment(nodeId, stackName); + return assembleStackDrift({ + stack: stackName, + declared: render.declared, + containers, + networks, + managedNetworkAttachment, + }); } diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 25d1f5a0..50c8d427 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -430,6 +430,12 @@ export class ImageUpdateService { private isRunning = false; private checkStartedAt = 0; private lastManualRefreshAt = 0; + // Per-stack recheck cooldown (key: `${nodeId}:${stackName}`). Enforces the + // same MANUAL_COOLDOWN_MS as the node-wide triggerManualRefresh, and also + // acts as an in-flight gate: the mark writes before the first await, so a + // second synchronous check-and-mark on the same event-loop tick sees the + // first entry and is denied. + private perStackRecheckAt = new Map(); private lastCheckedAt: number | null = null; // when the last scan body started private nextCheckAt: number | null = null; // Initialized at declaration so getStatus() never reports NaN before start() @@ -693,6 +699,41 @@ export class ImageUpdateService { return Math.max(0, this.lastManualRefreshAt + ImageUpdateService.MANUAL_COOLDOWN_MS - Date.now()); } + /** + * Per-stack rate gate for explicit rechecks (idiomatic API calls and the + * sidebar "Check updates" action). Reuses the same MANUAL_COOLDOWN_MS as the + * node-wide manual trigger so both surfaces share one cooldown policy without + * a new knob. + * + * Returns true when the recheck is allowed and atomically marks in-flight; + * returns false when a recheck for this (nodeId, stackName) was started + * within the cooldown window (including one that is still in-flight, whose + * mark was written synchronously on the previous event-loop tick before the + * first await). + */ + public tryMarkStackRecheck(nodeId: number, stackName: string): boolean { + const key = `${nodeId}:${stackName}`; + const now = Date.now(); + const lastAt = this.perStackRecheckAt.get(key) ?? 0; + if (now - lastAt < ImageUpdateService.MANUAL_COOLDOWN_MS) { + return false; + } + this.perStackRecheckAt.set(key, now); + return true; + } + + /** Milliseconds left on the per-stack recheck cooldown; 0 when allowed. */ + public getStackRecheckCooldownRemainingMs(nodeId: number, stackName: string): number { + const key = `${nodeId}:${stackName}`; + const lastAt = this.perStackRecheckAt.get(key) ?? 0; + return Math.max(0, lastAt + ImageUpdateService.MANUAL_COOLDOWN_MS - Date.now()); + } + + /** Clear every per-stack recheck cooldown (test-only). */ + public resetStackRecheckCooldowns(): void { + this.perStackRecheckAt.clear(); + } + public getStatus(): ImageUpdateStatus { const enabled = ImageUpdateService.isChecksEnabled(); let sidebarIndicators = false; diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index b7b0ff85..ca87971d 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -1,6 +1,7 @@ import net from 'net'; import path from 'path'; import fs from 'fs/promises'; +import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; import * as YAML from 'yaml'; import { ComposeService } from './ComposeService'; @@ -20,6 +21,7 @@ import { STREAM_PENDING_DATA_MAX_BYTES } from '../pilot/protocol'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; import { isPathWithinBase, isValidStackName, isValidRelativeStackPath } from '../utils/validation'; +import { getErrorMessage } from '../utils/errors'; import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; @@ -1497,6 +1499,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // --- Opt-in / opt-out --- public async optInStack(nodeId: number, stackName: string, actor: string): Promise { + return this.runMeshNodeMutation(nodeId, () => this.optInStackExclusive(nodeId, stackName, actor)); + } + + private async optInStackExclusive(nodeId: number, stackName: string, actor: string): Promise { this.logDiag('opt-in start', { nodeId, stackName, actor }); const t0 = Date.now(); if (!isValidStackName(stackName)) { @@ -1541,19 +1547,41 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } db.insertMeshStack(nodeId, stackName, actor); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + db.deleteMeshStack(nodeId, stackName); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (rollbackError) { + console.warn('[MeshService] Failed to refresh aliases after opt-in rollback:', sanitizeForLog(getErrorMessage(rollbackError, 'unknown'))); + } + throw error; + } - // Push the just-opted-in stack's override loudly. If this fails the - // DB state is invalid (alias claimed but remote pilot has no - // override file) so roll back rather than leave a half-state that - // future opt-in calls would short-circuit on `isMeshStackEnabled`. + // Push the just-opted-in stack's override loudly. Explicit target + // rejection rolls back the row. A remote transport failure is + // ambiguous because the target may already have committed, so retain + // authority and let normal regeneration reconcile it. try { await this.pushOverrideToNode(nodeId, stackName); } catch (err) { - db.deleteMeshStack(nodeId, stackName); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + const node = db.getNode(nodeId); + const explicitlyRejected = node?.type !== 'remote' || err instanceof MeshError; + if (explicitlyRejected) { + db.deleteMeshStack(nodeId, stackName); + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } else { + this.logActivity({ + source: 'mesh', level: 'warn', type: 'forwarder.error', + nodeId, + message: `mesh override push outcome unknown for ${stackName}; retaining opt-in authority for reconciliation`, + details: { stackName }, + }); + } throw err; } // Regenerate every other meshed stack's override across the fleet @@ -1584,6 +1612,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } public async optOutStack(nodeId: number, stackName: string, actor: string): Promise { + return this.runMeshNodeMutation(nodeId, () => this.optOutStackExclusive(nodeId, stackName, actor)); + } + + private async optOutStackExclusive(nodeId: number, stackName: string, actor: string): Promise { this.logDiag('opt-out start', { nodeId, stackName, actor }); if (!isValidStackName(stackName)) { throw new MeshError('denied', `invalid stack name: ${stackName}`); @@ -1591,9 +1623,18 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const db = DatabaseService.getInstance(); if (!db.isMeshStackEnabled(nodeId, stackName)) return; db.deleteMeshStack(nodeId, stackName); - await this.removeOverrideFromNode(nodeId, stackName); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.removeOverrideFromNode(nodeId, stackName); + } catch (error) { + db.insertMeshStack(nodeId, stackName, actor); + throw error; + } + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed opt-out:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } // The opted-out row is already deleted, so listMeshStacks() will not // include it. Walk the remaining fleet-wide rows so every other // meshed stack regenerates its override without the dropped alias. @@ -1620,6 +1661,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } public async enableForNode(nodeId: number): Promise { + return this.runMeshNodeMutation(nodeId, () => this.enableForNodeExclusive(nodeId)); + } + + private async enableForNodeExclusive(nodeId: number): Promise { this.logDiag('enable-for-node', { nodeId }); DatabaseService.getInstance().setNodeMeshEnabled(nodeId, true); this.logActivity({ @@ -1642,26 +1687,42 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { nodeId: number, actor: string = 'system:mesh.disable', ): Promise { + return this.runMeshNodeMutation(nodeId, () => this.disableForNodeExclusive(nodeId, actor)); + } + + private async disableForNodeExclusive(nodeId: number, actor: string): Promise { this.logDiag('disable-for-node start', { nodeId, actor }); const t0 = Date.now(); - DatabaseService.getInstance().setNodeMeshEnabled(nodeId, false); - const stacks = DatabaseService.getInstance().listMeshStacks(nodeId); - for (const s of stacks) { - DatabaseService.getInstance().deleteMeshStack(nodeId, s.stack_name); - } + const db = DatabaseService.getInstance(); + const stacks = db.listMeshStacks(nodeId); // Dispatch DELETE /api/mesh/local-override/:stack for remote nodes // (pilot or proxy) so the override file pushed earlier via // applyLocalOverride is removed; falls back to local deletion for // local nodes. Parallelize per the regenerateOverridesForNode // rationale: each remote call is its own HTTP round-trip, so // awaiting sequentially turns N stacks into N serialised DELETEs. - // `allSettled` so a single failure does not abort the others - // (removeOverrideFromNode already swallows errors internally). - await Promise.allSettled( + // `allSettled` so a single failure does not abort the others. + const removals = await Promise.allSettled( stacks.map((s) => this.removeOverrideFromNode(nodeId, s.stack_name)), ); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + const failed: string[] = []; + const removed: typeof stacks = []; + removals.forEach((result, index) => { + const stack = stacks[index]; + if (result.status === 'fulfilled') { + db.deleteMeshStack(nodeId, stack.stack_name); + removed.push(stack); + } else { + failed.push(stack.stack_name); + } + }); + if (failed.length === 0) db.setNodeMeshEnabled(nodeId, false); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed node disable changes:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } // Mirror optOutStack: regenerate every remaining node's override // without the dropped aliases, recompose the rest of the fleet so // their containers shed the stale extra_hosts, and redeploy the @@ -1669,9 +1730,15 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // sencho_mesh network and lose the alias entries they owned. await this.regenerateOverridesAcrossFleet(); this.cascadeRecomposeAcrossFleet(undefined, undefined, actor); - for (const s of stacks) { + for (const s of removed) { this.triggerRedeploy(nodeId, s.stack_name, actor); } + if (failed.length > 0) { + throw new MeshError( + 'push_failed', + `Could not disable Mesh on node ${nodeId}: override removal failed for ${failed.join(', ')}.`, + ); + } this.logDiag('disable-for-node complete', { nodeId, stacks: stacks.length, ms: Date.now() - t0 }); this.logActivity({ source: 'mesh', level: 'info', type: 'mesh.disable', @@ -1679,6 +1746,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { }); } + private readonly meshNodeMutations = new Map>(); + + private async runMeshNodeMutation(nodeId: number, operation: () => Promise): Promise { + const previous = this.meshNodeMutations.get(nodeId) ?? Promise.resolve(); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + this.meshNodeMutations.set(nodeId, pending); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.meshNodeMutations.get(nodeId) === pending) this.meshNodeMutations.delete(nodeId); + } + } + // --- Override file management --- public async ensureStackOverride(nodeId: number, stackName: string): Promise { @@ -1690,6 +1775,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // lives on central per the C-3 design). Use file-presence as the // fallback: if central pushed an override via applyLocalOverride, return // that path so ComposeService picks it up on the next deploy. + if (process.env.SENCHO_MODE !== 'pilot') return null; const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return null; try { @@ -1754,13 +1840,37 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { portAliases?: MeshGlobalAlias[], ): Promise { if (!isValidStackName(stackName)) return null; - if (!this.senchoIp) { + const senchoIp = this.senchoIp; + if (!senchoIp) { throw new MeshError( 'push_failed', this.networkSetupError || 'mesh data plane unavailable on this node', ); } const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const lock = await StackOpLockService.getInstance().runExclusive( + localNodeId, + stackName, + 'deploy', + 'system:mesh.override', + () => this.applyLocalOverrideExclusive(localNodeId, stackName, aliases, senchoIp, portAliases), + ); + if (!lock.ran) { + throw new MeshError( + 'push_failed', + `Cannot apply Mesh override for "${stackName}": another operation (${lock.existing.action}) is already in progress.`, + ); + } + return lock.result; + } + + private async applyLocalOverrideExclusive( + localNodeId: number, + stackName: string, + aliases: MeshAlias[], + senchoIp: string, + portAliases?: MeshGlobalAlias[], + ): Promise { const serviceNames = await this.getDeclaredStackServiceNames(stackName, localNodeId); const dir = this.overrideDirFor(localNodeId); @@ -1785,6 +1895,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { message: `mesh override preserved for ${stackName}: declared services unreadable, keeping ${existing.length} existing entries`, details: { stackName, preservedServices: existing }, }); + this.recordLocalOverrideIntent(localNodeId, stackName); return file; } } @@ -1792,13 +1903,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const yaml = generateOverrideYaml({ services: serviceNames, aliases, - senchoIp: this.senchoIp, + senchoIp, }); - await fs.writeFile(file, yaml, 'utf8'); + const previousYaml = await this.readOverrideContent(file); + await this.writeOverrideAtomically(file, yaml); + try { + this.recordLocalOverrideIntent(localNodeId, stackName); + } catch (error) { + await this.restoreOverrideAfterAuthorityFailure(file, previousYaml); + throw error; + } if (portAliases && portAliases.length > 0) { this.pilotAliasOverlay.set(stackName, portAliases); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed override apply:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } } return file; } @@ -1810,13 +1932,95 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { public async removeLocalOverride(stackName: string): Promise { if (!isValidStackName(stackName)) return; const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const lock = await StackOpLockService.getInstance().runExclusive( + localNodeId, + stackName, + 'deploy', + 'system:mesh.override', + () => this.removeLocalOverrideExclusive(localNodeId, stackName), + ); + if (!lock.ran) { + throw new MeshError( + 'push_failed', + `Cannot remove Mesh override for "${stackName}": another operation (${lock.existing.action}) is already in progress.`, + ); + } + } + + private async removeLocalOverrideExclusive(localNodeId: number, stackName: string): Promise { const dir = this.overrideDirFor(localNodeId); const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return; - try { await fs.unlink(file); } catch { /* ignore not-exist */ } + const db = DatabaseService.getInstance(); + const hadIntent = db.isMeshStackEnabled(localNodeId, stackName); + if (hadIntent) db.deleteMeshStack(localNodeId, stackName); + try { + await fs.unlink(file); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + if (hadIntent) db.insertMeshStack(localNodeId, stackName, null); + throw error; + } + } if (this.pilotAliasOverlay.delete(stackName)) { - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed override removal:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } + } + } + + private async writeOverrideAtomically(file: string, yaml: string): Promise { + const dir = path.dirname(file); + const tempFile = path.resolve(dir, `.${path.basename(file)}.${randomUUID()}.tmp`); + if (!isPathWithinBase(tempFile, dir)) throw new Error('Invalid Mesh override temporary path'); + + let handle: Awaited> | null = null; + try { + handle = await fs.open(tempFile, 'wx'); + await handle.writeFile(yaml, 'utf8'); + await handle.sync(); + await handle.close(); + handle = null; + await fs.rename(tempFile, file); + } catch (error) { + if (handle) { + try { await handle.close(); } catch (closeError) { + console.warn('[MeshService] Failed to close temporary override:', sanitizeForLog(getErrorMessage(closeError, 'unknown'))); + } + } + try { + await fs.unlink(tempFile); + } catch (cleanupError) { + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) { + console.warn('[MeshService] Failed to clean up temporary override:', sanitizeForLog(getErrorMessage(cleanupError, 'unknown'))); + } + } + throw error; + } + } + + private async readOverrideContent(file: string): Promise { + try { + return await fs.readFile(file, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null; + throw error; + } + } + + private async restoreOverrideAfterAuthorityFailure(file: string, previousYaml: string | null): Promise { + try { + if (previousYaml === null) { + await fs.unlink(file); + } else { + await this.writeOverrideAtomically(file, previousYaml); + } + } catch (error) { + if (previousYaml === null && error instanceof Error && 'code' in error && error.code === 'ENOENT') return; + console.warn('[MeshService] Failed to restore override after authority error:', sanitizeForLog(getErrorMessage(error, 'unknown'))); } } @@ -1825,7 +2029,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const dir = this.overrideDirFor(nodeId); const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return; - try { await fs.unlink(file); } catch { /* ignore not-exist */ } + try { + await fs.unlink(file); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; + } } private overrideDirFor(nodeId: number): string { @@ -1833,6 +2041,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { return path.join(dataDir, 'mesh', 'overrides', String(nodeId)); } + private recordLocalOverrideIntent(nodeId: number, stackName: string): void { + if (process.env.SENCHO_MODE === 'pilot') return; + const db = DatabaseService.getInstance(); + if (db.isMeshStackEnabled(nodeId, stackName)) return; + db.insertMeshStack(nodeId, stackName, null); + } + private async regenerateOverridesForNode(nodeId: number, skipStack?: string): Promise { const db = DatabaseService.getInstance(); const stacks = db.listMeshStacks(nodeId); @@ -2331,7 +2546,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { ); } if (!res.ok) { - throw new MeshError('push_failed', `HTTP ${res.status} from node ${node.name}`); + const message = `HTTP ${res.status} from node ${node.name}`; + if (res.status >= 400 && res.status < 500) { + throw new MeshError('push_failed', message); + } + throw new Error(message); } } @@ -2436,15 +2655,20 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } try { - await this.proxyFetch( + const response = await this.proxyFetch( nodeId, 'DELETE', `/api/mesh/local-override/${encodeURIComponent(stackName)}`, undefined, 5_000, ); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status} from node ${node.name}: ${body.slice(0, 256)}`); + } } catch (err) { console.warn('[MeshService] removeOverrideFromNode failed:', sanitizeForLog((err as Error).message)); + throw err; } } diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 4df2a575..54268c63 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -330,8 +330,12 @@ export class MonitorService { this.clearHostMetricSuppression('cpu'); } - // ZFS ARC aware: reclaimable ARC is added back into available so a large ARC - // cache does not fire spurious host-memory alerts. See helpers/hostMemory.ts. + // ZFS ARC aware: reclaimable ARC is added back into available so a + // large ARC cache does not fire spurious host-memory alerts. + // Ballooned memory is deliberately NOT subtracted here: unlike + // ARC, ballooned pages are reclaimed by the hypervisor and the + // guest cannot get them back on demand. A ballooned VM with real + // memory pressure must still alert. See helpers/hostMemory.ts. const ramUsage = hostMem.usagePercent; const ramLimit = parseFloat(settings['host_ram_limit']); if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index ba414ce0..eaa03f7e 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -48,6 +48,9 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + // Manual rollback-generation release (Resources → Rollback). History-only + // for the same reason as the drift pair above. + | 'rollback_generation_released' // Automatic external-network creation during deploy. History-only. | 'network_auto_created' | 'node_update_available' @@ -67,7 +70,7 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [ ...ALL_NOTIFICATION_CATEGORIES, 'drift_detected', 'drift_resolved', 'update_started', 'health_gate_passed', 'health_gate_failed', - 'network_auto_created', + 'network_auto_created', 'rollback_generation_released', ]; /** Webhook timeout: 10 seconds per external dispatch call. */ diff --git a/backend/src/services/PilotTunnelBridge.ts b/backend/src/services/PilotTunnelBridge.ts index 5a998830..04a318b1 100644 --- a/backend/src/services/PilotTunnelBridge.ts +++ b/backend/src/services/PilotTunnelBridge.ts @@ -918,23 +918,33 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle if (sendClose) this.sendJson({ t: 'tcp_close', s }); }; - // Pre-connect failure: ack-fail and drop. The handler is removed in - // 'connect' below so post-connect errors fall through to the - // mid-stream teardown path instead of double-firing. - const onPreConnectError = (err?: Error) => { - if (!this.streams.has(s)) return; - this.streams.delete(s); - meshSvc.logActivity({ - source: 'mesh', level: 'error', type: 'route.resolve.fail', - nodeId: this.nodeId, - message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`, - details: { ...baseDetails, reason: 'connect_error' }, - }); - this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' }); - }; - socket.once('error', onPreConnectError); + // One persistent 'error' listener attached at socket creation, + // branching on `connected`, instead of swapping a pre-connect handler + // for a post-connect one inside the 'connect' callback. A swap leaves + // a window (real under some schedulers, e.g. CI runners) where the + // socket briefly has zero 'error' listeners between removing the old + // one and attaching the new one; a Node EventEmitter 'error' with no + // listener throws instead of being swallowed. Keeping a single + // listener for the socket's whole lifetime removes that window + // entirely rather than narrowing it. + let connected = false; + socket.on('error', (err?: Error) => { + if (!connected) { + if (!this.streams.has(s)) return; + this.streams.delete(s); + meshSvc.logActivity({ + source: 'mesh', level: 'error', type: 'route.resolve.fail', + nodeId: this.nodeId, + message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`, + details: { ...baseDetails, reason: 'connect_error' }, + }); + this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' }); + return; + } + teardown(true); + }); socket.once('connect', () => { - socket.off('error', onPreConnectError); + connected = true; meshSvc.logActivity({ source: 'mesh', level: 'info', type: 'route.resolve.ok', nodeId: this.nodeId, @@ -960,7 +970,6 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle this.refreshIdleTimer(s, cur); }); socket.on('close', () => teardown(true)); - socket.on('error', () => teardown(true)); }); } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 18f1ce49..5b62f417 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser'; import { DatabaseService } from './DatabaseService'; import type { ScheduledTask } from './DatabaseService'; import { LicenseService } from './LicenseService'; +import type { LicenseTier } from './license-types'; import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import DockerController from './DockerController'; import { ComposeService } from './ComposeService'; @@ -35,6 +36,8 @@ import { filterContainersByComposeService } from '../helpers/composeServiceMatch import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { enforcePolicyPreDeploy } from './PolicyEnforcement'; import { summarizeBlockReasons } from '../utils/policy-risk'; +import { resolveTaskPermissionScope, type BackendScheduledAction, type TargetType } from './scheduledActionRegistry'; +import { checkPermissionForSubject } from '../middleware/permissions'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; @@ -42,6 +45,14 @@ const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; const TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000; const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000; +/** Thrown when a scheduled task's creator no longer has permission for the target action. */ +export class TaskAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TaskAuthorizationError'; + } +} + export class SchedulerService { private static instance: SchedulerService; private intervalId: ReturnType | null = null; @@ -303,6 +314,38 @@ export class SchedulerService { }); try { + // Permission revalidation: for automatic runs, verify the creator still holds + // the required permission. Runs before the node-reachability check so that + // a revoked authorization is surfaced even when the target node is offline + // — a misleading "target node is offline" error must not hide the real + // reason the task cannot execute. Manual runs skip this; the route's + // acting-user check is the gate. Legacy tasks (creator_user_id NULL) + // execute as before. + if (triggeredBy === 'scheduler' && task.creator_user_id != null) { + const creator = db.getUserById(task.creator_user_id); + if (!creator) { + throw new TaskAuthorizationError('Scheduled task no longer authorized: creator account no longer exists.'); + } + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + const tier: LicenseTier = LicenseService.getInstance().getTier(); + if (!checkPermissionForSubject( + { username: creator.username, role: creator.role, userId: creator.id }, + tier, + scope.action, + scope.resourceType, + scope.resourceId, + scope.resourceNodeId, + )) { + throw new TaskAuthorizationError('Scheduled task no longer authorized: creator permission was revoked.'); + } + } + // Pre-check: ensure target node exists and is reachable if (task.node_id != null && task.action !== 'snapshot') { const node = db.getNode(task.node_id); @@ -426,6 +469,10 @@ export class SchedulerService { updates.enabled = 0; console.warn(`[SchedulerService] Task "${task.name}" (id=${task.id}) auto-disabled: cron expression invalid`); } + if (error instanceof TaskAuthorizationError) { + updates.enabled = 0; + console.warn(`[SchedulerService] Task "${task.name}" (id=${task.id}) auto-disabled: creator authorization revoked`); + } db.updateScheduledTask(task.id, updates); db.updateScheduledTaskRun(runId, { completed_at: Date.now(), diff --git a/backend/src/services/ServiceUpdateRecoveryService.ts b/backend/src/services/ServiceUpdateRecoveryService.ts index d51c0e63..c607e29a 100644 --- a/backend/src/services/ServiceUpdateRecoveryService.ts +++ b/backend/src/services/ServiceUpdateRecoveryService.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto'; import { DatabaseService, type ServiceUpdateRecoveryRow } from './DatabaseService'; import { getComposeCommandTimeoutMs } from './ComposeService'; +import { buildUnifiedHeldImagePredicate } from './recoveryHeldImages'; import { getErrorMessage } from '../utils/errors'; const SWEEP_INTERVAL_MS = 5 * 60_000; @@ -229,26 +230,13 @@ export class ServiceUpdateRecoveryService { /** * A predicate a pruner can call immediately before deleting each candidate * image. Re-reads the held set on every call (rather than snapshotting it - * once) so a snapshot that becomes eligible between plan and delete is - * still honored. When the held set cannot be read, returns true for every - * id so prune skips deletes (fail closed). + * once, unlike recoveryHeldImages.buildUnifiedHeldImagePredicate) so a + * generation that becomes eligible between plan and delete is still + * honored. When the held set cannot be read, returns true for every id so + * prune skips deletes (fail closed). */ public buildHeldImagePredicate(nodeId: number): (imageId: string) => boolean { - return (imageId: string) => { - const held = this.getHeldImageIds(nodeId); - if (held === null) return true; - if (held.has(imageId)) return true; - try { - // Dynamic import avoids a static cycle with StackUpdateRecoveryService. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { StackUpdateRecoveryService } = require('./StackUpdateRecoveryService') as typeof import('./StackUpdateRecoveryService'); - const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); - if (stackHeld === null) return true; - return stackHeld.has(imageId); - } catch { - return true; - } - }; + return (imageId: string) => buildUnifiedHeldImagePredicate(nodeId)(imageId); } private nextClaimExpiry(now: number): number { diff --git a/backend/src/services/StackUpdateRecoveryService.ts b/backend/src/services/StackUpdateRecoveryService.ts index 4a6fccd9..dc65ef82 100644 --- a/backend/src/services/StackUpdateRecoveryService.ts +++ b/backend/src/services/StackUpdateRecoveryService.ts @@ -70,9 +70,13 @@ function sanitizeServiceSlug(name: string): string { return name.replace(/[^a-zA-Z0-9._-]/g, '-').toLowerCase() || 'svc'; } +/** Same short form used in the opaque rollback tag, so the UI's "Generation" label matches the Docker tag. */ +export function shortGenerationId(generationId: string): string { + return generationId.replace(/-/g, '').slice(0, 12); +} + function opaqueRollbackTag(generationId: string, serviceName: string): string { - const short = generationId.replace(/-/g, '').slice(0, 12); - return `sencho-rb/${short}/${sanitizeServiceSlug(serviceName)}:hold`; + return `sencho-rb/${shortGenerationId(generationId)}/${sanitizeServiceSlug(serviceName)}:hold`; } function parseServicesJson(raw: string): StackRecoveryServiceCapture[] { @@ -284,6 +288,8 @@ export class StackUpdateRecoveryService { updated_at: now, created_by: createdBy, artifacts_retired: 0, + released_at: null, + released_by: null, }; DatabaseService.getInstance().insertStackUpdateRecoveryGeneration(row); return row; @@ -329,7 +335,7 @@ export class StackUpdateRecoveryService { throw new Error('Stack directory escapes compose base'); } - const short = generationId.replace(/-/g, '').slice(0, 12); + const short = shortGenerationId(generationId); if (!/^[a-f0-9]{12}$/i.test(short)) { throw new Error('Invalid recovery generation id'); } @@ -398,6 +404,74 @@ export class StackUpdateRecoveryService { return ok; } + /** + * Informational mirror of releaseStackUpdateRecoveryGeneration's WHERE + * clause, for the list endpoint to grey out a row it already knows is + * ineligible. Not authoritative: releaseGeneration revalidates for real. + */ + public isReleaseEligible(row: StackUpdateRecoveryGenerationRow): boolean { + if (row.released_at !== null || row.artifacts_retired !== 0) return false; + if (row.phase !== 'immediate_verified') return false; + if (!['active', 'restored_current', 'superseded'].includes(row.status)) return false; + if (row.health_gate_id) { + const gate = DatabaseService.getInstance().getHealthGateRun(row.node_id, row.stack_name, row.health_gate_id); + if (gate?.status === 'observing') return false; + } + return true; + } + + /** + * Operator-initiated release of rollback protection, current generation + * included. The DB transition (releaseStackUpdateRecoveryGeneration) + * atomically revalidates eligibility and clears is_current, which is what + * stops getCurrent()/isRestoredCurrentPinActive() from reporting a released + * row as the live rollback point. Docker tag + override cleanup reuses the + * same idempotent retireGenerationArtifacts() that abandon() already relies + * on, so a mid-cleanup Docker failure leaves artifacts_retired at 0 and is + * retried by the next reconcileIncomplete() sweep rather than silently + * "succeeding" in the UI. + */ + public async releaseGeneration( + id: string, + releasedBy: string | null, + ): Promise< + | { ok: true; row: StackUpdateRecoveryGenerationRow; artifactsCleaned: boolean } + | { ok: false; reason: 'not_found' | 'already_released' | 'not_eligible' } + > { + const before = this.get(id); + if (!before) return { ok: false, reason: 'not_found' }; + if (before.released_at !== null) return { ok: false, reason: 'already_released' }; + + const released = DatabaseService.getInstance().releaseStackUpdateRecoveryGeneration(id, releasedBy); + if (!released) return { ok: false, reason: 'not_eligible' }; + + const row = this.get(id); + if (!row) return { ok: false, reason: 'not_found' }; + const artifactsCleaned = await this.retireGenerationArtifacts(row); + + const wasCurrent = before.is_current === 1; + try { + DatabaseService.getInstance().addNotificationHistory(row.node_id, { + level: wasCurrent ? 'warning' : 'info', + category: 'rollback_generation_released', + message: wasCurrent + ? `${row.stack_name}: current rollback protection released. Automatic rollback is unavailable until the next successful full-stack update.` + : `${row.stack_name}: rollback protection released for generation ${shortGenerationId(row.id)}.`, + timestamp: Date.now(), + stack_name: row.stack_name, + actor_username: releasedBy, + }); + } catch (error) { + console.warn( + '[StackUpdateRecovery] Failed to record release activity for %s:', + sanitizeForLog(id), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + } + + return { ok: true, row, artifactsCleaned }; + } + public linkHealthGate(id: string, healthGateId: string): void { DatabaseService.getInstance().linkStackUpdateRecoveryHealthGate(id, healthGateId); } @@ -460,22 +534,6 @@ export class StackUpdateRecoveryService { } } - /** - * Unified held-image predicate: service-scoped + full-stack holds. - * Fail closed (skip prune) when either lookup fails. - */ - public buildUnifiedHeldImagePredicate(nodeId: number): (imageId: string) => boolean { - // Dynamic require avoids a static cycle with ServiceUpdateRecoveryService. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ServiceUpdateRecoveryService } = require('./ServiceUpdateRecoveryService') as typeof import('./ServiceUpdateRecoveryService'); - const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); - const stackHeld = this.getHeldImageIds(nodeId); - if (serviceHeld === null || stackHeld === null) { - return () => true; - } - return (imageId: string) => serviceHeld.has(imageId) || stackHeld.has(imageId); - } - /** * Post-handoff compensation: restore files + pinned up, then probe before * reporting restored_current / immediate_verified. @@ -655,7 +713,20 @@ export class StackUpdateRecoveryService { } } if (!tagsOk || !overrideOk) return false; - DatabaseService.getInstance().markStackUpdateRecoveryArtifactsRetired(row.id); + try { + DatabaseService.getInstance().markStackUpdateRecoveryArtifactsRetired(row.id); + } catch (error) { + // Tags/override are already gone at this point; a DB write failure here + // must not surface as "release/abandon failed" to the caller (the + // mutation it asked for already happened). Leave artifacts_retired at 0 + // so the next reconcileIncomplete() sweep retries the DB write alone. + console.warn( + '[StackUpdateRecovery] Failed to mark artifacts retired for %s: %s', + sanitizeForLog(row.id), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return false; + } return true; } @@ -680,16 +751,38 @@ export class StackUpdateRecoveryService { }); flagged += 1; } + let capped = 0; + const maxGenerations = db.getRecoveryMaxGenerations(); + if (maxGenerations > 0) { + // The current generation always counts as one of the cap, so the + // superseded budget is one less; it can never itself be evicted here. + const supersededBudget = Math.max(0, maxGenerations - 1); + const byStack = new Map(); + for (const row of db.listActiveSupersededGenerations()) { + const key = `${row.node_id}:${row.stack_name}`; + const list = byStack.get(key) ?? []; + list.push(row); + byStack.set(key, list); + } + for (const rows of byStack.values()) { + for (const row of rows.slice(supersededBudget)) { + if (row.artifact_expires_at === null || row.artifact_expires_at > now) { + db.updateStackUpdateRecoveryGeneration(row.id, { artifact_expires_at: now }); + capped += 1; + } + } + } + } let retired = 0; for (const row of db.listStackUpdateRecoveryGenerationsForArtifactRetirement(now)) { // Never retire an active/current or recovery_required hold target. if (row.is_current === 1 || row.status === 'recovery_required') continue; if (await this.retireGenerationArtifacts(row)) retired += 1; } - if (abandoned > 0 || flagged > 0 || retired > 0) { + if (abandoned > 0 || flagged > 0 || capped > 0 || retired > 0) { console.log( `[StackUpdateRecovery] Reconciled ${abandoned} stale candidate(s), ` - + `${flagged} stuck generation(s), retired ${retired} artifact set(s)`, + + `${flagged} stuck generation(s), ${capped} generation(s) over cap, retired ${retired} artifact set(s)`, ); } } catch (error) { diff --git a/backend/src/services/license-headers.ts b/backend/src/services/license-headers.ts index e70ead58..45b14fb7 100644 --- a/backend/src/services/license-headers.ts +++ b/backend/src/services/license-headers.ts @@ -27,6 +27,16 @@ export const PROXY_ROLE_HEADER = 'x-sencho-actor-role'; export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source'; export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor'; +/** + * Bound stack-scoped RBAC evidence for Proxy/Pilot hops. The hub strips any + * client-supplied values and, when scoped elevation is required, sets the + * exact stack name plus a comma-separated PermissionAction set conferred by + * that tuple's hub assignments. Remotes trust these only under node_proxy / + * pilot_tunnel machine auth. + */ +export const PROXY_SCOPED_STACK_NAME_HEADER = 'x-sencho-scoped-stack-name'; +export const PROXY_SCOPED_STACK_ACTIONS_HEADER = 'x-sencho-scoped-stack-actions'; + export const DEPLOY_SOURCES = [ 'manual', 'rollback', diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index 1cd05f1b..9e667404 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -23,6 +23,8 @@ import type { NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts, } from './types'; import { classifyMissingExternalNetworks, type MissingExternalNetwork } from './missingExternalNetworks'; +import { resolveManagedMeshAttachment } from './managedMeshAttachment'; +import type { ManagedNetworkAttachmentPredicate } from './normalize'; import { getErrorMessage } from '../../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; @@ -43,6 +45,7 @@ export function assembleStackNetworkFacts( model: EffectiveModel | null, renderError: string | null, snapshot: DependencySnapshot | null, + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate, ): StackNetworkFacts { const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable'; @@ -82,7 +85,9 @@ export function assembleStackNetworkFacts( extraHosts: s.extraHosts, })); - const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT; + const drift = snapshot + ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName, managedNetworkAttachment) + : EMPTY_DRIFT; const missingExternalNetworks: MissingExternalNetwork[] = snapshot ? classifyMissingExternalNetworks( model, @@ -147,5 +152,8 @@ export async function buildStackNetworkFacts( } } - return assembleStackNetworkFacts(stackName, model, renderError, snapshot); + const managedNetworkAttachment = snapshot && model + ? await resolveManagedMeshAttachment(nodeId, stackName) + : undefined; + return assembleStackNetworkFacts(stackName, model, renderError, snapshot, managedNetworkAttachment); } diff --git a/backend/src/services/network/managedMeshAttachment.ts b/backend/src/services/network/managedMeshAttachment.ts new file mode 100644 index 00000000..93c8ce4c --- /dev/null +++ b/backend/src/services/network/managedMeshAttachment.ts @@ -0,0 +1,55 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { DatabaseService } from '../DatabaseService'; +import { SENCHO_MESH_NETWORK } from '../MeshComposeOverride'; +import SelfIdentityService from '../SelfIdentityService'; +import { getErrorMessage } from '../../utils/errors'; +import { sanitizeForLog } from '../../utils/safeLog'; +import { isPathWithinBase, isValidStackName } from '../../utils/validation'; +import type { ManagedNetworkAttachmentPredicate } from './normalize'; + +async function hasPilotMeshOverride(nodeId: number, stackName: string): Promise { + if (process.env.SENCHO_MODE !== 'pilot' || !isValidStackName(stackName)) return false; + + const dataDir = process.env.DATA_DIR || '/app/data'; + const overrideDir = path.resolve(dataDir, 'mesh', 'overrides', String(nodeId)); + const overridePath = path.resolve(overrideDir, `${path.basename(stackName)}.override.yml`); + if (!isPathWithinBase(overridePath, overrideDir)) return false; + + try { + await fs.access(overridePath); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false; + console.warn( + '[NetworkDrift] Could not verify Pilot Mesh override for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return false; + } +} + +export async function resolveManagedMeshAttachment( + nodeId: number, + stackName: string, +): Promise { + let stackManaged = false; + try { + stackManaged = DatabaseService.getInstance().isMeshStackEnabled(nodeId, stackName); + } catch (error) { + console.warn( + '[NetworkDrift] Could not verify Mesh opt-in state for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + } + if (!stackManaged) stackManaged = await hasPilotMeshOverride(nodeId, stackName); + const selfIdentity = SelfIdentityService.getInstance(); + + return (container, networkName) => networkName === SENCHO_MESH_NETWORK && ( + stackManaged + || selfIdentity.isOwnContainer(container.id) + || selfIdentity.isOwnContainer(container.name) + ); +} diff --git a/backend/src/services/network/networkingSummary.ts b/backend/src/services/network/networkingSummary.ts index aeb24696..80874865 100644 --- a/backend/src/services/network/networkingSummary.ts +++ b/backend/src/services/network/networkingSummary.ts @@ -10,6 +10,7 @@ import { FileSystemService } from '../FileSystemService'; import { DatabaseService } from '../DatabaseService'; import { parseComposeDependencies } from '../../helpers/composeDependencyParse'; import { assembleStackDrift } from '../DriftDetectionService'; +import { resolveManagedMeshAttachment } from './managedMeshAttachment'; import { isHostNetwork, isLoopback } from './normalize'; import { getErrorMessage } from '../../utils/errors'; import { sanitizeForLog } from '../../utils/safeLog'; @@ -86,7 +87,14 @@ export async function computeNodeNetworkingSummary(nodeId: number): Promise c.stack === stack); - const report = assembleStackDrift({ stack, declared, containers, networks: snapshot.networks }); + const managedNetworkAttachment = await resolveManagedMeshAttachment(nodeId, stack); + const report = assembleStackDrift({ + stack, + declared, + containers, + networks: snapshot.networks, + managedNetworkAttachment, + }); if (report.findings.some(f => f.kind === 'network-undeclared' || f.kind === 'network-missing')) networkDrift.push(stack); } } diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts index c2694cb1..523c4f18 100644 --- a/backend/src/services/network/normalize.ts +++ b/backend/src/services/network/normalize.ts @@ -8,8 +8,9 @@ */ import type { EffectiveModel } from '../preflight/effectiveModel'; import type { DeclaredCompose } from '../../helpers/composeDependencyParse'; -import type { DependencySnapshot } from '../DockerController'; +import type { DependencyContainer, DependencySnapshot } from '../DockerController'; import type { NetworkDriftFacts } from './types'; +import { SENCHO_MESH_NETWORK } from '../MeshComposeOverride'; /** Container states that count as "deployed" for drift, matching DriftDetectionService. */ const RUNNING_STATES = new Set(['running', 'restarting']); @@ -62,6 +63,11 @@ export interface NormalizedNetworkModel { services: { name: string; networkKeys: string[]; networkMode?: string }[]; } +export type ManagedNetworkAttachmentPredicate = ( + container: DependencyContainer, + networkName: string, +) => boolean; + /** Rendered model: resource names are already resolved by `docker compose config`. */ export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel { const networks: NormalizedNetworkModel['networks'] = {}; @@ -97,6 +103,7 @@ export function compareStackNetworks( declared: NormalizedNetworkModel, snapshot: DependencySnapshot, stackName: string, + isManagedAttachment: ManagedNetworkAttachmentPredicate = () => false, ): NetworkDriftFacts { const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = []; const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = []; @@ -118,6 +125,7 @@ export function compareStackNetworks( const net = networkByName.get(attached.name); if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue; if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; } + if (attached.name === SENCHO_MESH_NETWORK && isManagedAttachment(c, attached.name)) continue; if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) { runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name }); } else { diff --git a/backend/src/services/prunePlan.ts b/backend/src/services/prunePlan.ts index a3612884..585d04e5 100644 --- a/backend/src/services/prunePlan.ts +++ b/backend/src/services/prunePlan.ts @@ -3,13 +3,47 @@ import { createHash } from 'crypto'; export type PruneTarget = 'images' | 'volumes' | 'networks' | 'containers'; export type PruneScope = 'managed' | 'all'; -export interface PrunePlanItem { - target: PruneTarget; +interface PrunePlanItemBase { id: string; name: string; sizeBytes?: number; + managed: boolean; + reason: string; + stackName?: string; } +export type PrunePlanItem = + | (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never }) + | (PrunePlanItemBase & { + target: 'images'; + image: { + references: string[]; + digest?: string; + createdAt?: number; + }; + volume?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'volumes'; + volume: { + driver?: string; + ownershipLabels?: Record; + }; + image?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'networks'; + network: { + driver?: string; + scope?: string; + ownershipLabels?: Record; + }; + image?: never; + volume?: never; + }); + export interface PrunePlan { scope: PruneScope; /** Ordered execution sequence (dependency-safe when multi-target). */ @@ -34,6 +68,36 @@ export const PRUNE_EXECUTION_ORDER: readonly PruneTarget[] = ['volumes', 'contai export const PRUNEABLE_CONTAINER_STATES = new Set(['created', 'exited', 'dead']); +const COMPOSE_OWNERSHIP_LABEL_KEYS = new Set([ + 'com.docker.compose.project', + 'com.docker.compose.project.working_dir', + 'com.docker.compose.project.config_files', + 'com.docker.compose.volume', + 'com.docker.compose.network', + 'com.docker.compose.service', +]); + +/** + * Disclosure allowlist for ownership evidence returned to API clients. + * Do not broaden it without reviewing Docker label values for sensitive data. + */ +export function projectPruneOwnershipLabels(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const projected = Object.entries(value).filter( + (entry): entry is [string, string] => COMPOSE_OWNERSHIP_LABEL_KEYS.has(entry[0]) + && typeof entry[1] === 'string' && entry[1].length > 0, + ); + return projected.length > 0 ? Object.fromEntries(projected) : undefined; +} + +export function hasOnlyPruneOwnershipLabels(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return Object.entries(value).every( + ([key, label]) => COMPOSE_OWNERSHIP_LABEL_KEYS.has(key) && typeof label === 'string' && label.length > 0, + ); +} + export function isPruneTarget(value: unknown): value is PruneTarget { return typeof value === 'string' && (PRUNE_TARGETS as readonly string[]).includes(value); } diff --git a/backend/src/services/recoveryHeldImages.ts b/backend/src/services/recoveryHeldImages.ts new file mode 100644 index 00000000..408fd992 --- /dev/null +++ b/backend/src/services/recoveryHeldImages.ts @@ -0,0 +1,18 @@ +import { ServiceUpdateRecoveryService } from './ServiceUpdateRecoveryService'; +import { StackUpdateRecoveryService } from './StackUpdateRecoveryService'; + +/** + * Unified held-image predicate: service-scoped + full-stack rollback holds. + * Lives in its own module (rather than on either service) so both can be + * imported here statically without a cycle -- ServiceUpdateRecoveryService + * and StackUpdateRecoveryService intentionally do not import each other. + * Fails closed (protects every image) when either lookup fails. + */ +export function buildUnifiedHeldImagePredicate(nodeId: number): (imageId: string) => boolean { + const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); + const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); + if (serviceHeld === null || stackHeld === null) { + return () => true; + } + return (imageId: string) => serviceHeld.has(imageId) || stackHeld.has(imageId); +} diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts index 295c94a2..b71ada6f 100644 --- a/backend/src/services/scheduledActionRegistry.ts +++ b/backend/src/services/scheduledActionRegistry.ts @@ -1,8 +1,9 @@ /** * Single source of truth for scheduled-operation action metadata that the - * backend needs for validation. The route layer derives its allow-list and - * action/target compatibility checks from this table, so adding a new action - * means adding one entry here (plus its execution logic in SchedulerService). + * backend needs for validation and authorization. The route layer derives its + * allow-list, action/target compatibility checks, and permission enforcement + * from this table, so adding a new action means adding one entry here + * (plus its execution logic in SchedulerService). * * The frontend keeps its own richer registry (labels, categories, tones) in * `frontend/src/lib/scheduledActions.ts`; the two cannot share a module because @@ -10,6 +11,9 @@ * each side. */ +import type { PermissionAction } from '../middleware/permissions'; +import type { ResourceType } from './DatabaseService'; + export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const; export type TargetType = typeof VALID_TARGET_TYPES[number]; @@ -19,6 +23,19 @@ export interface BackendScheduledActionDefinition { readonly targetTypes: readonly TargetType[]; readonly requiresNode: boolean; readonly nodeScope?: 'local'; + /** Permission required to create, edit, enable, run, or delete a schedule for this action. */ + readonly permission: PermissionAction; +} + +/** + * Permission scope resolved from a task's action, target, and node identity. + * When `resourceType` is omitted the check is unscoped (global role matrix only). + */ +export interface ScheduledActionPermissionScope { + readonly action: PermissionAction; + readonly resourceType?: ResourceType; + readonly resourceId?: string; + readonly resourceNodeId?: number | null; } /** @@ -26,15 +43,15 @@ export interface BackendScheduledActionDefinition { * in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ..."). */ export const BACKEND_SCHEDULED_ACTIONS = [ - { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true }, - { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false }, - { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, - { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true }, - { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, - { id: 'auto_backup', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true }, - { id: 'auto_down', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true }, + { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false, permission: 'node:manage' as const }, + { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' as const, permission: 'system:settings' as const }, + { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' as const, permission: 'node:manage' as const }, + { id: 'auto_backup',targetTypes: ['stack'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_down', targetTypes: ['stack'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, ] as const satisfies readonly BackendScheduledActionDefinition[]; export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id']; @@ -83,3 +100,58 @@ export function validateActionTarget(action: BackendScheduledAction, targetType: export function getScheduledActionDefinition(action: BackendScheduledAction): BackendScheduledActionDefinition | undefined { return ACTION_BY_ID.get(action); } + +/** + * Resolve the permission scope for a scheduled action + target combination. + * This is the single source of truth consumed by the route layer and the + * scheduler revalidation path. Scope resolution is per-action, not per + * target-type bucket. + */ +export function resolveTaskPermissionScope( + action: BackendScheduledAction, + targetType: TargetType, + targetId: string | null, + nodeId: number | null, + _selectorType?: string | null, +): ScheduledActionPermissionScope { + const def = ACTION_BY_ID.get(action); + const basePermission = def?.permission ?? 'stack:deploy'; + + switch (action) { + case 'restart': + case 'auto_stop': + case 'auto_start': { + if (targetType === 'container') { + return { action: 'node:manage', resourceType: 'node', resourceId: nodeId != null ? String(nodeId) : undefined, resourceNodeId: nodeId }; + } + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + } + case 'auto_down': + case 'auto_backup': + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + + case 'update': { + if (targetType === 'stack') { + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + } + if (nodeId != null) { + return { action: 'node:manage', resourceType: 'node', resourceId: String(nodeId), resourceNodeId: nodeId }; + } + return { action: 'node:manage' }; + } + + case 'scan': + return { action: basePermission, resourceType: 'node', resourceId: nodeId != null ? String(nodeId) : undefined, resourceNodeId: nodeId }; + + case 'prune': + return { action: basePermission }; + + case 'snapshot': + return { action: basePermission }; + + default: { + const exhaustive: never = action; + return { action: exhaustive as never }; + } + } +} diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 6007ef5d..2292e2a0 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -1,5 +1,6 @@ import type { UserRole, ApiTokenScope, ApiToken } from '../services/DatabaseService'; import type { LicenseTier } from '../services/license-types'; +import type { PermissionAction } from '../middleware/permissions'; // Extend Express Request type for user and node context. // This file is imported for its side effects only (ambient declaration). @@ -27,6 +28,28 @@ declare global { deployContext?: import('../services/network/missingExternalNetworksError').DeployInvocationContext; /** Verified JWT scope for machine credentials (`node_proxy` / `pilot_tunnel`). */ machineAuthScope?: 'node_proxy' | 'pilot_tunnel'; + /** + * Hub-bound stack-scoped action evidence, trusted only when set under + * machine auth (`node_proxy` / `pilot_tunnel`). Never set from browser sessions. + */ + scopedStackEvidence?: { stackName: string; actions: ReadonlySet }; + /** + * Hub-side pending evidence to attach on the outbound proxy hop when + * the caller's global role alone would not grant the primary action. + */ + proxyScopedStackEvidence?: { stackName: string; actions: readonly PermissionAction[] }; + /** + * Named-stack classification from the hub gate. Stashed because + * http-proxy pathRewrite mutates req.url before proxyRes, so + * re-classifying req.path there would miss DELETE cleanup. + */ + proxyNamedStackRoute?: { stackName: string; action: PermissionAction }; + /** + * Elevated role for a single proxied request. Set by the settings + * pre-authorization gate when the hub-side scoped permission check + * passes for a non-admin user. Resets to undefined after the hop. + */ + proxyElevatedRole?: 'node-admin'; } } } diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 4ecdb21b..2b39e7da 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -40,6 +40,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /system/images/delete': 'Deleted images', 'POST /system/volumes/delete': 'Deleted volumes', 'POST /system/networks/delete': 'Deleted networks', + 'POST /system/rollback/generations/*/release': 'Released rollback protection', 'POST /system/networks': 'Created network', 'POST /system/console-token': 'Generated console token', 'POST /system/reapply-compose': 'Triggered compose reapply', diff --git a/backend/src/websocket/generic.ts b/backend/src/websocket/generic.ts index bf93c84b..73466311 100644 --- a/backend/src/websocket/generic.ts +++ b/backend/src/websocket/generic.ts @@ -7,6 +7,23 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { isDebugEnabled } from '../utils/debug'; import { rejectUpgrade as reject } from './reject'; +/** + * Scoped JWTs allowed on the generic `/ws` upgrade after the upgrade handler's + * earlier gates. Deny-by-default: mfa_pending, pilot_enroll, node_proxy (also + * rejected via isProxyToken), and any unknown future scope must not skip the + * session admin check. + * + * - api_token: restricted scopes blocked upstream for /ws + * - console_session: path + one-time jti consumed upstream + * - pilot_tunnel: machine credential for agent loopback; no further path gate + * (must stay allowed so hub-forwarded /ws still works on pilot agents) + */ +const GENERIC_WS_ALLOWED_SCOPES = new Set([ + 'api_token', + 'console_session', + 'pilot_tunnel', +]); + /** * Header the deploy/update/down routes carry the per-deploy correlation id on, * mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`. @@ -53,18 +70,24 @@ export function handleGenericWs( if (isProxyToken) return reject(socket, 403, 'Forbidden'); - // Admin enforcement: container exec requires admin role. - // console_session tokens are already admin-gated at creation time and - // path/jti-gated in upgradeHandler. API tokens reaching this point have - // full-admin scope (read-only / deploy-only are blocked by the upgrade - // handler's scope gate). - if (!decoded.scope) { + // Admin enforcement for unscoped session JWTs: DB user must be admin. + // Scoped JWTs are deny-by-default via GENERIC_WS_ALLOWED_SCOPES (each + // allowed scope is reduced earlier in the upgrade pipeline, except + // pilot_tunnel which is the loopback machine credential itself). + if (decoded.scope) { + if (!GENERIC_WS_ALLOWED_SCOPES.has(decoded.scope)) { + console.warn('[Exec] Rejected scoped token on /ws:', decoded.scope); + return reject(socket, 403, 'Forbidden'); + } + } else { const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined; if (!execUser) { console.warn('[Exec] User account not found:', decoded.username); return reject(socket, 401, 'Unauthorized'); } - if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (execUser.token_version !== (decoded.tv ?? 1)) { console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username); return reject(socket, 401, 'Unauthorized'); } diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 201ba34a..b8f5ae3c 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken'; import { DatabaseService, type UserRole } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import { NodeRegistry } from '../services/NodeRegistry'; -import { COOKIE_NAME } from '../helpers/constants'; +import { COOKIE_NAME, MFA_PENDING_SCOPE } from '../helpers/constants'; import { handlePilotTunnel } from './pilotTunnel'; import { handleMeshProxyTunnel } from './meshProxyTunnel'; import { handleNotificationsWs } from './notifications'; @@ -201,7 +201,9 @@ export function attachUpgrade( if (!decoded.scope && decoded.username) { const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username); if (!dbUser) return reject(socket, 401, 'Unauthorized'); - if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (dbUser.token_version !== (decoded.tv ?? 1)) { console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username); return reject(socket, 401, 'Unauthorized'); } @@ -212,6 +214,22 @@ export function attachUpgrade( }; } + // Partial-auth (mfa_pending) and enroll-only (pilot_enroll) JWTs must not + // continue past shared cookie/Bearer auth. HTTP already rejects mfa_pending + // outside MFA routes; pilot_enroll is only valid on /api/pilot/tunnel + // (handled above). Reject here for every remaining WS path so handlers that + // do not re-check scope (logs, notifications, host console, etc.) cannot + // accept them. handleGenericWs used to treat any set scope as "already + // gated" and skip admin; that path is now deny-by-default too. + // pilot_tunnel is intentionally allowed: agent loopback injects it on every + // forwarded WS, including /ws container exec. + if ( + decoded.scope === MFA_PENDING_SCOPE + || decoded.scope === 'pilot_enroll' + ) { + return reject(socket, 403, 'Forbidden'); + } + const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; diff --git a/docker-compose.yml b/docker-compose.yml index a07d3e26..75095b8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: # available memory. Usually already visible in the container; only needed # if your runtime does not expose /proc/spl/kstat/zfs/arcstats. # - /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro + # VM ballooning: mount /proc/meminfo if your runtime does not expose it. + # - /proc/meminfo:/host/proc/meminfo:ro environment: # ENVIRONMENT VARIABLES FOR INSIDE THE CONTAINER @@ -50,6 +52,9 @@ services: # file, if it is not at a standard location. Leave empty to auto-detect # /host/proc/spl/kstat/zfs/arcstats then /proc/spl/kstat/zfs/arcstats. - SENCHO_ZFS_ARCSTATS_PATH=${SENCHO_ZFS_ARCSTATS_PATH:-} + # Optional: mount /proc/meminfo for VM memory ballooning awareness + # - /proc/meminfo:/host/proc/meminfo:ro + - SENCHO_PROC_MEMINFO_PATH=${SENCHO_PROC_MEMINFO_PATH:-} # ⚠️ GLOBAL ENVIRONMENT VARIABLES ⚠️ # If your compose files rely on host-level shell variables (like $PUID, $TZ) diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index e1e4c633..b3efc312 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -1,10 +1,10 @@ --- title: Audit Log -description: Track every mutating action on your Sencho instance with a searchable, exportable trail for team accountability. +description: Track every mutating action on your Sencho instance with a searchable trail for team accountability; export and extended retention on Admiral. --- - Community keeps a rolling 14-day recent-activity audit API window, but has no **Audit** tab in navigation to browse it. The Audit navigation view, CSV and JSON export, anomaly detection, and configurable retention beyond the recent window all require a Sencho **Admiral** license. + Audit requires the `system:audit` permission (Admin, or Auditor on Admiral). Community shows the rolling 14-day recent-activity window. Admiral adds the 24h signal rail, CSV/JSON export, anomaly detection, and configurable retention beyond that window. @@ -66,21 +66,25 @@ Expanding a row in the Table view reveals additional detail: ## Viewing the audit log -The **Audit** view in navigation is an Admiral governance surface: the tab itself only appears on an Admiral license, and Community accounts have no navigation entry point to Audit at all, though the underlying recent-activity API stays reachable directly. On Admiral, the tab is further limited to users whose role grants the `system:audit` permission, which by default means **Admin** or **Auditor**. +The **Audit** tab appears for any signed-in user whose role grants the `system:audit` permission (by default **Admin**, and **Auditor** when that role is available). Community shows the last 14 days of activity. Admiral shows the full retained history; retention defaults to 90 days and is configurable up to 365. -Navigate to the **Audit** tab in the top navigation when it is available for your role and plan. The feed then shows your full retained history rather than any fixed lookback window; retention defaults to 90 days and is configurable up to 365. +Navigate to the **Audit** tab in the top navigation when it is available for your role. The page has two views, toggled from the segmented control in the card header: **Stream** (default) and **Table**. The card subtitle reports the total number of entries that match the current filters. ### Stream view -Stream gives you an at-a-glance read on activity. A signal rail at the top summarizes the last 24 hours across four tiles, and the feed below groups entries by day with severity dots, relative times, and inline anomaly callouts. +Stream gives you an at-a-glance read on activity. The feed groups entries by day with severity dots and relative times. + + + The 24h signal rail and inline anomaly callouts require a Sencho **Admiral** license. + Audit Log Stream view with the four-tile signal rail (Events 53 +218% vs 7d avg, Actors 2, Failure rate 0%, Peak hour 21:00) above a day-banded chronological feed of admin POST and DELETE entries, including a first seen anomaly flag on one entry. -**Signal rail tiles:** +**Signal rail tiles (Admiral):** | Tile | What it shows | |------|---------------| @@ -105,7 +109,7 @@ Table keeps the full-featured detail grid for power users: exact timestamps, met Audit Log Table view with the second row expanded to reveal Request Path /api/webhooks/4, IP Address ::ffff:203.0.113.10, Node ID 1, and Entry ID #2472 in a four-cell detail strip. -Both views share the **Refresh** button and the **Export** dropdown in the card header, and both paginate at 50 entries per page with chevron controls at the bottom of the feed or table. +Both views share the **Refresh** button in the card header and paginate at 50 entries per page with chevron controls at the bottom of the feed or table. On Admiral, both views also share the **Export** dropdown. ## Anomaly detection @@ -190,7 +194,7 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest - The Audit tab is an Admiral governance view: it only appears on an Admiral license, and on Community it is not shown at all, regardless of role. On Admiral, the tab is further limited to users whose role grants the `system:audit` permission, by default **Admin** or **Auditor**. If you are on Admiral but signed in as a Deployer or Viewer, ask an admin to assign you the Auditor role from **Settings · Users**. The recent-activity API stays reachable on Community even without a navigation entry point to it. + The Audit tab appears only for users with the `system:audit` permission (by default **Admin**, or **Auditor** when that role is available). If you are signed in as a Viewer, Deployer, or Node Admin, ask an admin to grant you a role that includes audit access. On Community, Admin is the role that can open Audit; the Auditor role requires Admiral to assign. Filters live in **Table view only**. Toggle the segmented control in the card header from **Stream** to **Table** and the search box, method dropdown, and From / To date pickers will appear above the grid. Switching back to Stream clears the filter strip but does not remember the last filter. @@ -202,6 +206,6 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest Each export is capped at 10,000 entries. If your filter selects more than that, narrow the date range using the **From** and **To** pickers and download in chunks. The cap protects the API from generating very large CSVs in a single response; for full archives, schedule periodic exports from your own tooling. - Cleanup runs automatically against the **Audit log** retention value in **Settings · Operations · Data Retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries. + On Community, the list shows only the last 14 days of activity, so older rows are not returned even when they still exist in the database. On Admiral, cleanup also runs against the **Audit log** retention value in **Settings · Operations · Data Retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries. diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index f1cad0bb..8da7bdcf 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -153,7 +153,7 @@ When the node is reachable, the tab compares the declared effective model agains | **Declared but unused** | A network is declared in the Compose file but no currently running service is connected to it. Often seen when a service is stopped or removed without `docker compose down`. | | **Missing from runtime** | A network is declared but does not exist in Docker. The stack may not have been deployed, or the network was deleted externally. | -System-managed networks (`bridge`, `host`, `none`) and Docker's implicit default bridge are excluded from all drift findings. +System-managed networks (`bridge`, `host`, `none`) and Docker's implicit default bridge are excluded from all drift findings. Attachments to `sencho_mesh` are also excluded when Sencho verifies that the container is its own instance or that the stack is opted into Sencho Mesh. A manual attachment from an opted-out stack remains visible as drift. When the runtime matches the Compose file, the section shows a green **runtime matches compose** card. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 5e4ddbb0..5f687215 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -48,7 +48,9 @@ The gauge bars (and the corresponding numeric values) pick up amber at 80% and r While the dashboard is loading the CPU tile reads `--` and the caption shows `collecting metrics…`; bars and sparklines render once the first sample arrives. - **ZFS hosts:** the memory tile and host RAM alerts are ZFS ARC-aware. Reclaimable ARC cache is treated as available memory rather than used, so a large ARC does not inflate the gauge or trigger false low-memory alerts. See [ZFS ARC-aware host memory](/getting-started/configuration#zfs-arc-aware-host-memory) for how to expose ARC stats to a Docker install. + **ZFS hosts:** the memory tile and host RAM alerts are ZFS ARC-aware. Reclaimable ARC cache is treated as available memory rather than used, so a large ARC does not inflate the gauge or trigger false low-memory alerts. The memory tile shows the reclaimable amount as a context line when ARC stats are readable and the reclaimable amount is nonzero. See [ZFS ARC-aware host memory](/getting-started/configuration#zfs-arc-aware-host-memory) for how to expose ARC stats to a Docker install. + + **Virtual machines:** the memory tile shows hypervisor-ballooned memory (TrueNAS/KVM, Proxmox) as informational context. Unlike ARC, ballooned pages are host-reclaimed and the guest cannot get them back on demand, so the gauge, health verdict, and alerts continue to use the standard working-set percentage. See [VM memory ballooning](/getting-started/configuration#vm-memory-ballooning) for details. ## Stack health diff --git a/docs/features/fleet-actions.mdx b/docs/features/fleet-actions.mdx index 43b70633..947ec9cf 100644 --- a/docs/features/fleet-actions.mdx +++ b/docs/features/fleet-actions.mdx @@ -5,7 +5,7 @@ description: "Bulk operations across the fleet from one tab: stop stacks by labe The **Actions** tab on the Fleet view groups bulk operations that touch more than a single stack on a single node. Each action lives in its own card, orchestrates from the control instance, and reports per-node and per-stack results inline so you never have to click through a modal to learn what happened. -Three cards ship today: **Prune Docker resources fleet-wide**, **Bulk label assign**, and **Stop by label**. Every card follows the same discipline before it touches anything: a live, debounced readout resolves the exact blast radius as you type or select, and the destructive or state-changing button stays disabled until that readout resolves to a concrete node and stack list. You confirm against real names, not a label string or a byte estimate. +Three cards ship today: **Prune Docker resources fleet-wide**, **Bulk label assign**, and **Stop by label**. Every card resolves its blast radius before it touches anything. Fleet Prune also requires an itemized dry run, so its destructive button stays disabled until every reachable node has returned the exact resources and fingerprint that will authorize execution. Fleet view with the Actions tab selected. A two-column grid: Prune fleet-wide (top left, Maintenance chip), Bulk label assign (top right, Transformative chip), and Stop by label (bottom, Destructive chip). Each card shows a toolbar row with an action-class chip, a live blast-radius readout, a Dry run or Reset button, and the primary action. @@ -26,17 +26,17 @@ Fleet Actions is the home for operations that span the fleet but don't fit anywh | Trigger a Sencho self-update across remote nodes | **Check Updates** button on the Fleet masthead | | Steer where new blueprint deployments land | [Fleet Federation](/features/fleet-federation) | | Replicate scan policies and CVE suppressions to remotes | [Fleet Sync](/features/fleet-sync) | -| Reclaim disk space on a single node with an itemized, undo-safe preview | **Resources → Prune** on that node (a different endpoint and flow from the fleet-wide card; see [Prune Docker resources fleet-wide](#prune-docker-resources-fleet-wide)) | +| Reclaim disk space on a single node with an itemized preview | **Resources → Prune** on that node (the single-node version of the fingerprint-bound flow described in [Prune Docker resources fleet-wide](#prune-docker-resources-fleet-wide)) | ## How every card works: preview, confirm, execute All three cards share one execution model, and understanding it explains every result panel, timeout, and edge case below. 1. **You describe the target.** A stack label name (Stop by label), a label plus checked stacks (Bulk label assign), or a set of resource targets and a scope (Prune). -2. **A live, debounced readout resolves the real blast radius.** Typing a label name or checking a target fires a non-destructive preview call (`POST /api/fleet/labels/match-preview` or `POST /api/fleet/prune/estimate`) roughly 350-500ms after you stop changing input. The readout in the card's toolbar shows `awaiting target` until something is selected, `resolving…` while the call is in flight, and then a concrete count (`7 stacks · 1 nodes`, `~ 6.09 GB reclaimable`). The primary button stays disabled until this resolves to a non-zero, non-loading result. -3. **You confirm against the resolved list, not the input.** Clicking the primary button opens a confirmation dialog that lists the actual nodes and stacks (Stop, Bulk assign) or restates the scope (Prune). For Stop by label specifically, the confirmation carries the exact node/stack list the preview resolved, and the real stop only touches stacks that are still in that list *and* still carry the label at execution time: a stack that gains the label after you opened the confirmation is never touched, and a node that reconnects after the preview does not get pulled into the stop. -4. **The control instance fans the confirmed action out to every node in parallel.** The local node runs in-process; each remote node is called over the standard Bearer-token proxy path. A node that cannot be reached, returns a non-2xx response, or returns a shape Sencho does not recognize is reported as a failure for that node only; the fan-out to every other node still completes. -5. **Results render per node, grouped and expandable**, in a `Per-node breakdown` section below the form. Stop and Prune also expose a **Dry run** button that walks the identical code path and locks without performing the destructive step, so you can rehearse the exact fan-out before committing. +2. **A live, debounced readout estimates the blast radius.** Typing a label name or checking a target fires a non-destructive preview call (`POST /api/fleet/labels/match-preview` or `POST /api/fleet/prune/estimate`) roughly 350-500ms after you stop changing input. The toolbar shows a stack count or approximate reclaimable bytes while you refine the action. +3. **You review the resolved list, not only the input.** Stop and Bulk assign resolve concrete stacks. Prune requires **Dry run**, which lists every candidate image, volume, and network for each reachable node. Changing the targets, scope, node roster, or node reachability clears that authorization. +4. **The control instance verifies before mutation.** Fleet Prune rebuilds every reviewed plan and checks the complete node roster before any node starts deleting. A stale plan or changed reachability rejects the whole preflight. A later race can still produce an explicit partial result because each node revalidates again immediately before deletion. +5. **Results render per node, grouped and expandable.** Prune retains the reviewed item identity and, when the node returns item outcomes, marks each candidate Removed, Skipped, or Failed after execution. Unreachable nodes remain visible as excluded rather than appearing as successful empty plans. ## The three cards @@ -46,7 +46,7 @@ All three cards share one execution model, and understanding it explains every r | Bulk label assign | Transformative | `POST /api/fleet/labels/bulk-assign` | (computed client-side from `/api/labels` and `/api/fleet/node/:id/stacks` per node) | Only the nodes whose stacks you select | | Prune Docker resources fleet-wide | Maintenance | `POST /api/fleet/labels/fleet-prune` | `POST /api/fleet/prune/estimate` | Every configured node | -Every card is admin-only and available on every license tier. Stop and Prune iterate every node in **Settings → Nodes**; Bulk label assign iterates only the nodes whose stacks you actually checked. Each card runs the authoritative work on the executing node (the local node in process, every remote over the node proxy), so an unreachable node shows up in the results with a transport error rather than blocking the rest of the batch. +Every card is admin-only and available on every license tier. Stop and Prune iterate every node in **Settings → Nodes**; Bulk label assign iterates only the nodes whose stacks you actually checked. Each card runs the authoritative work on the executing node (the local node in process, every remote over the node proxy). Stop and Bulk label assign report unreachable nodes without blocking work elsewhere. Fleet Prune excludes unreachable nodes during review, then rejects execution if that reviewed reachability changes. ## Stop by label @@ -120,7 +120,7 @@ A single Apply accepts up to **1,000 stack assignments** summed across every tar ## Prune Docker resources fleet-wide -Reclaim disk space on every reachable node by deleting unused images, volumes, and networks. The control instance fans out to each node and reports reclaimed bytes per node and per target. +Reclaim disk space on every reachable node by deleting unused images, volumes, and networks. A dry run lists the exact candidates on each node, and the real prune is authorized by the fingerprint of each reviewed plan. Prune fleet-wide card with Images and Volumes targets checked, scope set to All unused, and a live per-node estimate: Local 153.85 MB, Opsix 3.06 GB, Pitt-Moba 1.37 GB, SLX-Mars 1.51 GB, totaling roughly 6.09 GB reclaimable in the toolbar readout. @@ -135,33 +135,48 @@ The **Targets** checkboxes are independent and at least one must be ticked: **Im Scope is a segmented control with two options: - **Managed only** (default). Sencho looks up the stacks it knows about on the node, then prunes only resources owned by those stacks. Active containers and resources placed by other tools are untouched. -- **All unused**. Sencho runs the equivalent of `docker system prune` for each selected target. Any image, volume, or network not currently in use is deleted, including resources from workloads Sencho does not manage. The confirmation title flips to **Prune ALL unused resources across the fleet?**. +- **All unused**. Sencho applies the target-specific Docker prune eligibility rules to each selected resource type. Any selected image, volume, or network not currently in use is deleted, including resources from workloads Sencho does not manage. The confirmation title flips to **Prune ALL unused resources across the fleet?**. -### Live estimate and behaviour +### Review the itemized dry run -- Changing a target or the scope re-triggers a debounced call to `POST /api/fleet/prune/estimate`, which walks the same Docker enumeration the destructive path uses so the estimate matches what pruning would actually reclaim. A completed real prune that succeeds on at least one target re-triggers the same estimate so the toolbar total and per-node list reflect post-prune Docker state. **Prune fleet** stays disabled until the estimate resolves: you cannot confirm a destructive fleet-wide prune with no context on what it will reclaim. -- Each remote node receives one `POST /api/system/prune/system` call per selected target, with a 120-second timeout. If a transport error fires for one target, the remaining targets on that node are short-circuited with the same error rather than retried, so a dead node doesn't absorb the full multi-target timeout budget. -- Local nodes serialize against a per-node lock (`bulk-prune:`). A second fleet prune launched against the same local node while the first is still in flight returns *A prune is already running on this node* for each target. -- Reclaimed bytes are reported by the Docker daemon and are approximate. Per-node rows in the results panel sum the per-target reclaim; the per-target children show how much each individual prune actually freed. +Click **Dry run** after choosing targets and scope. Each reachable node returns one multi-target plan grouped into Images, Volumes, and Networks. Candidate rows show the stable ID, display name, reclaimable size when Docker provides one, why the resource is unused, managed or unmanaged ownership, and the associated stack when it can be resolved. Images also show available digest and creation details; volumes show their driver; networks show driver and scope. Only Compose ownership labels are shown, not arbitrary Docker labels. - -Fleet Actions' prune card calls the same node-local prune route as the single-node **Resources → Prune** page, but without that page's itemized plan-and-fingerprint flow. It never returns the `PRUNE_PLAN_STALE` (409) error you can see on Resources; each fleet prune call targets exactly one resource type per node and executes immediately. If you want an itemized, reviewable plan before pruning a specific node, use that node's own Resources page instead. - +An untagged image is identified as `:` alongside its short ID. Under **All unused**, unmanaged candidates carry an **UNMANAGED** badge. Nodes that cannot be reached are shown as **excluded** and never as zero-candidate success. A reachable plan with zero items is still valid. + +The node total is the sum of the sizes shown in that node's candidate rows. Image totals are estimates because Docker layers may be shared; the actual bytes reclaimed can differ after Docker accounts for layers still referenced by other images. + +### Fingerprint-bound execution + +**Prune fleet** remains disabled until the current targets, scope, and node roster have a valid reviewed plan for every reachable node. Execution sends one fingerprint per reviewed reachable node. Before deletion begins, the control instance rebuilds all plans, confirms that reviewed-unreachable nodes are still unreachable, and compares the complete configured-node roster. + +If a node was added, removed, connected, disconnected, or changed candidates after the dry run, no node starts pruning. Run **Dry run** again to review the new state. Once fleet-wide preflight passes, each node revalidates immediately before deletion. A race at that point can produce a partial result, which is reported rather than hidden. + +Local plan enumeration has an eight-second Docker-daemon timeout. Real local execution holds the per-node prune lock from preflight through mutation. Proxy remotes and Pilot nodes use one multi-target plan request and one fingerprint-bound execute request through their normal fleet transport. Mesh-managed stacks follow the transport of the node that hosts them. + +### Read post-prune outcomes + +The result keeps the reviewed name and metadata for every candidate and adds one outcome when the node returns itemized outcomes: + +- **Removed** means the reviewed resource was deleted. +- **Skipped** means it became active, was already absent, or became protected before deletion. +- **Failed** includes the resource-level error returned by the node. + +If a remote reports only its reclaimed total, the node shows that total without inventing per-item statuses. A completed mutation refreshes the live estimate. ## Prerequisites | Requirement | Why it matters | |---|---| -| **Configured remote nodes in Settings → Nodes** | Stop and Prune iterate the configured node list; Bulk label assign iterates whichever nodes you select stacks on. A node missing its `api_url` or `api_token`, or one that cannot be reached, is reported once per node as unreachable and never blocks the reachable nodes. | +| **Configured remote nodes in Settings → Nodes** | Stop and Prune iterate the configured node list; Bulk label assign iterates whichever nodes you select stacks on. Stop and Bulk label assign report an unreachable node without blocking other nodes. Prune excludes it from the reviewed plan and rejects execution if its reachability later changes. | | **Admin role** | Every card requires the admin role to apply. | | **Labels you intend to target** | Stop by label and its autocomplete depend on stack labels existing on at least one node; Bulk label assign depends on at least one stack label existing anywhere in the fleet. See [Stack Labels](/features/stack-labels) for the authoring flow. | ## Behaviour and lifecycle -- **Always returns 200.** Every destructive endpoint is structured so the HTTP status reflects the request shape, not the operational outcome. Partial failure is encoded in per-row fields, not in the status code. +- **Operational outcomes are itemized.** Normal fan-out results use per-node and per-item fields. Fleet Prune uses `409` when the reviewed roster, reachability, or fingerprint changes before mutation, because that rejection guarantees no node has started deleting. - **No retry, no scheduling, no undo.** Fleet Actions runs synchronously and is operator-driven; there is no background scheduler and no roll-back. For recurrence, use [Scheduled Operations](/features/scheduled-operations). -- **Offline remotes still receive the request.** A node that is down at the moment of the action returns a transport-error row but does not block the fan-out across the rest of the fleet. -- **Concurrent runs serialize per node.** All three cards take per-node locks before touching Docker or the label tables, so kicking off a second prune, a second fleet stop, or a fleet stop overlapping a per-label stop on the same node yields a calm "already running on this node" row rather than silent double-execution. +- **Offline remotes stay visible.** Dry run marks an unreachable node as excluded. If its reachability changes before Prune executes, the reviewed authorization is rejected and must be rebuilt. +- **Concurrent mutations serialize per node.** A real local Fleet Prune holds its prune lock through preflight and execution. Dry-run enumeration stays outside the destructive lock. ## Limitations and non-goals @@ -174,7 +189,7 @@ Fleet Actions is intentionally narrow. The following are deliberately out of sco - **No undo.** A stopped stack stays stopped until you start it again; a pruned image is gone until it is pulled or rebuilt. - **Approximate reclaim numbers.** The bytes the Prune card reports come from the Docker daemon and are best-effort, not authoritative. - **Confirmed-target stops need a current remote.** A real (non-dry-run) stop bound to specific stacks refuses to run against a remote that doesn't advertise support for confirmed-target binding; upgrade the remote to retry. -- **Timeouts scale with the fan-out, not with any one node.** 60 seconds per remote on fleet-stop and bulk-assign, 120 seconds per remote per prune target. A remote with many stacks or a very slow filesystem may produce a timeout row before the underlying work fully completes; the action itself usually still finishes on the remote, the control instance just stopped waiting. +- **Timeouts scale with the fan-out, not with any one node.** Remote fleet-stop and bulk-assign calls allow 60 seconds. Fleet Prune allows 120 seconds for each node's combined multi-target plan or execute request. A remote with many stacks or a very slow filesystem may produce a timeout row before the underlying work fully completes; during execution, check that remote's logs before retrying because the control instance may have stopped waiting after mutation began. ## Practical workflows @@ -184,7 +199,7 @@ Tag the stacks you want to bring down with a dedicated label (for example `eveni ### Rehearse a destructive action before committing -For Stop and Prune, click **Dry run** first. It walks the identical lock, fan-out, and per-node logic as the real action but skips the destructive leaf call, so the results panel shows exactly what would happen (including which nodes are unreachable right now) before you commit to it. +For Stop and Prune, click **Dry run** first. Fleet Prune shows the exact Docker candidates, including which nodes are excluded, and stores the fingerprints needed to unlock the destructive action. ### Propagate a label across the fleet @@ -192,7 +207,7 @@ Define a label like `Media` on one node (for example the local node) under **Set ### Free disk before a heavy deploy -Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed only** scope, and check the live per-node estimate before confirming. It gives a quick read on which hosts have accumulated the most stale layers. Switch to **All unused** if you want the prune to reach workloads that Sencho does not manage. +Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed only** scope. Check the live estimate, run the itemized dry run, and review each image before confirming. Switch to **All unused** if you want the plan to include workloads that Sencho does not manage. ## Common questions @@ -201,13 +216,13 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed Bulk mode operates on a hand-picked set of stacks **on one node** and supports start, stop, restart, and update. Fleet Actions operates **across every configured node** by selector (a label, or a checked cross-node set), and only Stop is a lifecycle action here (Bulk mode covers restart and update, Fleet Actions does not). - No. Dry run walks the same code path, including acquiring the per-node lock, but every card skips the destructive Docker or label-table call and returns what it would have done instead. It is safe to run repeatedly. + No. Fleet Prune enumerates candidates without calling Docker remove methods or invalidating caches. It is safe to run repeatedly. - Every destructive or state-changing button stays disabled until the live preview or estimate resolves to a non-zero, non-loading result. This is deliberate: you always confirm against a concrete, current blast radius rather than an unresolved input. + Fleet Prune requires a successful **Dry run** for the current targets, scope, and node roster. Run it again after any of those inputs or a node's reachability changes. - - Resources → Prune builds an itemized plan with a fingerprint and re-validates it at execute time, which is where that error comes from. Fleet Actions' prune card calls the simpler legacy single-target path on each node instead, so there is no plan to go stale. + + The reviewed node roster, reachability, or candidate fingerprint changed before deletion began. Sencho rejected the entire fleet preflight so you can review the current candidates before trying again. @@ -239,7 +254,7 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed Fleet Actions runs admin-only. Confirm the active user has the admin role under **Settings → Users**; operator and viewer roles see every card but cannot apply them. - The node is in **Settings → Nodes** but its `api_url` or `api_token` is missing, expired, or unreachable. Stop by label reports it once as a single ` (unreachable)` row; Prune reports it per target. Open **Settings → Nodes** on the control instance and test the connection for the remote; fix the credential or the reachability, then re-run the action. + The node is in **Settings → Nodes** but its `api_url` or `api_token` is missing, expired, or unreachable. Stop by label reports one ` (unreachable)` row. Fleet Prune keeps the node visible as excluded from the reviewed plan; its target rows carry the same reachability error. Open **Settings → Nodes** on the control instance and test the connection for the remote; fix the credential or reachability, then run a new dry run. diff --git a/docs/features/fleet-secrets.mdx b/docs/features/fleet-secrets.mdx index 849a2a0a..6af9ce80 100644 --- a/docs/features/fleet-secrets.mdx +++ b/docs/features/fleet-secrets.mdx @@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus -Fleet Secrets is a limited-availability surface. When it is present on an instance, managing it requires an admin user role. +Fleet Secrets is available on every Sencho installation. Managing bundles requires an admin user role. ## What Fleet Secrets covers (and what it doesn't) @@ -38,14 +38,14 @@ A **push** is a separate action. It reads the bundle's current version, walks ev | Requirement | Why it matters | |---|---| -| Admin role on the control instance | Bundle CRUD and push require an administrator when the surface is present; authored-by rows are written into the audit log | +| Admin role on the control instance | Bundle CRUD and push require an administrator; authored-by rows are written into the audit log | | At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks | | The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up | | The control instance can reach the remote node's API URL | Each remote write is an HTTP call from the control instance to the remote's `/api/stacks/.../env`; an unreachable remote is reported as a per-node failure, not a transport error for the whole push | ## Create a bundle -1. Open **Fleet → Secrets** (when that tab is available on the instance). +1. Open **Fleet → Secrets** on the Fleet view. 2. Click **New bundle**. 3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric. 4. Optionally add a description; the description is a free-text field and is shown in the bundle list. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 61856b69..8f4df16d 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -37,7 +37,7 @@ A single rail summarises the state of every registered node so you can read the ### Tabs -The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Routing and Secrets are limited-availability fleet surfaces and are not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) divides the per-node monitoring tabs from the fleet-wide orchestration tabs. +The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Secrets appears for admins. Routing is a limited-availability fleet surface and is not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) divides the per-node monitoring tabs from the fleet-wide orchestration tabs. | Tab | Tier | What it does | |-----|------|--------------| @@ -50,7 +50,7 @@ The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Lab | **Routing** | Limited availability | Cross-node service routing via Sencho Mesh when that surface is enabled on the instance. See [Sencho Mesh](/features/sencho-mesh). | | **Federation** | Community | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | | **Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). | -| **Secrets** | Limited availability | Encrypted env-var bundles you push to labeled nodes when that surface is enabled on the instance. See [Fleet Secrets](/features/fleet-secrets). | +| **Secrets** | Community (admin role) | Encrypted env-var bundles you push to labeled nodes across the fleet. See [Fleet Secrets](/features/fleet-secrets). | ### Action buttons @@ -105,18 +105,19 @@ Every node renders as a card. The local node is pinned at the top of the grid wi Offline nodes render dimmed, with no stats grid, no usage bars, and no update affordance. -### Node actions menu (admin) +### Node actions menu -Every card carries a three-dot **Node actions** kebab in the top-right corner. The menu surfaces the same lifecycle actions you would find in **Settings · Infrastructure · Nodes**: +Every card carries a three-dot **Node actions** kebab in the top-right corner: | Action | Notes | |--------|-------| +| **Node details** | Opens an info sheet with the node's connectivity, live capacity, Compose workload, version and update compatibility, and governance info (labels, cordon reason and date, default-node status, Compose directory, registration date). Available to anyone who can see the card; the label picker inside the sheet stays editable only for whoever holds `node:manage` on that node. | | **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. | | **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. | | **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it. Existing deployments keep running. Requires the `node:manage` permission (admin, or node-admin when scoped to that node). | | **Mute** submenu | Mute node notifications, mute update notifications, mute monitor alerts for this node, or open the full mute-rule manager. Shown to whoever can manage mute rules for the node. See [Alerts & Notifications](/features/alerts-notifications). | -Edit and delete remain admin-only. Users without `node:manage`, without mute permission, and without edit/delete affordances see no kebab on the card. +Edit, delete, cordon, and mute stay gated on `node:manage` or mute permission as before. Every card shows the kebab with at least **Node details**, even for a viewer with no manage permissions. ### Topology view diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index 4f14d137..d4b725af 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -26,7 +26,7 @@ The palette groups results into three sections. | Group | What it contains | What happens when you pick one | |-------|------------------|--------------------------------| -| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page | +| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page | | **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page | | **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor | diff --git a/docs/features/health-gated-updates.mdx b/docs/features/health-gated-updates.mdx index be9c27fa..cc995c04 100644 --- a/docs/features/health-gated-updates.mdx +++ b/docs/features/health-gated-updates.mdx @@ -101,6 +101,21 @@ The Stack Dossier carries a **Rollback readiness** section that answers one ques Rollback readiness section in the Stack Dossier showing the overall state chip and the six rows: Previous compose file, Previous env file, Previous image tag, Last successful deploy, Healthchecks, and the Application data row marked not covered +## Automatic rollback images + +Before a full-stack update runs, Sencho captures the running image of every service as an opaque, uniquely named copy so it can automatically restore the prior state if the update or its health gate fails. These copies exist in Docker as `sencho-rb//:hold`, but they are Sencho-internal recovery state, not part of your image inventory: they are kept out of **Resources → Images** and listed instead in **Resources → Rollback**. If a captured image still carries its original registry tag alongside the hold tag (a compose file pinned to an immutable tag, for example), it stays visible in the Images tab too, badged **Rollback protected** instead of the usual unused label, since it is held on purpose rather than left behind by accident. + +Each capture is one **rollback generation**. The generation currently backing a stack's live deployment is retained for as long as it is current; once a newer update supersedes it, it is retained for a configurable window before Sencho cleans it up automatically. A stack updated repeatedly in a short span can have more than one superseded generation in that window at once. + +**Resources → Rollback** lists every generation on the node: the stack it belongs to, a short generation id, whether it is the current protection or a superseded one awaiting cleanup, and roughly when it clears. An admin can release a generation's protection early from that list, including the current one, which immediately frees its image but means Sencho cannot automatically roll that stack back until its next successful full-stack update; the confirmation dialog says so before you proceed. A generation that is mid-recovery or still being observed by a health gate cannot be released until that finishes. + +Because these images are deliberately held, deleting one directly (by id, including through the API) is refused. Release the generation from **Resources → Rollback** instead, or leave it to clear on its own. + +Two settings under **Settings > Infrastructure > Stacks > Deploy Guardrails** control the automatic cleanup: + +- **Superseded rollback retention** sets how many days a superseded generation is kept before its image is cleaned up. The current generation is unaffected by this window; it stays protected until it is superseded or manually released. Default 7 days. +- **Maximum retained rollback generations per stack** caps how many generations a stack keeps at once, current generation included, so the oldest superseded generations beyond the cap are cleaned up ahead of the retention window. 0 (default) leaves the count unlimited and relies on the retention window alone. + ## Classified failures When a deploy or update fails, Sencho classifies the failure from the compose output and shows the cause with a suggested next step in the recovery panel: an image pull failure, a missing environment variable, a host port conflict, a missing bind-mount path, a permission problem, a crashed container, a failed healthcheck, an unavailable dependency, an unreachable node or Docker daemon, or an invalid compose file. The classification also lands in **Copy details**, so a bug report carries the cause, not just the raw output. @@ -126,4 +141,10 @@ When a deploy or update fails, Sencho classifies the failure from the compose ou The sidebar's per-stack **Update** action runs the same path as the editor toolbar, so it shows the same readiness dialog and deploy progress. One click on **Update now** proceeds. On nodes that do not advertise the capability, updates run directly without the dialog. + + Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** on purpose (they are recovery state, not image inventory) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge. + + + That failure is intentional: the image is protected by an active or recently superseded rollback generation. Open **Resources → Rollback**, find the matching generation, and use **Release** there if you are sure you do not need it. Releasing the current generation means Sencho cannot automatically roll that stack back until its next successful full-stack update. + diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index bb42b92d..874e8809 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -152,7 +152,7 @@ Configure threshold-based alerts per stack and route notifications to Discord, S ### Audit log -Track mutating actions across your Sencho instance with a searchable trail: who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Community keeps a rolling 14-day recent-activity audit API window. The Audit navigation view, plus CSV/JSON export, anomaly detection, and configurable retention, is Admiral governance. [Learn more →](/features/audit-log) +Track mutating actions across your Sencho instance with a searchable trail: who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Users with `system:audit` can open Audit from navigation. Community shows a rolling 14-day window; Admiral adds the 24h signal rail, export, anomaly detection, and configurable retention. [Learn more →](/features/audit-log) ## Fleet management @@ -194,7 +194,7 @@ When several Sencho instances run as a fleet, the control instance is the source ### Fleet Secrets -Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Limited-availability surface when present; admin role required to manage. [Learn more →](/features/fleet-secrets) +Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Available on every installation; admin role required to manage. [Learn more →](/features/fleet-secrets) ### Fleet-wide backups diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index ae047376..ed88e09c 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -20,8 +20,8 @@ Sencho ships with five built-in roles that map to the permissions most operators |------|----------------|------| | **Admin** | Full operator access: deploy, edit compose, manage users, configure nodes, view audit log, every system setting | Community | | **Viewer** | Read-only access to stacks, logs, stats, file contents, and node listings | Community | -| **Deployer** | Deploy, restart, stop, and start stacks. Cannot edit compose files, create or delete stacks, or view nodes | Admiral | -| **Node Admin** | Full stack and node management across the fleet. No access to system settings, users, or license | Admiral | +| **Deployer** | Deploy, restart, stop, and start stacks, and check individual stacks for image updates. Cannot edit compose files, create or delete stacks, view nodes, or manage alert and auto-heal rules | Admiral | +| **Node Admin** | Full stack and node management across the fleet, including node-scoped operational Settings. No access to users, licensing, credentials, or system-only Settings | Admiral | | **Auditor** | Read-only access to stacks, nodes, and the audit log. No write access anywhere | Admiral | ### Permission matrix @@ -31,8 +31,8 @@ Each row is one of the permission keys the backend checks. The matrix below is t | Permission | Admin | Node Admin | Deployer | Auditor | Viewer | |------------|:-----:|:----------:|:--------:|:-------:|:------:| | View stacks, logs, stats (`stack:read`) | Yes | Yes | Yes | Yes | Yes | -| Deploy, restart, stop, start, take down (`stack:deploy`) | Yes | Yes | Yes | No | No | -| Edit compose and `.env` files (`stack:edit`) | Yes | Yes | No | No | No | +| Deploy, restart, stop, start, take down, check a stack for image updates (`stack:deploy`) | Yes | Yes | Yes | No | No | +| Edit compose and `.env` files, manage alert rules and auto-heal policies (`stack:edit`) | Yes | Yes | No | No | No | | Create stacks (`stack:create`) | Yes | Yes | No | No | No | | Delete stacks (`stack:delete`) | Yes | Yes | No | No | No | | View nodes (`node:read`) | Yes | Yes | No | Yes | Yes | @@ -115,29 +115,30 @@ Click **Update user** to save. Changing the role takes effect on the next API re Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server. +**Stack scopes are node-specific.** The same stack name on two different nodes is two independent grants. Assigning a stack scope means choosing the node first, then picking a stack that exists on that node. Display form conceptually: stack name @ node name (for example `frontend @ prod`). + +**Node scopes are node-wide.** The resource is the node itself. There is no separate node qualifier on a node assignment row. Granting Node Admin (or Deployer, or Admin) on `staging-server` authorizes that role's stack and node operations for every stack on that node, without a separate per-stack grant. + The box appears below the user form whenever you are editing a user on Admiral. - - Edit User form for the viewer account with a Scoped Permissions box below. The box contains an existing assignment row (a Deployer badge with the text on Stack: bazarr and a destructive trash icon on the right) and a three-column add-scope row underneath (Role combobox set to Deployer, Resource Type combobox set to Stack, Resource combobox showing Select..., and a disabled Add button). - - -The add-scope form has three controls and an **Add** button: +The add-scope form has these controls and an **Add** button: | Control | Options | |---------|---------| | **Role** | Deployer, Node Admin, or Admin. The scoped role picker is narrower than the global role picker. Viewer and Auditor cannot be scoped (they are floor-only roles). | | **Resource Type** | `Stack` or `Node`. | -| **Resource** | The picker shows stacks (when type is `Stack`) or remote nodes (when type is `Node`) the gateway knows about. Resource names match what you see in the sidebar. | +| **Node** | Shown when Resource Type is `Stack`. Choose the node that hosts the stack before the stack picker unlocks. | +| **Stack** or **Node** | When type is `Stack`, the picker lists stacks on the selected node. When type is `Node`, the picker lists nodes the gateway knows about. Names match what you see in the sidebar. | -Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. +Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and for stacks the node name after an `@`, plus a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. -Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` can deploy `frontend` but stays read-only on every other resource. Scopes never reduce the global role. +Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` at a given node can deploy that stack on that node but stays read-only on every other resource. Scopes never reduce the global role. ### Example scenarios -- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack can deploy, restart, and stop only that stack. They cannot edit compose or delete it. +- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack at node `prod` can deploy, restart, and stop only that stack on `prod`. The same name on another node needs its own grant. They cannot edit compose or delete it. - A **Deployer** with a scoped **Node Admin** assignment on node `staging-server` can manage every stack and node operation on that server, while keeping plain Deployer rights on the rest of the fleet. -- A **Node Admin** without any scoped assignments has full stack and node management across every node, but still cannot reach system settings, the user list, or the audit log. +- A **Node Admin** without any scoped assignments has full stack and node management across every node, including node-scoped operational Settings, but still cannot reach users, licensing, credentials, or system-only Settings. ## Two-factor reset @@ -224,6 +225,9 @@ Entries include the acting user, IP address, HTTP method and path, response stat ## Troubleshooting + + Sencho could not verify your current permissions. Existing pages stay open, but changes remain disabled until verification succeeds. Select **Retry** in the notification bar. If the notice returns, check that the Sencho instance is reachable and sign in again if your session has expired. + The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead. @@ -237,7 +241,7 @@ Entries include the acting user, IP address, HTTP method and path, response stat Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits. - Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. + Three causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Three,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node. The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset. diff --git a/docs/features/stack-drift.mdx b/docs/features/stack-drift.mdx index 81cf7404..246e90e1 100644 --- a/docs/features/stack-drift.mdx +++ b/docs/features/stack-drift.mdx @@ -83,7 +83,7 @@ A Compose port range such as `8000-8002:8000-8002` is compared conservatively. B ### Network findings and the Networking tab -The **network-undeclared** and **network-missing** findings reuse the same comparison the stack's [Networking](/features/compose-networking) tab uses, so the two surfaces never disagree about a network attachment. The difference is history: this tab persists findings in the drift ledger over time, while the Networking tab always shows the current live state with no history. A stack with an open network finding also counts toward the **Drift** chip in the Fleet Overview's Networking filter group, so a network-attachment mismatch is visible both per-stack here and across the fleet there. +The **network-undeclared** and **network-missing** findings reuse the same comparison the stack's [Networking](/features/compose-networking) tab uses, so the two surfaces never disagree about a network attachment. Sencho-verified `sencho_mesh` attachments for its own container and Mesh-opted-in stacks are excluded, while a manual attachment from an opted-out stack remains actionable. The difference is history: this tab persists findings in the drift ledger over time, while the Networking tab always shows the current live state with no history. A stack with an open network finding also counts toward the **Drift** chip in the Fleet Overview's Networking filter group, so a network-attachment mismatch is visible both per-stack here and across the fleet there. ## When drift is recorded @@ -93,6 +93,8 @@ The **network-undeclared** and **network-missing** findings reuse the same compa **After every deploy or update**, Sencho automatically records a new baseline hash and runs a full reconciliation. You do not need to click re-check after deploying; the ledger is updated as part of the deploy pipeline. +An open ledger entry for an attachment that is no longer reported clears during the next re-check or post-deploy reconciliation. Opening the tab refreshes the live report but does not change persisted history. + The Drift tab showing both the Findings section and the Drift history section with an open finding marked just now - Limited-availability encrypted, versioned env-var bundles pushed to labeled nodes' stacks when the surface is present. Sealed with the same data key as MFA and registry credentials. + Encrypted, versioned env-var bundles pushed to labeled nodes' stacks. Sealed with the same data key as MFA and registry credentials. @@ -212,7 +212,7 @@ For user management and scoped permissions, see [RBAC & User Management](/featur Every POST, PUT, DELETE, and PATCH request to the API is recorded in the audit log with the acting username, IP address, HTTP method, response status, and an auto-generated summary. GET requests are excluded to keep the log focused on mutations. -The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. The recent-activity log, scoped to the last 14 days, is available on every tier. With Admiral, results can be exported as CSV or JSON (up to 10,000 entries per export), entries carry anomaly annotations, and retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Operations · Data Retention**. +The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. Users with the `system:audit` permission can open **Audit** from navigation. Community shows the last 14 days of activity; with Admiral, results can be exported as CSV or JSON (up to 10,000 entries per export), entries carry anomaly annotations, and retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Operations · Data Retention**. The **Auditor** role provides read-only access to the audit log without any other administrative privileges, making it suitable for compliance reviewers who should not have access to system settings. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 1a41b03d..cb5685a6 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -207,7 +207,7 @@ Create and manage user accounts with role-based access. The masthead publishes a | **Admin** | Community | Full access to all features | | **Viewer** | Community | Read-only access to stacks and nodes | | **Deployer** | Admiral | Can view stacks and trigger deployments | -| **Node Admin** | Admiral | Full stack and node management, no system settings | +| **Node Admin** | Admiral | Full stack and node management, including node-scoped operational Settings | | **Auditor** | Admiral | Read-only plus audit log access | See [RBAC & User Management](/features/rbac) for details on what each role can access. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86c1ddcc..52c7ba38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,21 +11,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-context-menu": "^2.3.4", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-hover-card": "^1.1.20", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -38,18 +38,18 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.25.0", - "monaco-editor": "^0.55.1", - "motion": "^12.42.2", + "lucide-react": "^1.27.0", + "monaco-editor": "^0.56.0", + "motion": "^12.43.0", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.4", + "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", @@ -62,15 +62,15 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.1.1", + "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "jsdom": "^29.1.1", + "globals": "^17.8.0", + "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", @@ -90,56 +90,58 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -404,9 +406,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -424,9 +426,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -448,9 +450,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -464,8 +466,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -499,9 +501,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -656,9 +658,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -727,9 +729,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -937,24 +939,24 @@ } }, "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", - "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz", - "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -972,20 +974,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz", - "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==", + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1003,16 +1005,16 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz", - "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1030,12 +1032,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz", - "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1053,12 +1055,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz", - "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1076,17 +1078,17 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz", - "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1104,18 +1106,18 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz", - "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==", + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1133,19 +1135,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz", - "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1163,15 +1165,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", - "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -1189,9 +1191,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1204,9 +1206,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1219,16 +1221,16 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz", - "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1246,24 +1248,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", - "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1283,9 +1285,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1298,16 +1300,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", - "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -1325,18 +1327,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz", - "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1354,9 +1356,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1369,14 +1371,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", - "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1394,17 +1396,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz", - "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1422,20 +1424,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz", - "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1453,12 +1455,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1471,12 +1473,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz", - "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1494,27 +1496,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz", - "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1534,21 +1536,21 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz", - "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1566,25 +1568,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz", - "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==", + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -1602,23 +1604,23 @@ } }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz", - "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1636,19 +1638,19 @@ } }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz", - "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1666,24 +1668,24 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz", - "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1703,21 +1705,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz", - "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1735,13 +1737,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", - "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1759,12 +1761,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", - "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1782,12 +1784,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -1805,13 +1807,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz", - "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1829,20 +1831,20 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz", - "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1860,22 +1862,22 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz", - "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1893,20 +1895,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz", - "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1924,31 +1926,31 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz", - "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8", + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1968,12 +1970,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz", - "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1991,22 +1993,22 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz", - "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2024,12 +2026,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -2042,17 +2044,17 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz", - "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2070,19 +2072,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz", - "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2100,23 +2102,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz", - "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==", + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -2134,14 +2136,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz", - "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2159,18 +2161,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz", - "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2188,18 +2190,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz", - "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-toggle-group": "1.1.16" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", @@ -2217,24 +2219,24 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz", - "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -2252,9 +2254,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2267,14 +2269,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", - "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2287,12 +2289,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2305,12 +2307,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", - "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2323,9 +2325,9 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", - "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2338,9 +2340,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2353,9 +2355,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2368,12 +2370,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2386,12 +2388,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2404,12 +2406,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz", - "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2427,9 +2429,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@reduxjs/toolkit": { @@ -3370,9 +3372,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", "dependencies": { @@ -4004,9 +4006,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4634,9 +4636,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4724,9 +4726,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -4736,7 +4738,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -4760,7 +4762,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5047,12 +5049,12 @@ "license": "ISC" }, "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -5153,9 +5155,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -5482,39 +5484,39 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", + "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -5523,15 +5525,30 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5901,9 +5918,9 @@ } }, "node_modules/lucide-react": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", - "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6814,13 +6831,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6830,22 +6847,22 @@ } }, "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", "license": "MIT", "dependencies": { - "dompurify": "3.2.7", + "dompurify": "3.4.8", "marked": "14.0.0" } }, "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.2", + "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6866,9 +6883,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -7090,9 +7107,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -7193,66 +7210,66 @@ } }, "node_modules/radix-ui": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.4.tgz", - "integrity": "sha512-Kpgb9sx08toOydBK42//0N3MqIPlqjHcY39CYuGG8+7DrF6+NTfAnc3o+f1kvoKzG6cI56ri7Z45XEBQqG1QqQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-accessible-icon": "1.1.12", - "@radix-ui/react-accordion": "1.2.17", - "@radix-ui/react-alert-dialog": "1.1.20", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-aspect-ratio": "1.1.12", - "@radix-ui/react-avatar": "1.2.3", - "@radix-ui/react-checkbox": "1.3.8", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-context-menu": "2.3.4", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-dropdown-menu": "2.1.21", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-form": "0.1.13", - "@radix-ui/react-hover-card": "1.1.20", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-menubar": "1.1.21", - "@radix-ui/react-navigation-menu": "1.2.19", - "@radix-ui/react-one-time-password-field": "0.1.13", - "@radix-ui/react-password-toggle-field": "0.1.8", - "@radix-ui/react-popover": "1.1.20", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-progress": "1.1.13", - "@radix-ui/react-radio-group": "1.4.4", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-scroll-area": "1.2.15", - "@radix-ui/react-select": "2.3.4", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-slider": "1.4.4", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.4", - "@radix-ui/react-tabs": "1.1.18", - "@radix-ui/react-toast": "1.2.20", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-toggle-group": "1.1.16", - "@radix-ui/react-toolbar": "1.1.16", - "@radix-ui/react-tooltip": "1.2.13", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -7457,9 +7474,9 @@ } }, "node_modules/recharts": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", - "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "license": "MIT", "workspaces": [ "www" @@ -7974,29 +7991,29 @@ } }, "node_modules/tldts": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", - "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.28" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", - "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8072,9 +8089,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8110,13 +8127,13 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/frontend/package.json b/frontend/package.json index c41c755d..1a7bbb2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,21 +18,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-context-menu": "^2.3.4", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-hover-card": "^1.1.20", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -45,18 +45,18 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.25.0", - "monaco-editor": "^0.55.1", - "motion": "^12.42.2", + "lucide-react": "^1.27.0", + "monaco-editor": "^0.56.0", + "motion": "^12.43.0", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.4", + "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", @@ -74,15 +74,15 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.1.1", + "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "jsdom": "^29.1.1", + "globals": "^17.8.0", + "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aea66650..461b5779 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { MfaChallenge } from './components/MfaChallenge'; import { DeployFeedbackProvider } from './context/DeployFeedbackContext'; import { DeployFeedbackPortal } from './components/DeployFeedbackPortal'; import { ToastContainer } from './components/ui/toast'; +import { Button } from './components/ui/button'; +import { AlertCircle, RefreshCw } from 'lucide-react'; /** Gates framer-motion animations on the "Reduced motion" appearance setting. * 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS @@ -27,7 +29,7 @@ function MotionProvider({ children }: { children: ReactNode }) { } function AppContent() { - const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); + const { appStatus, isAuthenticated, needsSetup, completeSetup, permissionsStatus, retryPermissions } = useAuth(); if (appStatus === 'loading') { return ( @@ -53,6 +55,18 @@ function AppContent() { + {permissionsStatus === 'error' && ( +
+
+ + Permission controls are unavailable. Changes remain disabled until access is verified. +
+ +
+ )} {/* Portal lives inside LicenseProvider so the editor surface and its portalled overlays can read license state via useLicense(). diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 9a8f9756..0182cf5b 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -431,7 +431,7 @@ export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewPro label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`, icon: isDeploying ? Loader2 : Rocket, onClick: handleDeploy, - disabled: isDeploying || !stackName.trim() || !can('stack:create'), + disabled: isDeploying || !stackName.trim() || !can('stack:create') || !can('stack:deploy'), } : undefined} footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined} size="md" diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index e02a50fd..dfb5beb6 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -20,6 +20,7 @@ import { isVerificationOnlyPreview, } from '@/lib/updatePreviewActionability'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, Kicker } from '@/components/mobile/mobile-ui'; import { ImageSourceMenu } from './ImageSourceMenu'; @@ -469,6 +470,7 @@ function ReadinessHero({ nodeCount, refreshing, onRefresh, + canRefresh, unresolvedChecks = false, detectionDisabled = false, }: { @@ -477,6 +479,7 @@ function ReadinessHero({ nodeCount: number; refreshing: boolean; onRefresh: () => void; + canRefresh: boolean; unresolvedChecks?: boolean; detectionDisabled?: boolean; }) { @@ -523,21 +526,23 @@ function ReadinessHero({ )} - + {canRefresh && ( + + )} @@ -791,6 +796,8 @@ interface AutoUpdateReadinessProps { function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) { const isMobile = useIsMobile(); const { runWithLog } = useDeployFeedback(); + const { can } = useAuth(); + const canRefreshFleet = can('node:manage'); const { nodes, nodeMeta, refreshNodeMeta } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); @@ -1296,10 +1303,12 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) />
- + {canRefreshFleet && ( + + )}
@@ -1351,6 +1360,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) nodeCount={groups.length} refreshing={refreshing} onRefresh={handleRefresh} + canRefresh={canRefreshFleet} unresolvedChecks={checkFailures.length > 0} detectionDisabled={cadence?.enabled === false} /> diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 53a1a1d1..8c73458b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -84,7 +84,7 @@ const NetworkingView = lazy(() => import('./networking/NetworkingView').then(m = const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView }))); export default function EditorLayout() { - const { isAdmin, can, permissions } = useAuth(); + const { isAdmin, can, permissions, permissionsStatus } = useAuth(); const { status: trivy } = useTrivyStatus(); const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback(); @@ -296,7 +296,7 @@ export default function EditorLayout() { hasServiceScopedUpdate: hasCapability('service-scoped-update'), canEditStack: (stackNameOrFilename) => { const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, ''); - return can('stack:edit', 'stack', stackName); + return can('stack:edit', 'stack', stackName, activeNode?.id); }, canOfferVolumeRemoval, onDeletedOpenStack: () => onDeletedOpenStackRef.current(), @@ -837,12 +837,14 @@ export default function EditorLayout() { } }, [permissions, can]); - const createStackSlot = can('stack:create') ? ( + const canCreateStack = can('stack:create'); + const createStackSlot = (canCreateStack || permissionsStatus === 'loading') ? ( <> + + {affordance.replicaCopy} + + + ); + }; + + const renderServiceLifecycleMenu = (serviceName: string, isServiceActive: boolean) => ( + + + + + + {isServiceActive ? ( + <> + serviceAction('restart', serviceName)}> + Restart service + + serviceAction('stop', serviceName)}> + Stop service + + + ) : ( + serviceAction('start', serviceName)}> + Start service + + )} + + + ); + + const renderContainerCard = ( + container: ContainerInfo, + hideServiceMenu: boolean, + updateAffordance?: ServiceUpdateAffordance, + ) => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; let mainPortProto: string | undefined; @@ -519,7 +592,14 @@ export function ContainersHealth({ {badgeGlyph}
-
{containerName}
+
+
{containerName}
+ {updateAffordance?.hasUpdate && ( + + Update + + )} +
{uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} {hcLabel ? <>·{hcLabel} : null} @@ -565,6 +645,7 @@ export function ContainersHealth({
+ {updateAffordance ? renderServiceUpdateButton(updateAffordance) : null} )} {!hideServiceMenu && container.Service && ( - - - - - - {isActive ? ( - <> - serviceAction('restart', container.Service!)}> - Restart service - - serviceAction('stop', container.Service!)}> - Stop service - - - ) : ( - serviceAction('start', container.Service!)}> - Start service - - )} - - + renderServiceLifecycleMenu( + container.Service, + isActive, + ) )}
@@ -752,13 +809,63 @@ export function ContainersHealth({ const busy = serviceUpdateInProgress?.service === spec.name; const hasUpdate = status ? isConfirmedServiceUpdate(status) : false; const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update'; - const showUpdateAction = spec.declaredImage !== null || spec.hasBuild; + // Registry Update only when a check confirmed a pending + // image update (clears after a successful recheck). Rebuild + // stays available for build-backed services without one. + // Stack-level Update in the identity header remains the + // always-on full-stack pull path. + const showUpdateAction = hasUpdate || spec.hasBuild; const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused'); const runningCount = group.filter(c => c.State === 'running').length; const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas'; const replicaCopy = mode === 'rebuild' ? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}` : `Updates all ${spec.expectedReplicas} ${replicaWord}`; + const updateAffordance: ServiceUpdateAffordance = { + hasUpdate, + mode, + showUpdateAction, + busy, + replicaCopy, + onRequest: () => onRequestServiceUpdate?.(spec.name, mode), + }; + + // Single-container declared service: one flat card with + // Update left of ImageSourceMenu and the lifecycle kebab. + if (group.length === 1) { + return ( +
+ {renderContainerCard(group[0], false, updateAffordance)} +
+ ); + } + + // Zero containers: compact row (name + Update + kebab), + // not renderContainerCard (no ContainerInfo). + if (group.length === 0) { + return ( +
+
+ {spec.name} + {hasUpdate && ( + + Update + + )} +
+
+ {renderServiceUpdateButton(updateAffordance)} + {renderServiceLifecycleMenu(spec.name, false)} +
+
+ ); + } + + // Multi-replica: keep header + nested children (no + // updateAffordance on child cards). return (
@@ -774,67 +881,13 @@ export function ContainersHealth({ )}
- {showUpdateAction && ( - - - - - - {replicaCopy} - - - )} - - - - - - {isServiceActive ? ( - <> - serviceAction('restart', spec.name)}> - Restart service - - serviceAction('stop', spec.name)}> - Stop service - - - ) : ( - serviceAction('start', spec.name)}> - Start service - - )} - - + {renderServiceUpdateButton(updateAffordance)} + {renderServiceLifecycleMenu(spec.name, isServiceActive)}
- {group.length > 0 ? ( -
- {group.map(container => renderContainerCard(container, true))} -
- ) : ( -
- No containers running for this service. -
- )} +
+ {group.map(container => renderContainerCard(container, true))} +
); })} @@ -846,6 +899,7 @@ export function ContainersHealth({ {safeContainers.map(container => renderContainerCard(container, false))} )} + ); } diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts index 5bd311df..1ee613f8 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -10,10 +10,13 @@ vi.mock('@/context/NodeContext', () => ({ // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. +type CanFn = (action: string, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; + function makeOptions( activeNode: Node | null, stackPorts: Record, stackStatuses: Record = { 'web.yml': 'running' }, + can: CanFn = () => true, ) { const stackListState = { stackStatuses, @@ -42,10 +45,16 @@ function makeOptions( stackActions, activeNode, isAdmin: true, - can: () => true, + can, } as unknown as Parameters[0]; } +// Reach past the `unknown` cast makeOptions returns to assert on the inner +// stackActions mocks it built. +function stackActionsOf(options: Parameters[0]) { + return (options as unknown as { stackActions: { checkUpdatesForStack: ReturnType } }).stackActions; +} + describe('useSidebarContextMenu canOpenApp', () => { it('is true for a local node with a published port', () => { const { result } = renderHook(() => @@ -89,3 +98,36 @@ describe('useSidebarContextMenu stackStatus', () => { expect(missing.result.current('web.yml').stackStatus).toBe('unknown'); }); }); + +describe('useSidebarContextMenu checkUpdates', () => { + it('calls checkUpdatesForStack with the stack name (not the .yml file)', () => { + const options = makeOptions({ id: 1, type: 'local' } as Node, { 'web.yml': 8989 }); + const { result } = renderHook(() => useSidebarContextMenu(options)); + result.current('web.yml').checkUpdates(); + expect(stackActionsOf(options).checkUpdatesForStack).toHaveBeenCalledWith('web'); + }); +}); + +describe('useSidebarContextMenu canViewMonitor / canCheckUpdates wiring', () => { + it('derives canViewMonitor from stack:read and canCheckUpdates from stack:deploy, both scoped to the stack and active node', () => { + const can = vi.fn((action) => action === 'stack:read'); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(true); + expect(ctx.canCheckUpdates).toBe(false); + expect(can).toHaveBeenCalledWith('stack:read', 'stack', 'web', 7); + expect(can).toHaveBeenCalledWith('stack:deploy', 'stack', 'web', 7); + }); + + it('denies both when the permission check fails closed', () => { + const can = vi.fn(() => false); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(false); + expect(ctx.canCheckUpdates).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index e9329318..2628237c 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions { stackActions: StackActionsHook; activeNode: Node | null | undefined; isAdmin: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; } export function useSidebarContextMenu({ @@ -61,9 +61,10 @@ export function useSidebarContextMenu({ canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null, isBusy: stackListState.isStackBusy(file), isAdmin, - canDelete: can('stack:delete', 'stack', sName), - canDeploy: can('stack:deploy', 'stack', sName), - canEditLabels: can('stack:edit', 'stack', sName), + canDelete: can('stack:delete', 'stack', sName, nodeId), + canDeploy: can('stack:deploy', 'stack', sName, nodeId), + canEditLabels: can('stack:edit', 'stack', sName, nodeId), + canViewMonitor: can('stack:read', 'stack', sName, nodeId), // POST /api/labels (the inline "New label" entry) is guarded by the // unscoped requirePermission('stack:edit'); a user with only per-stack // scoped edit can toggle existing labels but cannot create new ones. @@ -74,7 +75,8 @@ export function useSidebarContextMenu({ menuVisibility: stackActions.getStackMenuVisibility(file), openAlertSheet: () => overlayState.openAlertSheet(file), openAutoHeal: () => overlayState.openAutoHeal(file), - checkUpdates: () => stackActions.checkUpdatesForStack(), + canCheckUpdates: can('stack:deploy', 'stack', sName, nodeId), + checkUpdates: () => stackActions.checkUpdatesForStack(sName), openStackApp: () => stackActions.openStackApp(file), deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'), @@ -179,7 +181,7 @@ export function useSidebarContextMenu({ // deps would force a rebuild on every parent render and defeat the memo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, + stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, can, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id, hasCapability, navState.openMuteRulesWithPrefill, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 0fdeabcc..f00aa0be 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/api', () => ({ }), })); vi.mock('@/components/ui/toast-store', () => ({ - toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(() => 'loading-id'), dismiss: vi.fn() }, })); import { apiFetch } from '@/lib/api'; @@ -249,6 +249,87 @@ describe('useStackActions.handleSaveAndDeploy', () => { }); }); +describe('useStackActions.checkUpdatesForStack', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiFetch).mockReset(); + }); + + it('hits the per-stack refresh endpoint and shows success when the stack is cleared', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response(JSON.stringify({ outcome: 'cleared', warning: null }), { status: 200 }), + ); + const { result, stackListState } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(apiFetch).toHaveBeenCalledWith('/image-updates/refresh/web', { method: 'POST' }); + expect(stackListState.fetchImageUpdates).toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.success).toHaveBeenCalledWith('Image update check complete.'); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it('shows the warning via toast.info instead of success when verification did not cleanly complete', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ outcome: 'verification_failed', warning: 'Could not verify the update.' }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('Could not verify the update.'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('replaces the generic post-update warning copy for an incomplete verification too', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'verification_incomplete', + warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.info).toHaveBeenCalledWith('Could not fully verify update status for web.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + }); + + it('uses stack-scoped copy instead of the backend post-update warning when an update is still present', async () => { + // The backend reuses its post-update reconciliation result for this + // manual pre-update check, so its "still_present" warning text ("The + // update command completed...") does not apply here; the frontend must + // not forward it verbatim. + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'still_present', + warning: 'The update command completed, but Sencho still detects an available image update.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('web still has an update available.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('shows a loading toast immediately and dismisses it on error', async () => { + vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ error: 'nope' }), { status: 500 })); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.loading).toHaveBeenCalledWith('Checking web for image updates...'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.error).toHaveBeenCalledWith('nope'); + }); +}); + describe('useStackActions node binding', () => { const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index f945c407..088dfad7 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -79,6 +79,16 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = { const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +// Mirrors ImageUpdateService's UPDATE_STILL_PRESENT_WARNING / UPDATE_VERIFICATION_INCOMPLETE_WARNING: +// that service's warning copy assumes an update was just applied, but +// checkUpdatesForStack runs before any update, so these two generic messages +// are replaced with accurate pre-update copy. A stack-specific reason (e.g. a +// compose render failure) is still forwarded as-is. +const GENERIC_POST_UPDATE_WARNINGS: ReadonlySet = new Set([ + 'The update command completed, but Sencho still detects an available image update.', + 'The update command completed, but Sencho could not fully verify whether an image update remains.', +]); + const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => { @@ -416,7 +426,6 @@ export function useStackActions(options: UseStackActionsOptions) { const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); - const checkUpdatesIntervalRef = useRef | null>(null); // True from a deploy click through the async pre-deploy advisory phase until // the deploy starts or is cancelled, so a double-click cannot start two deploys. const deployPendingRef = useRef(false); @@ -471,9 +480,6 @@ export function useStackActions(options: UseStackActionsOptions) { useEffect(() => { return () => { - if (checkUpdatesIntervalRef.current !== null) { - clearInterval(checkUpdatesIntervalRef.current); - } loadFileAbortRef.current?.abort(); containersFetchGenRef.current += 1; }; @@ -2244,38 +2250,32 @@ export function useStackActions(options: UseStackActionsOptions) { } }; - const checkUpdatesForStack = async () => { + const checkUpdatesForStack = async (stackName: string) => { + const loadingId = toast.loading(`Checking ${stackName} for image updates...`); try { - const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + const res = await apiFetch(`/image-updates/refresh/${encodeURIComponent(stackName)}`, { method: 'POST' }); if (res.ok) { - toast.success('Checking for image updates...'); - let elapsed = 0; - const poll = setInterval(async () => { - elapsed += 2000; - try { - const statusRes = await apiFetch('/image-updates/status'); - if (statusRes.ok) { - const { checking } = await statusRes.json(); - if (!checking || elapsed >= 60000) { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - if (!checking) toast.success('Image update check complete.'); - } - } - } catch { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - } - }, 2000); - checkUpdatesIntervalRef.current = poll; + const data = await res.json().catch(() => ({})) as { outcome?: unknown; warning?: unknown }; + await stackListState.fetchImageUpdates(); + const warning = typeof data.warning === 'string' ? data.warning : undefined; + if (data.outcome === 'still_present') { + toast.info(`${stackName} still has an update available.`); + } else if (warning && GENERIC_POST_UPDATE_WARNINGS.has(warning)) { + toast.info(`Could not fully verify update status for ${stackName}.`); + } else if (warning) { + toast.info(warning); + } else { + toast.success('Image update check complete.'); + } } else { const data = await res.json().catch(() => ({})); toast.error(data.error || 'Failed to check for updates'); } - } catch { + } catch (error) { + console.error(`Failed to check updates for stack ${stackName}:`, error); toast.error('Failed to check for updates'); + } finally { + toast.dismiss(loadingId); } }; diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index 70432483..e5a656f8 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -28,6 +28,7 @@ function makeReachCtx(over: Partial = {}): ReachabilityCont licenseStatus: 'ready', experimental: true, experimentalReady: true, + scheduledOpsAccessible: false, ...over, }; } diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 811a7f10..75b1975e 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -17,6 +17,7 @@ import { type ReachabilityContext, } from '@/lib/routing/reachability'; import { useExperimental } from '@/hooks/useExperimental'; +import { canScheduleAny } from '@/lib/scheduledActions'; import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; @@ -34,12 +35,18 @@ interface UseViewNavigationStateOptions { export function useViewNavigationState(options?: UseViewNavigationStateOptions) { const { onNavigateToDashboard, hasFleetCapability = false, containerLabelsEnabled = false } = options ?? {}; - const { isAdmin, can, permissionsStatus } = useAuth(); + const { isAdmin, can, permissionsStatus, permissions } = useAuth(); const { isPaid, licenseStatus } = useLicense(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { experimental, experimentalReady } = useExperimental(); + const scheduledOpsAccessible = useMemo(() => canScheduleAny( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + (action, resourceType, resourceId, nodeId) => can(action as Parameters[0], resourceType, resourceId, nodeId), + permissions, + ), [can, permissions]); + const initialRoute = readUrlRouteState(); const [activeView, setActiveView] = useState(initialRoute.activeView); @@ -62,7 +69,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) licenseStatus, experimental, experimentalReady, - }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady]); + scheduledOpsAccessible, + }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady, scheduledOpsAccessible]); const handleOpenSettings = useCallback((section?: SectionId) => { if (section) setSettingsSection(section); diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 38487f35..7aededc2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { FleetMasthead } from './fleet/FleetMasthead'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet'; +import { NodeDetailsSheet } from './FleetView/NodeDetailsSheet'; import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog'; import { OverviewTab } from './FleetView/OverviewTab'; import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences'; @@ -63,13 +64,14 @@ export function FleetView({ onFleetActiveTabChange, }: FleetViewProps) { const { isPaid, licenseStatus } = useLicense(); - const { isAdmin } = useAuth(); - const { hasCapability } = useNodes(); + const { isAdmin, can } = useAuth(); + const canManageFleet = can('node:manage'); + const canExportDossier = can('node:read') && can('stack:read'); + const { hasCapability, nodes: registryNodes } = useNodes(); const { experimental, experimentalReady } = useExperimental(); const containerLabelsEnabled = hasCapability('container-label-inventory'); // Visual fail-closed while /meta loads; paid/admin gates still apply when on. const canDiscoverRouting = experimentalReady && experimental && isPaid; - const canDiscoverSecrets = experimentalReady && experimental && isPaid && isAdmin; const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -92,6 +94,7 @@ export function FleetView({ }); const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes'); + const [detailsNodeId, setDetailsNodeId] = useState(null); const [internalTab, setInternalTab] = useState('overview'); const activeTab = controlledTab ?? internalTab; @@ -100,9 +103,9 @@ export function FleetView({ if (controlledTab === undefined) setInternalTab(tab); }; - // Fall back only after experimental readiness settles. When experimental is - // on, also wait for license (and admin for secrets) so a paid deep link is - // not rewritten to Overview while isPaid is still the cold-load false. + // Fall back Routing deep links when experimental/license gates resolve false. + // Wait for license during cold load so a paid deep link is not rewritten to + // Overview while isPaid is still the cold-load false. useEffect(() => { if (!experimentalReady) return; if (activeTab === 'routing') { @@ -112,19 +115,10 @@ export function FleetView({ } if (licenseStatus !== 'ready') return; if (!isPaid) setActiveTab('overview'); - return; - } - if (activeTab === 'secrets') { - if (!experimental) { - setActiveTab('overview'); - return; - } - if (licenseStatus !== 'ready') return; - if (!isPaid || !isAdmin) setActiveTab('overview'); } // setActiveTab closes over onFleetActiveTabChange; listing deps explicitly. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [experimentalReady, experimental, licenseStatus, isPaid, isAdmin, activeTab]); + }, [experimentalReady, experimental, licenseStatus, isPaid, activeTab]); useEffect(() => { if (fleetUpdatesIntent) { @@ -213,7 +207,7 @@ export function FleetView({ Actions - {canDiscoverSecrets && ( + {isAdmin && ( Secrets @@ -240,7 +234,7 @@ export function FleetView({ Refresh - {isAdmin && ( + {canExportDossier && ( @@ -289,9 +283,10 @@ export function FleetView({ onRetryUpdate={updateStatus.retryNodeUpdate} onDismissUpdate={updateStatus.dismissNodeUpdate} onCordonChange={() => { void overview.fetchOverview(true); }} - onEditNode={isAdmin ? openEdit : undefined} - onDeleteNode={isAdmin ? openDelete : undefined} + onEditNode={openEdit} + onDeleteNode={openDelete} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + onOpenNodeDetails={setDetailsNodeId} onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} checkingUpdates={updateStatus.checkingUpdates} @@ -324,19 +319,25 @@ export function FleetView({ {canDiscoverRouting && ( - + can('node:manage', 'node', String(nodeId))} + canManageMembership={isAdmin} + /> )} - + can('node:manage', 'node', String(nodeId))} + /> {/* Fleet Actions runs against the whole fleet, so it takes the unfiltered node list rather than the overview-filtered view. */} - {canDiscoverSecrets && ( + {isAdmin && ( @@ -366,6 +367,18 @@ export function FleetView({ triggerUpdateAll={updateStatus.triggerUpdateAll} /> + { if (!open) setDetailsNodeId(null); }} + node={detailsNodeId !== null ? (overview.allNodes.find(n => n.id === detailsNodeId) ?? null) : null} + registryNode={detailsNodeId !== null ? (registryNodes.find(n => n.id === detailsNodeId) ?? null) : null} + updateStatus={detailsNodeId !== null ? overview.updateStatusMap.get(detailsNodeId) : undefined} + networkingSignal={detailsNodeId !== null ? overview.networkingByNode.get(detailsNodeId) : undefined} + canManageNode={detailsNodeId !== null && can('node:manage', 'node', String(detailsNodeId))} + onOpenNetworking={onOpenNodeNetworking} + onEdit={openEdit} + /> + void; onDelete?: (node: Node) => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + /** Opens the read-only Node details sheet. Available to any role that can see the card. */ + onOpenDetails?: (nodeId: number) => void; } // --- Sub-Components --- @@ -67,7 +70,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { // --- Main Export --- -export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) { +export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill, onOpenDetails }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -79,16 +82,21 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, const { nodes: registryNodes } = useNodes(); const registryNode = registryNodes.find(n => n.id === node.id); const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1; - const canEdit = Boolean(isAdmin && onEdit && registryNode); - const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal); + const canManageNode = can('node:manage', 'node', String(node.id)); + const canEdit = Boolean(canManageNode && onEdit && registryNode); + const canDelete = Boolean(canManageNode && onDelete && registryNode && !registryNode.is_default && !isLastLocal); // Cordon is permission-gated only (node:manage), matching the backend route guard. - const canCordon = can('node:manage', 'node', String(node.id)); + const canCordon = canManageNode; const nodeMuteActions = useNodeMuteActions( node.id, node.name, onOpenMuteRulesWithPrefill ?? (() => {}), ); - const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); + // "Node details" is always available to anyone who can see the card (same + // node:read gate that already governs Fleet card visibility), so the kebab + // itself is no longer conditional. This flag now only decides whether the + // manage items (which stay node:manage-gated) render below the separator. + const hasManageMenuItems = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; @@ -96,6 +104,8 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, const formattedLatest = formatVersion(updateStatus?.latestVersion); const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); + const memUsed = getNodeMemUsed(node); + const memTotal = getNodeMemTotal(node); const diskPercent = getNodeDisk(node); const openCordonModal = () => { @@ -155,49 +165,54 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, {/* Card Header */}
{isLocal && ( - + ★ Local )} - {showMenu && ( -
- - - + + + {onOpenDetails && ( + onOpenDetails(node.id)}> + + Node details + + )} + {onOpenDetails && hasManageMenuItems && } + {canEdit && registryNode && ( + onEdit!(registryNode)}> + + Edit node + + )} + {canDelete && registryNode && ( + onDelete!(registryNode)} + className="text-destructive focus:text-destructive" > - - - - - {canEdit && registryNode && ( - onEdit!(registryNode)}> - - Edit node - - )} - {canDelete && registryNode && ( - onDelete!(registryNode)} - className="text-destructive focus:text-destructive" - > - - Delete node - - )} - {canCordon && ( - - - {node.cordoned ? 'Uncordon node' : 'Cordon node'} - - )} - {onOpenMuteRulesWithPrefill && } - - -
- )} + + Delete node + + )} + {canCordon && ( + + + {node.cordoned ? 'Uncordon node' : 'Cordon node'} + + )} + {onOpenMuteRulesWithPrefill && } + + +
@@ -307,7 +322,7 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, RAM - {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)} + {formatBytes(memUsed, 1)} / {formatBytes(memTotal, 1)}
80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
diff --git a/frontend/src/components/FleetView/NodeDetailsSheet.tsx b/frontend/src/components/FleetView/NodeDetailsSheet.tsx new file mode 100644 index 00000000..f9090269 --- /dev/null +++ b/frontend/src/components/FleetView/NodeDetailsSheet.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { Cpu, MemoryStick, HardDrive, Globe, Monitor, Terminal, Ban, Pencil, KeyRound, Network } from 'lucide-react'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NodeLabelPicker } from '@/components/blueprints/NodeLabelPicker'; +import { useNodes, type Node } from '@/context/NodeContext'; +import { formatVersion } from '@/lib/version'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import { formatBytes } from '@/lib/utils'; +import { PinnedUpdateBadge } from './PinnedUpdateBadge'; +import type { FleetNode, NodeUpdateStatus } from './types'; + +interface NodeDetailsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: FleetNode | null; + registryNode: Node | null; + updateStatus?: NodeUpdateStatus; + networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean }; + canManageNode: boolean; + onOpenNetworking?: (nodeId: number) => void; + onEdit?: (node: Node) => void; +} + +// A small nice-to-have translation for the most operator-relevant capability +// strings; anything not listed here just renders its raw identifier. +const CAPABILITY_LABELS: Partial> = { + 'cross-node-rbac': 'Cross-node RBAC', + 'self-update': 'Self-update', + 'fleet': 'Fleet management', + 'compose-networking': 'Networking inventory', +}; + +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleString(); +} + +// `FleetNode.last_successful_contact` and `FleetNode.pilot_last_seen` come from +// the fleet-overview endpoint in Unix SECONDS (DatabaseService.updateNodeLastContact +// writes Math.floor(Date.now()/1000); fleet.ts's pilotLastSeenSeconds() divides the +// millisecond DB value by 1000 for this same response). `formatTimeAgo`/`formatTimestamp` +// both expect milliseconds, so any FleetNode-sourced timestamp must convert here before +// use. `registryNode`-sourced timestamps (e.g. pilot_last_seen from /api/nodes) are +// already in milliseconds and must NOT be passed through this helper. +function fleetSecondsToMs(seconds: number): number { + return seconds * 1000; +} + +function UsageBar({ percent, color }: { percent: number; color: string }) { + return ( +
+
+
+ ); +} + +function Field({ label, children, span }: { label: string; children: ReactNode; span?: 1 | 2 }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function NodeDetailsSheet({ + open, onOpenChange, node, registryNode, updateStatus, networkingSignal, + canManageNode, onOpenNetworking, onEdit, +}: NodeDetailsSheetProps) { + const { nodeMeta, refreshNodeMeta } = useNodes(); + const [capabilitiesExpanded, setCapabilitiesExpanded] = useState(false); + const nodeId = node?.id ?? null; + + useEffect(() => { + if (open && nodeId !== null) void refreshNodeMeta(nodeId); + }, [open, nodeId, refreshNodeMeta]); + + useEffect(() => { + if (!open) setCapabilitiesExpanded(false); + }, [open]); + + if (!node) return null; + + const meta = nodeMeta.get(node.id) ?? null; + const isLocal = node.type === 'local'; + const isPilot = registryNode?.mode === 'pilot_agent'; + const connectionModeLabel = isLocal ? 'Local' : isPilot ? 'Pilot Agent' : 'API Proxy'; + const versionLabel = formatVersion(updateStatus?.version ?? meta?.version ?? null); + const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0; + const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; + const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0; + const hasNetworkingSignal = Boolean( + networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift), + ); + + const metaLine = [ + connectionModeLabel, + node.status === 'online' ? 'Online' : node.status === 'offline' ? 'Offline' : 'Unknown', + versionLabel, + node.stacks ? `${node.stacks.length} stack${node.stacks.length === 1 ? '' : 's'}` : null, + ].filter(Boolean).join(' · '); + + const footerContext = node.status === 'online' + ? 'Live · refreshes with the fleet overview' + : node.last_successful_contact + ? `Last seen ${formatTimeAgo(fleetSecondsToMs(node.last_successful_contact))}` + : 'Never contacted'; + + return ( + onEdit(registryNode), + } : undefined} + secondaryActions={onOpenNetworking && hasNetworkingSignal ? [{ + label: 'View networking', + icon: Network, + onClick: () => onOpenNetworking(node.id), + }] : undefined} + footerContext={footerContext} + size="md" + > + +
+ +

+ {isLocal ? : isPilot ? : } + {connectionModeLabel} +

+
+ +

+ {isLocal + ? 'docker.sock' + : isPilot + ? (registryNode?.pilot_last_seen ? `Tunnel (seen ${formatTimeAgo(registryNode.pilot_last_seen)})` : 'Tunnel (waiting)') + : (registryNode?.api_url || '-')} +

+
+ {typeof node.latency_ms === 'number' && ( + +

{node.latency_ms} ms

+
+ )} + {!isLocal && ( + +

+ {node.last_successful_contact ? formatTimeAgo(fleetSecondsToMs(node.last_successful_contact)) : 'Never'} +

+
+ )} + {isPilot && ( + <> + +

+ {node.pilot_last_seen ? formatTimeAgo(fleetSecondsToMs(node.pilot_last_seen)) : 'Never'} +

+
+ +

{formatVersion(registryNode?.pilot_agent_version) ?? 'Unknown'}

+
+ + )} + +

+ + + {registryNode?.has_token ? 'Yes' : 'No'} + +

+
+
+
+ + + {node.systemStats ? ( +
+
+
+ + CPU · {node.systemStats.cpu.cores} cores + + {node.systemStats.cpu.usage}% +
+ 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} /> +
+
+
+ + Memory + + {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)} +
+ 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} /> +
+ {node.systemStats.disk && ( +
+
+ + Disk + + {formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)} +
+ 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} /> +
+ )} +
+ ) : ( +

Unavailable while the node is offline.

+ )} +
+ + + {node.stats ? ( +
+
+
{node.stats.active}
+
Running
+
+
+
{node.stats.exited}
+
Stopped
+
+
+
{node.stats.managed}
+
Managed
+
+
+
{node.stats.unmanaged}
+
Unmanaged
+
+
+ ) : ( +

Unavailable while the node is offline.

+ )} + {onOpenNetworking && hasNetworkingSignal && networkingSignal && ( + onOpenNetworking(node.id)} + > + Networking · {networkingSignal.drift ? 'drift' : networkingSignal.exposed ? 'exposed' : 'unknown exposure'} + + )} +
+ + +
+ +

{versionLabel ?? 'Unknown'}

+
+ +

{updateStatus?.imageChannel ?? 'Unknown'}

+
+ +

{updateStatus?.imagePinKind ?? 'Unknown'}

+
+ +

+ {!updateStatus ? ( + Unknown + ) : updateStatus.updateBlocked ? ( + + ) : updateStatus.updateAvailable ? ( + Update available + ) : ( + Up to date + )} +

+
+
+ {meta ? ( +
+ + {capabilitiesExpanded && ( +
    + {meta.capabilities.map(c => ( +
  • + {CAPABILITY_LABELS[c] ?? c} +
  • + ))} +
+ )} +
+ ) : ( + + )} +
+ + +
+
+ Labels +
+ +
+
+
+ +

+ {node.cordoned ? ( + + Cordoned + + ) : ( + Schedulable + )} +

+
+ {node.cordoned && ( + <> + +

{node.cordoned_at ? formatTimestamp(node.cordoned_at) : 'Unknown'}

+
+ +

{node.cordoned_reason ?? 'No reason given'}

+
+ + )} + +

{registryNode?.is_default ? 'Yes' : 'No'}

+
+ +

{registryNode?.compose_dir ?? '-'}

+
+ +

{registryNode?.created_at ? formatTimestamp(registryNode.created_at) : 'Unknown'}

+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 46465953..72c6f4c8 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -39,6 +39,7 @@ interface OverviewTabProps { onEditNode?: (node: Node) => void; onDeleteNode?: (node: Node) => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + onOpenNodeDetails: (nodeId: number) => void; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; topologyPositions: SavedPositions; @@ -77,6 +78,7 @@ export function OverviewTab({ onEditNode, onDeleteNode, onOpenMuteRulesWithPrefill, + onOpenNodeDetails, topologyMode, onTopologyModeChange, topologyPositions, @@ -159,6 +161,7 @@ export function OverviewTab({ onEdit={onEditNode} onDelete={onDeleteNode} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + onOpenDetails={onOpenNodeDetails} /> ))}
diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index a94ee6ee..fa32cc88 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -28,7 +28,7 @@ function offlineNode(): FleetNode { } function baseProps(node: FleetNode) { - return { node, onNavigate: vi.fn() }; + return { node, onNavigate: vi.fn(), onOpenDetails: vi.fn() }; } beforeEach(() => { @@ -62,10 +62,51 @@ describe('NodeCard', () => { expect(can).toHaveBeenCalledWith('node:manage', 'node', '2'); }); - it('hides the cordon control from a user lacking node:manage', () => { + it('shows edit and delete controls to a scoped node manager who is not an admin', async () => { + const node = onlineNode(); + const registryNode = { id: 2, name: 'Edge', type: 'remote', is_default: false }; + const onEdit = vi.fn(); + const onDelete = vi.fn(); + useNodesMock.mockReturnValue({ nodes: [registryNode, { id: 1, type: 'local' }], hasCapability: vi.fn(() => false) }); + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn((action: string) => action === 'node:manage') }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + expect(await screen.findByText('Edit node')).toBeInTheDocument(); + expect(screen.getByText('Delete node')).toBeInTheDocument(); + }); + + it('shows only Node details to a user lacking node:manage', async () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); render(); - expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + expect(await screen.findByText('Node details')).toBeInTheDocument(); + expect(screen.queryByText('Cordon node')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit node')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete node')).not.toBeInTheDocument(); + }); + + it('calls onOpenDetails with the node id when Node details is clicked', async () => { + const onOpenDetails = vi.fn(); + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + await userEvent.click(await screen.findByText('Node details')); + expect(onOpenDetails).toHaveBeenCalledWith(2); + }); + + it('shows Node details ahead of the manage items for a node:manage user', async () => { + const can = vi.fn((action: string) => action === 'node:manage'); + useAuthMock.mockReturnValue({ isAdmin: false, can }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + const menuItems = await screen.findAllByRole('menuitem'); + const labels = menuItems.map(item => item.textContent); + expect(labels[0]).toBe('Node details'); + expect(labels).toContain('Cordon node'); }); it('shows Uncordon when the node is already cordoned', async () => { diff --git a/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx new file mode 100644 index 00000000..8aa9adf4 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const useNodesMock = vi.fn(); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() })); + +// NodeLabelPicker is a fully self-fetching reused unit (its own tests cover its +// behavior); shallow-mock it here so this file stays focused on the sheet. +vi.mock('@/components/blueprints/NodeLabelPicker', () => ({ + NodeLabelPicker: ({ nodeId, canEdit }: { nodeId: number; canEdit: boolean }) => ( +
labels for {nodeId} · editable={String(canEdit)}
+ ), +})); + +import { NodeDetailsSheet } from '../NodeDetailsSheet'; +import type { FleetNode, NodeUpdateStatus } from '../types'; +import type { Node } from '@/context/NodeContext'; + +// FleetNode's last_successful_contact/pilot_last_seen come from the +// fleet-overview endpoint in Unix SECONDS (see fleetSecondsToMs's comment in +// the component) — these fixtures must use seconds, not milliseconds, or a +// bug in the component's unit handling would go undetected here. +function fleetNode(overrides: Partial = {}): FleetNode { + return { + id: 2, + name: 'Edge', + type: 'remote', + mode: 'proxy', + status: 'online', + stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 }, + systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } }, + stacks: ['web'], + cordoned: false, + cordoned_at: null, + cordoned_reason: null, + latency_ms: 42, + last_successful_contact: Math.floor(Date.now() / 1000) - 5, + ...overrides, + }; +} + +function registryNode(overrides: Partial = {}): Node { + return { + id: 2, + name: 'Edge', + type: 'remote', + mode: 'proxy', + compose_dir: '/srv/compose', + is_default: false, + status: 'online', + created_at: Date.UTC(2026, 0, 1), + api_url: 'https://edge.internal:1852', + has_token: true, + ...overrides, + }; +} + +const UPDATE_STATUS: NodeUpdateStatus = { + nodeId: 2, name: 'Edge', type: 'remote', version: '1.2.0', latestVersion: '1.2.0', + updateAvailable: false, updateStatus: null, imageChannel: 'community', imagePinKind: 'semver', +}; + +function baseProps(overrides: Partial> = {}) { + return { + open: true, + onOpenChange: vi.fn(), + node: fleetNode(), + registryNode: registryNode(), + updateStatus: UPDATE_STATUS, + networkingSignal: { exposed: false, unknown: false, drift: false }, + canManageNode: false, + onOpenNetworking: vi.fn(), + onEdit: vi.fn(), + ...overrides, + }; +} + +beforeEach(() => { + useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() }); +}); +afterEach(() => vi.clearAllMocks()); + +describe('NodeDetailsSheet', () => { + it('renders all sections from the node, registry, and update-status data', () => { + render(); + expect(screen.getByRole('heading', { name: 'Edge' })).toBeInTheDocument(); + expect(screen.getByText('Connectivity')).toBeInTheDocument(); + expect(screen.getByText('Capacity')).toBeInTheDocument(); + expect(screen.getByText(/Compose workload/)).toBeInTheDocument(); + expect(screen.getByText('Compatibility')).toBeInTheDocument(); + expect(screen.getByText('Governance')).toBeInTheDocument(); + expect(screen.getByText('42 ms')).toBeInTheDocument(); + expect(screen.getByTestId('node-label-picker')).toHaveTextContent('labels for 2 · editable=false'); + }); + + it('renders cordon reason and date as visible text, not tooltip-only', () => { + render( + , + ); + expect(screen.getByText('Host maintenance')).toBeInTheDocument(); + expect(screen.getByText(new Date(Date.UTC(2026, 6, 1)).toLocaleString())).toBeInTheDocument(); + }); + + it('shows token-configured as a yes/no badge and never renders a raw token value', () => { + render(); + expect(screen.getByText('Token configured')).toBeInTheDocument(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.queryByText(/eyJ|Bearer /)).not.toBeInTheDocument(); + }); + + it('reuses the existing networking handler instead of rendering networking detail inline', async () => { + const onOpenNetworking = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const badge = screen.getByText(/Networking/); + await user.click(badge); + expect(onOpenNetworking).toHaveBeenCalledWith(2); + // No inline network detail (IPAM, subnet, etc.) is rendered by this sheet. + expect(screen.queryByText(/subnet/i)).not.toBeInTheDocument(); + }); + + it('shows a skeleton for capabilities until nodeMeta resolves, then renders the count', () => { + useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() }); + const { rerender } = render(); + expect(screen.queryByText(/capabilities advertised/)).not.toBeInTheDocument(); + + useNodesMock.mockReturnValue({ + nodeMeta: new Map([[2, { version: '1.2.0', capabilities: ['fleet', 'self-update'], fetchedAt: Date.now() }]]), + refreshNodeMeta: vi.fn(), + }); + rerender(); + expect(screen.getByText('2 capabilities advertised (show)')).toBeInTheDocument(); + }); + + it('returns null when no node is selected', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('converts FleetNode seconds-based timestamps correctly, not decades off', () => { + render( + , + ); + // "just now" appears for both Last successful contact and Pilot heartbeat. + // If the seconds value were passed straight to formatTimeAgo (which expects + // ms), this would instead render something like "20647d ago". + expect(screen.getAllByText('just now').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/d ago/)).not.toBeInTheDocument(); + }); + + it('omits Last successful contact for the local node instead of showing Never', () => { + render(); + expect(screen.queryByText('Last successful contact')).not.toBeInTheDocument(); + expect(screen.queryByText('Never')).not.toBeInTheDocument(); + }); + + it('renders Update status as Unknown, never a confident Up to date, when updateStatus is absent', () => { + render(); + const updateStatusLabel = screen.getByText('Update status'); + const updateStatusField = updateStatusLabel.parentElement as HTMLElement; + expect(within(updateStatusField).getByText('Unknown')).toBeInTheDocument(); + expect(screen.queryByText('Up to date')).not.toBeInTheDocument(); + }); + + it('still renders Up to date when updateStatus confirms no update is available', () => { + render(); + expect(screen.getByText('Up to date')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index b9448b09..742c0040 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -37,6 +37,7 @@ function props(overrides: Partial> = {} onNavigateToNode: vi.fn(), onOpenNodeNetworking: vi.fn(), networkingByNode: new Map(), + onOpenNodeDetails: vi.fn(), updatingNodeId: null, topologyMode: 'hub' as const, onTopologyModeChange: vi.fn(), diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 6f454215..6cad486b 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef } from 'react'; import { apiFetch } from '@/lib/api'; import { useFleetLabels, labelPaletteKey } from './useFleetLabels'; import { useNodeLabels } from './useNodeLabels'; -import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from '../nodeUtils'; +import { isCritical, getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk } from '../nodeUtils'; import type { FleetNode, ViewMode, FleetPreferences, NodeUpdateStatus } from '../types'; interface MastheadStats { @@ -96,8 +96,8 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee const worstCpu = worstCpuNode ? { name: worstCpuNode.name, percent: getNodeCpu(worstCpuNode) } : null; - const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0); - const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0); + const totalMemUsed = onlineNodes.reduce((sum, n) => sum + getNodeMemUsed(n), 0); + const totalMemTotal = onlineNodes.reduce((sum, n) => sum + getNodeMemTotal(n), 0); return { nodeCount: nodes.length, onlineCount, diff --git a/frontend/src/components/FleetView/nodeUtils.ts b/frontend/src/components/FleetView/nodeUtils.ts index 18cd8a73..b2c3f910 100644 --- a/frontend/src/components/FleetView/nodeUtils.ts +++ b/frontend/src/components/FleetView/nodeUtils.ts @@ -5,7 +5,19 @@ export function getNodeCpu(node: FleetNode): number { } export function getNodeMem(node: FleetNode): number { - return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; + if (!node.systemStats) return 0; + const eff = node.systemStats.memory.effectiveUsagePercent; + return parseFloat(eff ?? node.systemStats.memory.usagePercent); +} + +export function getNodeMemUsed(node: FleetNode): number { + const mem = node.systemStats?.memory; + return mem?.effectiveUsed ?? mem?.used ?? 0; +} + +export function getNodeMemTotal(node: FleetNode): number { + const mem = node.systemStats?.memory; + return mem?.effectiveTotal ?? mem?.total ?? 0; } export function getNodeDisk(node: FleetNode): number { diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index c733a44f..6b1ba012 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -10,7 +10,19 @@ export interface FleetNodeStats { export interface FleetNodeSystemStats { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: { + total: number; + used: number; + free: number; + usagePercent: string; + arcReclaimable?: number; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; + }; disk: { total: number; used: number; free: number; usagePercent: string } | null; } diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index d8a2da7e..c269db4f 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -41,7 +41,6 @@ export interface SenchoNavigateDetail { export function NodeManager() { const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); - const canEditLabels = isAdmin; // Mirror the backend node:manage guard. This top-level flag checks the global // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons // below additionally honor scoped per-node grants via can('node:manage', 'node', id). @@ -374,7 +373,7 @@ export function NodeManager() { {getStatusBadge(node.status)} - + {(() => { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index f436378a..b74b47ab 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -29,10 +29,13 @@ import { cn } from '@/lib/utils'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; +import { RollbackGenerationsTab, type RollbackGeneration } from './resources/RollbackGenerationsTab'; +import { TableSkeleton } from './resources/TableSkeleton'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { VolumeNameLabel } from './resources/VolumeNameLabel'; import { useTableSort } from '@/hooks/useTableSort'; import { SortableTableHead } from '@/components/ui/sortable-table'; +import { isPrunePlan, type PrunePlan, type PruneScope, type PruneTarget } from '@/lib/prunePlan'; // ── Interfaces ───────────────────────────────────────────────────────────────── @@ -58,6 +61,9 @@ interface DockerImage { managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; + /** True when a rollback hold protects this image from pruning; additive, independent of managedStatus. */ + rollbackProtected: boolean; + rollbackProtectionKind?: 'stack' | 'service'; } interface DockerVolume { @@ -90,25 +96,6 @@ interface UnmanagedContainer { } type ResourceFilter = 'all' | 'managed' | 'unmanaged'; -type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; -type PruneScope = 'managed' | 'all'; - -interface PrunePlanItem { - target: PruneTarget; - id: string; - name: string; - sizeBytes?: number; -} - -interface PrunePlan { - scope: PruneScope; - targets: PruneTarget[]; - items: PrunePlanItem[]; - reclaimableBytes: number; - fingerprint: string; - createdAt: number; - nodeId: number; -} const PLAN_PREVIEW_CAP = 30; @@ -290,6 +277,26 @@ function SenchoBadge() { ); } +function RollbackProtectedBadge({ kind }: { kind?: 'stack' | 'service' }) { + return ( + + + + + + Rollback protected + + + + {kind === 'stack' + ? 'Held as a full-stack rollback point. See Resources → Rollback.' + : 'Held for a pending per-service update rollback.'} + + + + ); +} + // ── Severity Badge ───────────────────────────────────────────────────────────── // ── Quick Clean Prune Button ─────────────────────────────────────────────────── @@ -345,24 +352,6 @@ function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: Pru ); } -// ── Table Skeleton ───────────────────────────────────────────────────────────── - -function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { - return ( - - {Array.from({ length: rows }).map((_, r) => ( - - {Array.from({ length: cols }).map((_, c) => ( - - - - ))} - - ))} - - ); -} - // Stable comparator maps for the resource tables (module scope so useTableSort // does not re-sort on every render). Mirrors the Security Images sort standard. const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = { @@ -384,14 +373,18 @@ interface ResourcesViewProps { export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) { const isMobile = useIsMobile(); - const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images'); - const { isAdmin } = useAuth(); + const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged' | 'rollback'>('images'); + const { isAdmin, can } = useAuth(); + const canReadResources = can('stack:read'); + const canDeployResources = can('stack:deploy'); + const canEditSecurityPolicy = can('stack:edit'); const { activeNode } = useNodes(); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); const [orphans, setOrphans] = useState>({}); + const [rollbackGenerations, setRollbackGenerations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); @@ -453,12 +446,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const generation = ++fetchGenerationRef.current; setIsLoading(true); try { - const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes] = await Promise.all([ + const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes, rollbackRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), apiFetch('/security/image-summaries').catch(() => null), apiFetch('/settings').catch(() => null), + apiFetch('/system/rollback/generations').catch(() => null), ]); // Resolve every body before the staleness check so a stale @@ -468,6 +462,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const orphansData = orphansRes.ok ? await orphansRes.json() : null; const summariesData = summariesRes && summariesRes.ok ? await summariesRes.json() : null; const settingsData = settingsRes && settingsRes.ok ? await settingsRes.json() : null; + const rollbackData = rollbackRes && rollbackRes.ok ? await rollbackRes.json() : null; if (fetchGenerationRef.current !== generation) return; @@ -486,6 +481,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} setSelectedOrphans([]); } if (summariesData) setScanSummaries(summariesData); + setRollbackGenerations(Array.isArray(rollbackData) ? rollbackData : []); } catch (err) { if (fetchGenerationRef.current !== generation) return; console.error('Failed to fetch data', err); @@ -530,8 +526,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} if (!res.ok) { throw new Error(data?.error || 'Failed to build prune plan'); } - setPrunePlan(data as PrunePlan); - return data as PrunePlan; + if (!isPrunePlan(data)) throw new Error('The node returned a malformed prune plan'); + setPrunePlan(data); + return data; } catch (error) { if (planFetchGenRef.current !== generation) return null; const err = error as { message?: string }; @@ -942,6 +939,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} { value: 'images', label: 'Images', count: images.length }, { value: 'volumes', label: 'Volumes', count: volumes.length }, { value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount }, + { value: 'rollback', label: 'Rollback', count: rollbackGenerations.length }, ]} /> ) : ( @@ -964,6 +962,12 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} {totalOrphansCount} + + + Rollback + {rollbackGenerations.length} + +
@@ -1064,6 +1068,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} ) : undefined} /> {img.isSencho && } + {img.rollbackProtected && } {(() => { const tag = img.RepoTags?.[0]; const summary = tag ? scanSummaries[tag] : undefined; @@ -1093,7 +1098,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} Inspect image
- {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( + {trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( @@ -1231,7 +1236,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
- {isAdmin && ( + {canReadResources && ( @@ -1365,6 +1370,17 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+ + {/* Rollback */} + + + @@ -1496,11 +1512,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} setInspectScanId(null)} - onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} - canGenerateSbom={isAdmin} - canExportSarif={isAdmin} + onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} + canGenerateSbom={canReadResources} + canExportSarif={canReadResources} canCompare - canManageSuppressions={isAdmin} + canManageSuppressions={canEditSecurityPolicy} /> ); diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index d7bd8bea..9a968c3c 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -40,7 +40,10 @@ import { RISK_BADGE_CLASSES, RISK_DOT_CLASSES, RISK_LABEL, + canScheduleAction, + canScheduleActionAnywhere, } from '@/lib/scheduledActions'; +import { useAuth } from '@/context/AuthContext'; import { LabelNameAutocomplete, type LabelNameSuggestion } from '@/components/labels/LabelNameAutocomplete'; const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes']; @@ -127,6 +130,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [simpleSchedule, setSimpleSchedule] = useState(DEFAULT_SIMPLE_SCHEDULE); const [simpleReplacedCron, setSimpleReplacedCron] = useState(false); const [formEnabled, setFormEnabled] = useState(true); + const { can, permissions } = useAuth(); const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(DEFAULT_PRUNE_TARGETS); const [formTargetServices, setFormTargetServices] = useState([]); @@ -574,12 +578,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]); const actionOptions = useMemo( () => - SCHEDULED_ACTIONS.map(o => ({ - value: o.id, - label: o.label, - group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label, - })), - [], + SCHEDULED_ACTIONS + .filter(o => canScheduleActionAnywhere(can, o, permissions)) + .map(o => ({ + value: o.id, + label: o.label, + group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label, + })), + [can, permissions], ); // Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers. const localNodeOptions = useMemo( @@ -607,6 +613,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const scheduleInvalid = scheduleMode === 'simple' ? !!simpleCronError : (!formCron || !!cronFieldError); + const canSaveWithCurrentTarget = useMemo(() => { + if (!currentAction) return false; + return canScheduleAction(can, currentAction, { + nodeId: formNodeId ? Number(formNodeId) : null, + stackName: formTargetId || null, + labelScope: formLabelScope === 'node' ? 'node' : 'fleet', + }); + }, [can, currentAction, formNodeId, formTargetId, formLabelScope]); + const isSaveDisabled = saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) @@ -616,7 +631,16 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p || (formAction === 'update-by-label' && ( !formSelectorValue.trim() || (formLabelScope === 'node' && !formNodeId) - )); + )) + || !canSaveWithCurrentTarget; + + const saveDisabledReason = useMemo((): string | null => { + if (saving || !currentAction || !formName || scheduleInvalid) return null; + if (!canSaveWithCurrentTarget) { + return 'You do not have permission to schedule this action on the selected target.'; + } + return null; + }, [saving, currentAction, formName, scheduleInvalid, canSaveWithCurrentTarget]); const windowEnd = now + TIMELINE_WINDOW_MS; const timelinePills = filteredTasks @@ -1282,6 +1306,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p } /> + {saveDisabledReason && ( +

{saveDisabledReason}

+ )} {/* Delete Confirmation */} diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 531dc35b..8de0f0e7 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -63,7 +63,7 @@ const MOBILE_MASTHEAD_TONE: Record onInspect(scanId, 'vulns'), onSummaries: setSummaries, @@ -264,7 +267,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security exploitTruncated={exploitTruncated} onNavigate={handleNavigate} onInspect={onInspect} - canScan={canScan} + canScan={canScanNode} onScanComplete={() => setReloadToken((t) => t + 1)} /> @@ -276,7 +279,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security loading={summariesLoading} error={summariesError} onInspect={onInspect} - canScan={canScan} + canScan={canScanImages} scanningRef={scanningRef} onScan={scanImage} initialFilter={imagesFilter ?? undefined} @@ -336,10 +339,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security scanId={inspectScanId} initialTab={inspectInitialTab} onClose={() => setInspectScanId(null)} - canGenerateSbom={isAdmin} - canExportSarif={isAdmin} + canGenerateSbom={canReadSecurityExports} + canExportSarif={canReadSecurityExports} canCompare - canManageSuppressions={isAdmin} + canManageSuppressions={canEditSecurityPolicy} /> ); diff --git a/frontend/src/components/StackAlertSheet.test.tsx b/frontend/src/components/StackAlertSheet.test.tsx index a598be3d..81a4a6fd 100644 --- a/frontend/src/components/StackAlertSheet.test.tsx +++ b/frontend/src/components/StackAlertSheet.test.tsx @@ -34,8 +34,9 @@ vi.mock('@/context/NodeContext', () => ({ hasCapability: (cap: string) => nodeState.activeNodeMeta?.capabilities.includes(cap) === true, }), })); +const useAuthMock = vi.fn(); vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true }), + useAuth: () => useAuthMock(), })); import { apiFetch } from '@/lib/api'; @@ -62,6 +63,8 @@ beforeEach(() => { mockedFetch.mockReset(); vi.mocked(toast.success).mockReset(); vi.mocked(toast.error).mockReset(); + useAuthMock.mockReset(); + useAuthMock.mockReturnValue({ isAdmin: true, can: () => true }); }); function mockHappyPath(services: string[] = ['api', 'database'], alerts: unknown[] = []) { @@ -306,3 +309,72 @@ describe('StackAlertSheet Alerts tab', () => { }); }); }); + +describe('StackAlertSheet permission gating (stack:edit deny path)', () => { + it('AlertsTab hides Add new rule and the delete-alert control when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockHappyPath(['api'], [{ + id: 1, + stack_name: 'my-stack', + service_name: 'api', + metric: 'cpu_percent', + operator: '>', + threshold: 80, + duration_mins: 5, + cooldown_mins: 60, + }]); + + render( {}} stackName="my-stack" />); + + // Reads stay visible: the rule itself still renders for a stack:read-only caller. + await waitFor(() => expect(screen.getByText('api')).toBeInTheDocument()); + expect(screen.queryByText('Add Rule')).toBeNull(); + expect(screen.queryByText('Add new rule')).toBeNull(); + expect(screen.queryByLabelText('Delete alert')).toBeNull(); + }); + + it('AutoHealTab hides Add new policy and PolicyRow edit affordances when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockedFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auto-heal/policies')) { + return jsonRes([{ + id: 1, + stack_name: 'my-stack', + service_name: null, + unhealthy_duration_mins: 5, + cooldown_mins: 5, + max_restarts_per_hour: 3, + auto_disable_after_failures: 5, + enabled: 1, + consecutive_failures: 0, + }]); + } + if (url.includes('/services')) return jsonRes(['api', 'database']); + return jsonRes(null, false); + }); + + render( + {}} + stackName="my-stack" + initialTab="auto-heal" + />, + ); + + // Reads stay visible: the policy row itself still renders. + await waitFor(() => expect(screen.getByText('All services')).toBeInTheDocument()); + expect(screen.queryByText('Add Policy')).toBeNull(); + expect(screen.queryByLabelText(/toggle policy for/i)).toBeNull(); + expect(screen.queryByLabelText('Delete policy')).toBeNull(); + // History is not edit-gated and should remain available either way. + expect(screen.getByLabelText('Toggle history')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index bd1766d2..69f8c036 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -184,8 +184,9 @@ export function StackAlertSheet({ } function AlertsTab({ stackName, initialService }: { stackName: string; initialService?: string }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); const { activeNode, activeNodeMeta } = useNodes(); + const canEditAlerts = can('stack:edit', 'stack', stackName, activeNode?.id); const isRemote = activeNode?.type === 'remote'; const canScopeService = activeNodeMeta?.capabilities.includes(SERVICE_SCOPED_STACK_ALERT_CAPABILITY) === true; @@ -443,13 +444,14 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe {' '}• Trigger after {alert.duration_mins}m • Cooldown {alert.cooldown_mins}m - {isAdmin && ( + {canEditAlerts && ( @@ -460,7 +462,7 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe )} - {isAdmin && ( + {canEditAlerts && (
{canScopeService && ( @@ -595,7 +597,9 @@ function AutoHealTab({ open: boolean; initialService?: string; }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); + const { activeNode } = useNodes(); + const canEditAutoHeal = can('stack:edit', 'stack', stackName, activeNode?.id); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -739,14 +743,14 @@ function AutoHealTab({ onToggle={handleToggle} deleting={deleting} saving={saving} - isAdmin={isAdmin} + canEdit={canEditAutoHeal} /> ))}
)}
- {isAdmin && ( + {canEditAutoHeal && (
@@ -835,10 +839,11 @@ interface PolicyRowProps { onToggle: (id: number, enabled: boolean) => void; deleting: boolean; saving: boolean; - isAdmin: boolean; + /** True when the caller may toggle/delete this policy (stack:edit). */ + canEdit: boolean; } -function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: PolicyRowProps) { +function PolicyRow({ policy, onDelete, onToggle, deleting, saving, canEdit }: PolicyRowProps) { const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); @@ -886,7 +891,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )}
- {isAdmin && ( + {canEdit && ( policy.id != null && onToggle(policy.id, checked)} @@ -910,7 +915,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )} - {isAdmin && ( + {canEdit && (
- {systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'} + {systemStats ? `${formatBytes(ramUsed)} / ${formatBytes(ramTotal)}` : '\u00A0'}
+ {systemStats?.memory.ballooned && systemStats.memory.ballooned > 0 ? ( +
+ Ballooned to host: {formatBytes(systemStats.memory.ballooned)} + {ramEffectivePercent !== null ? ` (effective ${parseFloat(ramEffectivePercent).toFixed(0)}%)` : ''} +
+ ) : null} + {systemStats?.memory.arcReclaimable && systemStats.memory.arcReclaimable > 0 ? ( +
+ ZFS ARC reclaimable: {formatBytes(systemStats.memory.arcReclaimable)} +
+ ) : null} {systemStats ? : null}
diff --git a/frontend/src/components/dashboard/deriveHealth.ts b/frontend/src/components/dashboard/deriveHealth.ts index 9c4c32b8..1c338eee 100644 --- a/frontend/src/components/dashboard/deriveHealth.ts +++ b/frontend/src/components/dashboard/deriveHealth.ts @@ -10,7 +10,10 @@ export interface HealthResult { // drift apart. export function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult { const cpu = parseFloat(systemStats?.cpu.usage || '0'); - const ram = parseFloat(systemStats?.memory.usagePercent || '0'); + // Ballooned memory is NOT subtracted for health: unlike ARC, ballooned + // pages are host-reclaimed and the guest cannot get them back on demand. + // A ballooned VM with real memory pressure must still show degraded/critical. + const ram = parseFloat(systemStats?.memory.usagePercent ?? '0'); const disk = parseFloat(systemStats?.disk?.usagePercent || '0'); const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length; diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 0b0632f2..d2485b7d 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -16,6 +16,13 @@ export interface SystemStats { used: number; free: number; usagePercent: string; + arcReclaimable?: number; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; }; disk: { fs: string; @@ -60,6 +67,7 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + | 'rollback_generation_released' | 'node_update_available' | 'system'; diff --git a/frontend/src/components/fleet/FederationTab.test.tsx b/frontend/src/components/fleet/FederationTab.test.tsx index 3a29f881..d38679f0 100644 --- a/frontend/src/components/fleet/FederationTab.test.tsx +++ b/frontend/src/components/fleet/FederationTab.test.tsx @@ -1,9 +1,9 @@ /** * Render-gate coverage for FederationTab's pin control. * - * Pinning a blueprint to a node is admin-only on the backend - * (PUT /api/blueprints/:id/pin requires admin). This test locks the matching UI - * gate: an admin sees an editable Select, a non-admin sees the placement + * Pinning a blueprint to a node is permission-gated on the backend. This test + * locks the matching UI gate: a manager sees an editable Select, while a user + * without permission sees the placement * read-only with an explanatory hint. Without this the affordance can drift * back to rendering an enabled control that the API rejects with 403. */ @@ -67,7 +67,7 @@ describe('FederationTab pin gating', () => { expect(await screen.findByText('web-blueprint')).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.queryByText(/Pin changes require an administrator/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument(); }); it('renders the pin placement read-only for a non-admin', async () => { @@ -75,9 +75,9 @@ describe('FederationTab pin gating', () => { expect(await screen.findByText('web-blueprint')).toBeInTheDocument(); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); - expect(screen.getByText(/Pin changes require an administrator/i)).toBeInTheDocument(); + expect(screen.getByText(/do not have permission to change pin placement/i)).toBeInTheDocument(); expect(screen.getByText('(unpinned)')).toBeInTheDocument(); - // The read-only branch must never be able to issue the admin-only pin request. + // The read-only branch must never be able to issue the pin request. expect(vi.mocked(pinBlueprint)).not.toHaveBeenCalled(); }); @@ -91,4 +91,12 @@ describe('FederationTab pin gating', () => { // "Effective" column, so getAllByText (not getByText) is required. expect(screen.getAllByText('node-alpha').length).toBeGreaterThan(0); }); + + it('shows pin controls for a scoped node manager', async () => { + render( nodeId === 1} />); + + expect(await screen.findByText('web-blueprint')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/fleet/FederationTab.tsx b/frontend/src/components/fleet/FederationTab.tsx index 69e836ae..84146d04 100644 --- a/frontend/src/components/fleet/FederationTab.tsx +++ b/frontend/src/components/fleet/FederationTab.tsx @@ -26,12 +26,12 @@ function formatTimestamp(ms: number | null): string { } interface FederationTabProps { - /** Whether the current user may change pin placement. Pinning is admin-only on the backend - * (PUT /api/blueprints/:id/pin requires admin); non-admins see the placement read-only. */ + /** Whether the current user may change pin placement. */ canManage: boolean; + canManageNode?: (nodeId: number) => boolean; } -export function FederationTab({ canManage }: FederationTabProps) { +export function FederationTab({ canManage, canManageNode }: FederationTabProps) { const [nodes, setNodes] = useState([]); const [blueprints, setBlueprints] = useState([]); const [loading, setLoading] = useState(true); @@ -61,6 +61,7 @@ export function FederationTab({ canManage }: FederationTabProps) { for (const node of nodes) map.set(node.id, node.name); return map; }, [nodes]); + const canManageAnyNode = canManage || nodes.some(node => canManageNode?.(node.id)); const handlePinChange = useCallback(async (blueprintId: number, value: string) => { const nodeId = value === UNPINNED ? null : Number.parseInt(value, 10); @@ -149,7 +150,7 @@ export function FederationTab({ canManage }: FederationTabProps) {

Pin policy

Force a blueprint onto a specific node, overriding its selector. - {!canManage && ' Pin changes require an administrator.'} + {!canManageAnyNode && ' You do not have permission to change pin placement.'}
@@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) { {describeSelector(bp.selector)} - {canManage ? ( + {canManageAnyNode ? ( is second) + const combos = screen.getAllByRole('combobox'); + await userEvent.click(combos[0]); + // Click a label option + const option = await screen.findByText('app'); + await userEvent.click(option); + // Fill stack name + const stackInput = screen.getByPlaceholderText('my-app'); + await userEvent.clear(stackInput); + await userEvent.type(stackInput, 'my-app'); + // Click Preview + await userEvent.click(screen.getByRole('button', { name: /^preview$/i })); +} + +describe('SecretPushSheet', () => { + it('clears the plan when the stack name changes after preview', async () => { + renderSheet(); + await fillAndPreview(); + + // Plan entry visible on Preview tab + await waitFor(() => { + expect(screen.getByText('central')).toBeInTheDocument(); + }); + + // Switch to Target tab and change stack name + await userEvent.click(screen.getByRole('tab', { name: /target/i })); + await userEvent.type(screen.getByDisplayValue('my-app'), '-v2'); + + // Preview tab click should be blocked (plan cleared) + await userEvent.click(screen.getByRole('tab', { name: /preview/i })); + expect(screen.getByPlaceholderText('my-app')).toBeInTheDocument(); + expect(screen.queryByText('central')).not.toBeInTheDocument(); + }); + + it('discards a stale in-flight preview response when inputs change', async () => { + let resolvePreview!: (value: SecretPushPlanEntry[]) => void; + vi.mocked(previewPush).mockReturnValue(new Promise((r) => { resolvePreview = r; })); + + renderSheet(); + + await screen.findByText('Target'); + await userEvent.click(screen.getAllByRole('combobox')[0]); + await userEvent.click(await screen.findByText('app')); + const stackInput = screen.getByPlaceholderText('my-app'); + await userEvent.clear(stackInput); + await userEvent.type(stackInput, 'my-app'); + await userEvent.click(screen.getByRole('button', { name: /^preview$/i })); + + // Switch to Target while preview is in flight, change an input + await userEvent.click(screen.getByRole('tab', { name: /target/i })); + await userEvent.type(screen.getByDisplayValue('my-app'), '-changed'); + + // Resolve stale preview + resolvePreview([planEntry({ nodeName: 'stale-result' })]); + await waitFor(() => { + expect(screen.queryByText('stale-result')).not.toBeInTheDocument(); + }); + expect(screen.getByPlaceholderText('my-app')).toBeInTheDocument(); + }); + + it('always surfaces push results even when inputs change mid-flight', async () => { + // Real preview so Push button appears + vi.mocked(previewPush).mockResolvedValue([planEntry()]); + + let resolvePush!: (v: { pushId: string; results: SecretPushResultEntry[] }) => void; + vi.mocked(executePush).mockReturnValue(new Promise((r) => { resolvePush = r; })); + + renderSheet(); + await fillAndPreview(); + await waitFor(() => { + expect(screen.getByText('central')).toBeInTheDocument(); + }); + + // Click Push + await userEvent.click(screen.getByRole('button', { name: /push to 1 node/i })); + + // Switch to Target while push is in flight, change an input + await userEvent.click(screen.getByRole('tab', { name: /target/i })); + await userEvent.type(screen.getByDisplayValue('my-app'), '-changed'); + + // Resolve push. Must always surface (C1 fix). + resolvePush({ pushId: 'p1', results: [resultEntry({ nodeName: 'push-result' })] }); + await waitFor(() => { + expect(screen.getByText('push-result')).toBeInTheDocument(); + }); + expect(vi.mocked(toast.success)).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/fleet/secrets/SecretPushSheet.tsx b/frontend/src/components/fleet/secrets/SecretPushSheet.tsx index ec25842a..cafc346d 100644 --- a/frontend/src/components/fleet/secrets/SecretPushSheet.tsx +++ b/frontend/src/components/fleet/secrets/SecretPushSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Loader2, Send, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, MinusCircle, type LucideIcon } from 'lucide-react'; import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet'; import { Input } from '@/components/ui/input'; @@ -59,6 +59,7 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) { const [plan, setPlan] = useState([]); const [results, setResults] = useState([]); const [expanded, setExpanded] = useState>(new Set()); + const inputVersionRef = useRef(0); useEffect(() => { if (!open) return; @@ -75,6 +76,14 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) { .catch(() => setAllLabels([])); }, [open]); + // Invalidate preview/results computed from the old inputs: clear them and + // bump a version counter so an in-flight request for the old inputs is dropped. + useEffect(() => { + setPlan([]); + setResults([]); + inputVersionRef.current += 1; + }, [selectedLabels, labelMode, stackName, envFile]); + const labelOptions: MultiSelectOption[] = useMemo( () => allLabels.map((l) => ({ value: l, label: l })), [allLabels], @@ -134,12 +143,14 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) { return; } setPreviewLoading(true); + const version = inputVersionRef.current; try { const result = await previewPush(secret.id, { selector: buildSelector(), stackName: stackName.trim(), envFileBasename: envFile, }); + if (inputVersionRef.current !== version) return; if (result.length === 0) { toast.error('No nodes match this selector'); return; diff --git a/frontend/src/components/mobile/MobileDashboard.tsx b/frontend/src/components/mobile/MobileDashboard.tsx index 96936e4a..a73c0855 100644 --- a/frontend/src/components/mobile/MobileDashboard.tsx +++ b/frontend/src/components/mobile/MobileDashboard.tsx @@ -95,7 +95,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac ); const cpuVal = parseFloat(data.systemStats?.cpu.usage || '0'); - const ramVal = parseFloat(data.systemStats?.memory.usagePercent || '0'); + const ramVal = parseFloat(data.systemStats?.memory.effectiveUsagePercent ?? data.systemStats?.memory.usagePercent ?? '0'); const diskVal = parseFloat(data.systemStats?.disk?.usagePercent || '0'); const netPerSec = (data.systemStats?.network?.rxSec ?? 0) + (data.systemStats?.network?.txSec ?? 0); diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index 7d363dcc..6eefb642 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -7,7 +7,8 @@ import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; import { formatBytes } from '@/lib/utils'; -import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; +import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; +import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet'; import type { FleetNode } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; @@ -159,9 +160,12 @@ function NodeDetail({ onCordonChange: () => void; }) { const { can } = useAuth(); + const { nodes: registryNodes } = useNodes(); + const registryNode = registryNodes.find(n => n.id === node.id) ?? null; const canCordon = can('node:manage', 'node', String(node.id)); const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); const tone = nodeTone(node); const online = node.status === 'online'; @@ -207,6 +211,7 @@ function NodeDetail({
onInspectNode(node.id)}>Inspect + setDetailsOpen(true)}>Details {canCordon ? ( setConfirmOpen(true)}> {node.cordoned ? 'Uncordon' : 'Drain'} @@ -221,7 +226,7 @@ function NodeDetail({ {node.systemStats.disk ? ( + +
); } @@ -309,8 +322,8 @@ export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: Mo const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0); const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0); const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0; - const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0); - const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0); + const memUsed = onlineNodes.reduce((s, n) => s + getNodeMemUsed(n), 0); + const memTotal = onlineNodes.reduce((s, n) => s + getNodeMemTotal(n), 0); const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0; const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…'; diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx index 08fbbb98..e635a78d 100644 --- a/frontend/src/components/mobile/MobileSettings.tsx +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; -import { useAuth } from '@/context/AuthContext'; -import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { SETTINGS_GROUPS, @@ -13,6 +11,7 @@ import { } from '@/components/settings'; import type { SectionId } from '@/components/settings'; import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent'; +import { useSettingsVisibility } from '@/components/settings/useSettingsVisibility'; import { BackChip, Kicker, Masthead } from './mobile-ui'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; @@ -31,12 +30,9 @@ export function MobileSettings({ onSelectedSectionChange, quickLinkCandidates, }: MobileSettingsProps) { - const { isAdmin } = useAuth(); - const { isPaid } = useLicense(); const { activeNode } = useNodes(); - const isRemote = activeNode?.type === 'remote'; const nodeName = activeNode?.name ?? 'local'; - const visibility = { isRemote, isAdmin, isPaid }; + const visibility = useSettingsVisibility(); const visibleItems = SETTINGS_ITEMS.filter( item => isItemVisible(item, visibility) && !isItemLocked(item, visibility), diff --git a/frontend/src/components/networking/NetworkingView.tsx b/frontend/src/components/networking/NetworkingView.tsx index 7810ed39..ba80f605 100644 --- a/frontend/src/components/networking/NetworkingView.tsx +++ b/frontend/src/components/networking/NetworkingView.tsx @@ -342,7 +342,7 @@ export function NetworkingView({ headerActions }: NetworkingViewProps) { {topFindings.map((finding) => { const primary = finding.recommendedActions.find((action) => - isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)), + isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack, nodeId)), ); return ( diff --git a/frontend/src/components/resources/RollbackGenerationsTab.tsx b/frontend/src/components/resources/RollbackGenerationsTab.tsx new file mode 100644 index 00000000..c2dd867f --- /dev/null +++ b/frontend/src/components/resources/RollbackGenerationsTab.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ConfirmModal } from '@/components/ui/modal'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Unlock } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events'; +import { TableSkeleton } from './TableSkeleton'; + +export interface RollbackGeneration { + id: string; + shortId: string; + stackName: string; + status: 'active' | 'restored_current' | 'superseded' | 'recovery_required'; + isCurrent: boolean; + phase: string; + createdAt: number; + artifactExpiresAt: number | null; + /** Best-effort UI hint only; the server revalidates eligibility on release. */ + releasable: boolean; +} + +interface RollbackGenerationsTabProps { + generations: RollbackGeneration[]; + isLoading: boolean; + isAdmin: boolean; + nodeId?: number; + /** Refetches the Resources page's data after a successful release. */ + onReleased: () => void | Promise; +} + +function formatExpiry(gen: RollbackGeneration): string { + if (gen.isCurrent) return 'Protected while current'; + if (gen.status === 'recovery_required') return 'Recovery required'; + if (gen.artifactExpiresAt === null) return 'Pending'; + const days = (gen.artifactExpiresAt - Date.now()) / (24 * 60 * 60 * 1000); + if (days <= 0) return 'Expiring now'; + if (days < 1) return `Expires in ${Math.max(1, Math.round(days * 24))}h`; + return `Expires in ${Math.round(days)}d`; +} + +function StateBadge({ gen }: { gen: RollbackGeneration }) { + switch (gen.status) { + case 'recovery_required': + return Recovery required; + case 'superseded': + return Superseded; + case 'active': + case 'restored_current': + return gen.isCurrent + ? Current + : Superseded; + default: { + const unhandled: never = gen.status; + return {String(unhandled)}; + } + } +} + +/** + * Full-stack rollback generations (the sencho-rb//:hold images + * StackUpdateRecoveryService creates). Kept in its own tab rather than the + * generic Images list: this is durable recovery state with its own lifecycle + * (stack, generation, retention, release), not ordinary Docker image inventory. + */ +export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId, onReleased }: RollbackGenerationsTabProps) { + const [confirmRelease, setConfirmRelease] = useState(null); + const [isReleasing, setIsReleasing] = useState(false); + + const handleRelease = async () => { + if (!confirmRelease) return; + setIsReleasing(true); + const loadingId = toast.loading(`Releasing rollback protection for ${confirmRelease.shortId}...`); + try { + const res = await apiFetch(`/system/rollback/generations/${confirmRelease.id}/release`, { method: 'POST' }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || 'Failed to release rollback protection'); + } + toast.success(data?.message || 'Rollback protection released'); + await onReleased(); + } catch (error) { + const err = error as Record; + toast.error(String(err?.message || 'Failed to release rollback protection')); + } finally { + toast.dismiss(loadingId); + setIsReleasing(false); + setConfirmRelease(null); + } + }; + + return ( + <> +

+ Rollback-protected images from full-stack updates. Each generation is kept so a failed update can be + automatically rolled back, and clears on its own once it is superseded and its retention window + passes (configurable under Settings → Infrastructure → Stacks → Deploy Guardrails). +

+
+ + + + + Stack + Generation + State + Retention + Actions + + + {isLoading ? : ( + + {generations.length === 0 ? ( + + + No rollback-protected generations on this node. + + + ) : generations.map((gen, i) => ( + + + + + {gen.shortId} + + {formatExpiry(gen)} + + {isAdmin && ( + + + + + + + {gen.releasable + ? 'Release rollback protection' + : 'Not releasable right now (mid-recovery or observing a health gate)'} + + + + )} + + + ))} + + )} +
+
+
+ + !open && setConfirmRelease(null)} + variant="destructive" + kicker="ROLLBACK · RELEASE · IRREVERSIBLE" + title={`Release rollback protection for ${confirmRelease?.stackName ?? ''}`} + confirmLabel={isReleasing ? 'Releasing...' : 'Release'} + confirming={isReleasing} + onConfirm={handleRelease} + > +

+ {confirmRelease?.isCurrent ? ( + <> + This is {confirmRelease?.stackName}'s + current rollback point. Releasing it now means Sencho will not be able to automatically + roll this stack back until its next successful full-stack update. + + ) : ( + <> + Permanently removes the held rollback image for generation{' '} + {confirmRelease?.shortId}{' '} + ahead of its normal retention window. + + )} +

+
+ + ); +} diff --git a/frontend/src/components/resources/TableSkeleton.tsx b/frontend/src/components/resources/TableSkeleton.tsx new file mode 100644 index 00000000..22db984f --- /dev/null +++ b/frontend/src/components/resources/TableSkeleton.tsx @@ -0,0 +1,20 @@ +import { TableBody, TableRow, TableCell } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +/** Shared loading placeholder for the Resources page's tabbed tables (Images, Volumes, Rollback). */ +export function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { + return ( + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: cols }).map((_, c) => ( + + + + ))} + + ))} + + ); +} diff --git a/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx b/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx new file mode 100644 index 00000000..355258d8 --- /dev/null +++ b/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RollbackGenerationsTab, type RollbackGeneration } from '../RollbackGenerationsTab'; +import { toast } from '@/components/ui/toast-store'; + +const apiFetch = vi.fn(); +vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + loading: vi.fn(() => 'toast-id'), + dismiss: vi.fn(), + }, +})); + +function generation(overrides: Partial = {}): RollbackGeneration { + return { + id: 'gen-1', + shortId: 'abc123456789', + stackName: 'seerr', + status: 'superseded', + isCurrent: false, + phase: 'immediate_verified', + createdAt: Date.now(), + artifactExpiresAt: Date.now() + 3 * 24 * 60 * 60 * 1000, + releasable: true, + ...overrides, + }; +} + +beforeEach(() => { + apiFetch.mockReset(); + (toast.success as ReturnType).mockReset(); + (toast.error as ReturnType).mockReset(); +}); + +describe('RollbackGenerationsTab', () => { + it('shows superseded-generation confirm copy (not the current-generation warning) for a non-current release', async () => { + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + + expect(await screen.findByText(/Permanently removes the held rollback image/i)).toBeInTheDocument(); + expect(screen.queryByText(/Automatic rollback is unavailable until/i)).not.toBeInTheDocument(); + }); + + it('shows the current-generation warning copy when releasing the current generation', async () => { + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + + expect(await screen.findByText(/Sencho will not be able to automatically/i)).toBeInTheDocument(); + }); + + it('confirming release POSTs to the release endpoint and calls onReleased on success', async () => { + apiFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true, message: 'Rollback protection released', artifactsCleaned: true }) }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(apiFetch).toHaveBeenCalledWith('/system/rollback/generations/gen-1/release', { method: 'POST' })); + await waitFor(() => expect(onReleased).toHaveBeenCalled()); + expect(toast.success).toHaveBeenCalledWith('Rollback protection released'); + }); + + it('surfaces the backend partial-cleanup message distinctly from a full release', async () => { + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, message: 'Rollback protection released; cleanup will finish shortly', artifactsCleaned: false }), + }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Rollback protection released; cleanup will finish shortly')); + }); + + it('surfaces the server error via toast and closes the modal without a lingering Releasing state on failure', async () => { + apiFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'This rollback generation cannot be released right now (it may be observing a health gate, mid-recovery, or already in progress).', code: 'NOT_ELIGIBLE' }) }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('cannot be released right now'))); + expect(onReleased).not.toHaveBeenCalled(); + // Modal closes (confirm button no longer present) rather than staying stuck mid-action. + await waitFor(() => expect(screen.queryByRole('button', { name: 'Release' })).not.toBeInTheDocument()); + }); + + it('hides the Release action for a non-admin', () => { + render(); + expect(screen.queryByRole('button', { name: /release rollback protection/i })).not.toBeInTheDocument(); + }); + + it('disables the Release action when the generation is not releasable', () => { + render(); + expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeDisabled(); + }); + + it('renders an empty state when there are no generations', () => { + render(); + expect(screen.getByText(/No rollback-protected generations on this node/i)).toBeInTheDocument(); + }); + + it('shows a loading skeleton instead of the empty state while the initial fetch is in flight', () => { + render(); + expect(screen.queryByText(/No rollback-protected generations on this node/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/security/ScanPolicyManager.tsx b/frontend/src/components/security/ScanPolicyManager.tsx index 91d43308..9e7bafc0 100644 --- a/frontend/src/components/security/ScanPolicyManager.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -57,7 +57,8 @@ const EMPTY_FORM: PolicyFormState = { * mirroring how the rest of the fleet-governance UI behaves. */ export function ScanPolicyManager() { - const { isAdmin } = useAuth(); + const { isAdmin, can } = useAuth(); + const canManagePolicies = can('stack:edit'); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { status: trivy, refresh: refreshTrivy } = useTrivyStatus(); @@ -264,7 +265,7 @@ export function ScanPolicyManager() {

Deploy enforcement policies

- {isAdmin && !isRemote && !isReplica && ( + {canManagePolicies && !isRemote && !isReplica && (
- {isAdmin && !isReplica && ( + {canManagePolicies && !isReplica && (
- {isAdmin && !isReplica && row.replicated_from_control === 0 && ( + {canManage && !isReplica && row.replicated_from_control === 0 && (
- + {canRead && ( + + )} + {canManage && ( + + )}
) : undefined } @@ -325,7 +331,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { by {row.created_by} - expires {formatExpiry(row)}
- {isAdmin && !isReplica && row.replicated_from_control === 0 && ( + {canManage && !isReplica && row.replicated_from_control === 0 && (
diff --git a/frontend/src/components/settings/UpdatesSection.tsx b/frontend/src/components/settings/UpdatesSection.tsx index 7dfc1fbb..38a976ce 100644 --- a/frontend/src/components/settings/UpdatesSection.tsx +++ b/frontend/src/components/settings/UpdatesSection.tsx @@ -46,8 +46,8 @@ function SectionSkeleton() { export function UpdatesSection() { const { activeNode } = useNodes(); - const { isAdmin } = useAuth(); - const readOnly = !isAdmin; + const { can, permissionsReady } = useAuth(); + const readOnly = !permissionsReady || !can('system:settings'); const [status, setStatus] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 0dfbb992..d7597f38 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -36,6 +36,7 @@ interface RoleAssignmentItem { role: UserRole; resource_type: 'stack' | 'node'; resource_id: string; + node_id: number | null; created_at: number; } @@ -209,6 +210,11 @@ export function UsersSection() { setFormRole('viewer'); setEditingUser(null); setShowForm(false); + setRoleAssignments([]); + setScopeResourceType('stack'); + setScopeNodeId(''); + setScopeResourceId(''); + setAvailableStacks([]); }; const handleSave = async () => { @@ -313,16 +319,18 @@ export function UsersSection() { setFormConfirmPassword(''); setShowForm(true); fetchRoleAssignments(u.id); - fetchScopeResources(); + void fetchAvailableNodes(); }; // --- Scoped Role Assignments --- const [roleAssignments, setRoleAssignments] = useState([]); const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); + const [scopeNodeId, setScopeNodeId] = useState(''); const [scopeResourceId, setScopeResourceId] = useState(''); const [scopeRole, setScopeRole] = useState('deployer'); const [availableStacks, setAvailableStacks] = useState([]); const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]); + const [loadingStacks, setLoadingStacks] = useState(false); const [addingScope, setAddingScope] = useState(false); const fetchRoleAssignments = async (userId: number) => { @@ -333,16 +341,9 @@ export function UsersSection() { } catch { setRoleAssignments([]); } }; - const fetchScopeResources = async () => { + const fetchAvailableNodes = async () => { try { - const [stacksRes, nodesRes] = await Promise.all([ - apiFetch('/stacks', { localOnly: true }), - apiFetch('/nodes', { localOnly: true }), - ]); - if (stacksRes.ok) { - const data = await stacksRes.json(); - setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); - } + const nodesRes = await apiFetch('/nodes', { localOnly: true }); if (nodesRes.ok) { const data = await nodesRes.json(); setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []); @@ -350,14 +351,51 @@ export function UsersSection() { } catch { /* ignore */ } }; + const fetchStacksForNode = async (nodeIdStr: string) => { + if (!nodeIdStr) { + setAvailableStacks([]); + return; + } + const nodeId = parseInt(nodeIdStr, 10); + if (!Number.isInteger(nodeId)) { + setAvailableStacks([]); + return; + } + setLoadingStacks(true); + try { + const stacksRes = await apiFetch('/stacks', { nodeId }); + if (stacksRes.ok) { + const data = await stacksRes.json(); + setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); + } else { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } + } catch { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } finally { + setLoadingStacks(false); + } + }; + const addRoleAssignment = async () => { if (!editingUser || !scopeResourceId) return; + if (scopeResourceType === 'stack' && !scopeNodeId) return; setAddingScope(true); try { + const body: Record = { + role: scopeRole, + resource_type: scopeResourceType, + resource_id: scopeResourceId, + }; + if (scopeResourceType === 'stack') { + body.node_id = parseInt(scopeNodeId, 10); + } const res = await apiFetch(`/users/${editingUser.id}/roles`, { method: 'POST', localOnly: true, - body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }), + body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json(); @@ -476,21 +514,29 @@ export function UsersSection() { {roleAssignments.length > 0 && (
- {roleAssignments.map((a) => ( + {roleAssignments.map((a) => { + const nodeLabel = a.resource_type === 'stack' && a.node_id != null + ? (availableNodes.find((n) => n.id === a.node_id)?.name ?? `node ${a.node_id}`) + : null; + return (
{a.role} on {a.resource_type}: {a.resource_id} + {nodeLabel != null && ( + @ {nodeLabel} + )}
- ))} + ); + })}
)} -
+
{ setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }} + onValueChange={(v) => { + setScopeResourceType(v as 'stack' | 'node'); + setScopeResourceId(''); + setScopeNodeId(''); + setAvailableStacks([]); + void fetchAvailableNodes(); + }} placeholder="Type..." className="h-8 text-xs w-[100px]" />
-
- + {scopeResourceType === 'stack' && ( +
+ + ({ value: String(n.id), label: n.name }))} + value={scopeNodeId} + onValueChange={(v) => { + setScopeNodeId(v); + setScopeResourceId(''); + void fetchStacksForNode(v); + }} + placeholder="Select node..." + className="h-8 text-xs w-[140px]" + /> +
+ )} +
+ ({ value: s, label: s })) @@ -527,11 +595,25 @@ export function UsersSection() { } value={scopeResourceId} onValueChange={setScopeResourceId} - placeholder="Select..." + placeholder={ + scopeResourceType === 'stack' + ? (loadingStacks ? 'Loading stacks...' : (!scopeNodeId ? 'Select a node first...' : 'Select stack...')) + : 'Select...' + } className="h-8 text-xs" + disabled={scopeResourceType === 'stack' && (!scopeNodeId || loadingStacks)} />
- diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index f6148812..cf3dc1a7 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -43,7 +43,8 @@ interface WebhookExecution { } export function WebhooksSection() { - const { isAdmin } = useAuth(); + const { can } = useAuth(); + const canManageWebhooks = can('system:webhooks'); const { activeNode, nodes } = useNodes(); const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); @@ -74,7 +75,7 @@ export function WebhooksSection() { }; useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]); - useEffect(() => { if (!isAdmin) setShowForm(false); }, [isAdmin]); + useEffect(() => { if (!canManageWebhooks) setShowForm(false); }, [canManageWebhooks]); const enabledCount = webhooks.filter(w => w.enabled).length; useMastheadStats( @@ -160,7 +161,7 @@ export function WebhooksSection() { return (
- {isAdmin && ( + {canManageWebhooks && (
setShowForm(!showForm)}> Create webhook @@ -168,7 +169,7 @@ export function WebhooksSection() {
)} - {isAdmin && showForm && ( + {canManageWebhooks && showForm && ( setFormName(e.target.value)} /> @@ -243,9 +244,9 @@ export function WebhooksSection() { } title="No webhooks yet" - subtitle={isAdmin + subtitle={canManageWebhooks ? 'Create one to trigger stack actions from CI/CD.' - : 'An admin operator can create webhooks for this instance.'} + : 'An operator with webhook permission can create webhooks for this instance.'} /> )} @@ -274,7 +275,7 @@ export function WebhooksSection() {
- {isAdmin ? ( + {canManageWebhooks ? ( <> handleToggle(wh.id!, c)} />
@@ -250,7 +257,7 @@ function CompactBody(props: BodyChrome) { const { name, isLocal, chip, meta, nodeState, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics, - footerContext, onAddStack, onRetry, canManage, + footerContext, onAddStack, onRetry, canManage, canManageMembership, } = props; return ( @@ -314,6 +321,7 @@ function CompactBody(props: BodyChrome) { onRetry={onRetry} onToggleEnabled={onToggleEnabled} canManage={canManage} + canManageMembership={canManageMembership} /> ); @@ -478,14 +486,15 @@ interface EmptyStateProps { onRetry?: () => void; onToggleEnabled: (next: boolean) => void; canManage: boolean; + canManageMembership: boolean; } -function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage }: EmptyStateProps) { +function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: EmptyStateProps) { const { headline, sub, cta } = emptyStateCopy(nodeState, name, offlineReason); - // The idle and meshed CTAs (enable mesh, add stack) are management actions - // the backend gates on the admin role, so a non-admin viewer sees a hint - // instead. The degraded/offline retry is a read-only refresh and stays. + // Enabling a node uses node:manage. Adding a stack starts an Admin-only + // mesh membership cascade. The degraded/offline retry stays available. const isManagementState = nodeState === 'idle' || nodeState === 'meshed'; + const canManageState = nodeState === 'meshed' ? canManageMembership : canManage; // `connecting` is transient while the mesh bridge dials; show the headline // only, with no retry button (it clears on its own once the bridge is up). const isConnecting = nodeState === 'connecting'; @@ -507,7 +516,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog ); if (isConnecting) action = null; - else if (!canManage && isManagementState) { + else if (!canManageState && isManagementState) { action = (
Managing the mesh requires an administrator. @@ -546,13 +555,12 @@ function ctaToneFor(state: RoutingNodeState): string { return CTA_TONE[state]; } -// Management CTAs (enable mesh / add stack) need admin; the read-only retry on a -// degraded/offline node stays available to everyone. `meshed` always offers "add -// stack" to admins so a node with aliases is never a dead end, and the transient -// `connecting` state shows no CTA. -function shouldShowCta(state: RoutingNodeState, canManage: boolean): boolean { +// Enabling mesh uses node management, while adding a stack starts an Admin-only +// membership cascade. Read-only retry stays available to everyone. +function shouldShowCta(state: RoutingNodeState, canManage: boolean, canManageMembership: boolean): boolean { if (state === 'connecting') return false; - if (state === 'idle' || state === 'meshed') return canManage; + if (state === 'idle') return canManage; + if (state === 'meshed') return canManageMembership; return true; } @@ -615,10 +623,11 @@ interface CompactFooterProps { onRetry?: () => void; onToggleEnabled: (next: boolean) => void; canManage: boolean; + canManageMembership: boolean; } -function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) { - const showCta = shouldShowCta(nodeState, canManage); +function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: CompactFooterProps) { + const showCta = shouldShowCta(nodeState, canManage, canManageMembership); const { cta } = emptyStateCopy(nodeState, name); const handleClick = () => { if (nodeState === 'idle') onToggleEnabled(true); diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx new file mode 100644 index 00000000..46a5c15b --- /dev/null +++ b/frontend/src/context/AuthContext.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AuthProvider, useAuth } from './AuthContext'; + +const authenticated = { user: { username: 'operator', role: 'admin' } }; +const permissionData = { + globalRole: 'viewer', + globalPermissions: ['stack:read'], + scopedPermissions: {}, +}; + +function mockFetch(...responses: Array>) { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ needsSetup: false }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(authenticated), { status: 200 })) + .mockImplementationOnce(() => responses.shift())); +} + +describe('AuthContext permission metadata', () => { + it('keeps authorization unavailable until permissions load', async () => { + let resolvePermissions: (response: Response) => void; + const pendingPermissions = new Promise((resolve) => { resolvePermissions = resolve; }); + mockFetch(pendingPermissions); + + const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); + + await waitFor(() => expect(result.current.appStatus).toBe('authenticated')); + expect(result.current.permissionsStatus).toBe('loading'); + expect(result.current.isAdmin).toBe(false); + expect(result.current.can('stack:read')).toBe(false); + + await act(async () => resolvePermissions!(new Response(JSON.stringify(permissionData), { status: 200 }))); + + await waitFor(() => expect(result.current.permissionsStatus).toBe('ready')); + expect(result.current.can('stack:read')).toBe(true); + expect(result.current.isAdmin).toBe(false); + }); + + it('fails closed and recovers after a retry', async () => { + mockFetch(new Response(null, { status: 503 })); + const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); + + await waitFor(() => expect(result.current.permissionsStatus).toBe('error')); + expect(result.current.can('stack:read')).toBe(false); + expect(result.current.isAdmin).toBe(false); + + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(permissionData), { status: 200 })); + await act(async () => result.current.retryPermissions()); + + expect(result.current.permissionsStatus).toBe('ready'); + expect(result.current.can('stack:read')).toBe(true); + }); +}); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 08ea7f46..a5ff9a88 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ -import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'; import { markMilestone } from '@/lib/hydrationTiming'; +import { resolveCan } from '@/lib/resolveCan'; type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated'; @@ -33,7 +34,8 @@ interface AuthContextType { permissions: PermissionsData | null; permissionsStatus: PermissionsStatus; permissionsReady: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; + retryPermissions: () => Promise; login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>; @@ -50,12 +52,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [permissions, setPermissions] = useState(null); const [permissionsStatus, setPermissionsStatus] = useState('loading'); + const permissionRequestRef = useRef(0); const resetPermissions = useCallback(() => { + permissionRequestRef.current += 1; setPermissions(null); setPermissionsStatus('loading'); }, []); + const loadPermissions = useCallback(async () => { + const requestId = ++permissionRequestRef.current; + setPermissions(null); + setPermissionsStatus('loading'); + + try { + const response = await fetch('/api/permissions/me', { credentials: 'include' }); + if (!response.ok) { + console.error('[Auth] Permission metadata request failed:', response.status); + if (requestId === permissionRequestRef.current) setPermissionsStatus('error'); + return; + } + const data = await response.json(); + if (requestId !== permissionRequestRef.current) return; + setPermissions(data); + setPermissionsStatus('ready'); + } catch (error) { + console.error('[Auth] Permission metadata request failed:', error); + if (requestId === permissionRequestRef.current) setPermissionsStatus('error'); + } + }, []); + const checkAuth = async () => { resetPermissions(); try { @@ -78,26 +104,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { return; } - const authPromise = fetch('/api/auth/check', { credentials: 'include' }); - const permsPromise = fetch('/api/permissions/me', { credentials: 'include' }); - - const authResponse = await authPromise; + const authResponse = await fetch('/api/auth/check', { credentials: 'include' }); if (authResponse.ok) { const data = await authResponse.json(); setUser(data.user ?? null); setAppStatus('authenticated'); - - try { - const res = await permsPromise; - if (res.ok) { - setPermissions(await res.json()); - setPermissionsStatus('ready'); - } else { - setPermissionsStatus('error'); - } - } catch { - setPermissionsStatus('error'); - } + await loadPermissions(); } else { setUser(null); resetPermissions(); @@ -128,20 +140,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized); }, []); - const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => { - if (!permissions) return false; - - if (permissions.globalRole === 'admin') return true; - - if (permissions.globalPermissions.includes(action)) return true; - - if (resourceType && resourceId) { - const key = `${resourceType}:${resourceId}`; - return permissions.scopedPermissions[key]?.includes(action) ?? false; - } - - return false; - }, [permissions]); + const can = useCallback(( + action: PermissionAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, + ): boolean => { + if (permissionsStatus !== 'ready' || !permissions) return false; + return resolveCan(permissions, action, resourceType, resourceId, nodeId); + }, [permissions, permissionsStatus]); const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => { try { @@ -259,11 +266,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: appStatus === 'authenticated', needsSetup: appStatus === 'needsSetup', user, - isAdmin: user?.role === 'admin', + isAdmin: permissionsStatus === 'ready' && permissions?.globalRole === 'admin', permissions, permissionsStatus, - permissionsReady: permissionsStatus !== 'loading', + permissionsReady: permissionsStatus === 'ready', can, + retryPermissions: loadPermissions, login, ssoLdapLogin, submitMfa, diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 80397e45..2063c379 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -22,10 +22,19 @@ export interface Node { pilot_agent_version?: string | null; } +export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown'; + export interface NodeMeta { version: string | null; capabilities: string[]; fetchedAt: number; + /** Remote-only fields below; absent (undefined) for local nodes' /meta response. */ + startedAt?: number | null; + updateError?: string | null; + online?: boolean; + imagePinKind?: ImagePinKind | null; + updateBlocked?: boolean; + imageChannel?: 'community' | 'hardened' | 'unknown' | null; } interface NodeContextType { @@ -95,6 +104,12 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { version: data.version ?? null, capabilities: Array.isArray(data.capabilities) ? data.capabilities : [], fetchedAt: Date.now(), + startedAt: data.startedAt ?? null, + updateError: data.updateError ?? null, + online: data.online, + imagePinKind: data.imagePinKind ?? null, + updateBlocked: data.updateBlocked, + imageChannel: data.imageChannel ?? null, }); } else { // A non-OK response (proxy error, auth, 5xx) is a resolved failure: record an diff --git a/frontend/src/hooks/__tests__/useStackKeyboardShortcuts.test.ts b/frontend/src/hooks/__tests__/useStackKeyboardShortcuts.test.ts new file mode 100644 index 00000000..8ece08b7 --- /dev/null +++ b/frontend/src/hooks/__tests__/useStackKeyboardShortcuts.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useStackKeyboardShortcuts } from '../useStackKeyboardShortcuts'; +import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; + +function makeCtx(overrides: Partial = {}): StackMenuCtx { + return { + stackStatus: 'running', + isSelfStack: false, + canOpenApp: true, + isBusy: false, + isAdmin: true, + canDelete: true, + canDeploy: true, + canEditLabels: true, + canCreateLabels: true, + isPinned: false, + labels: [], + assignedLabelIds: [], + menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true }, + openAlertSheet: vi.fn(), + openAutoHeal: vi.fn(), + canViewMonitor: true, + checkUpdates: vi.fn(), + canCheckUpdates: true, + openStackApp: vi.fn(), + deploy: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + update: vi.fn(), + takeDown: vi.fn(), + remove: vi.fn(), + pin: vi.fn(), + unpin: vi.fn(), + toggleLabel: vi.fn(), + createAndAssignLabel: vi.fn(), + openLabelManager: vi.fn(), + openScheduleTask: vi.fn(), + canMuteNotifications: false, + muteStackAll: vi.fn(), + muteStackDeploySuccess: vi.fn(), + muteStackMonitor: vi.fn(), + openStackMuteRules: vi.fn(), + muteLabelAll: vi.fn(), + muteLabelExternal: vi.fn(), + muteLabelLowPriority: vi.fn(), + openLabelMuteRules: vi.fn(), + ...overrides, + }; +} + +function pressKey(key: string) { + window.dispatchEvent(new KeyboardEvent('keydown', { key })); +} + +describe('useStackKeyboardShortcuts monitor gating', () => { + it('opens the Alerts sheet on "a" when canViewMonitor', () => { + const ctx = makeCtx({ canViewMonitor: true }); + renderHook(() => useStackKeyboardShortcuts('web.yml', () => ctx)); + pressKey('a'); + expect(ctx.openAlertSheet).toHaveBeenCalled(); + }); + + it('ignores "a" when !canViewMonitor', () => { + const ctx = makeCtx({ canViewMonitor: false }); + renderHook(() => useStackKeyboardShortcuts('web.yml', () => ctx)); + pressKey('a'); + expect(ctx.openAlertSheet).not.toHaveBeenCalled(); + }); + + it('opens Auto-Heal on "h" when canViewMonitor', () => { + const ctx = makeCtx({ canViewMonitor: true }); + renderHook(() => useStackKeyboardShortcuts('web.yml', () => ctx)); + pressKey('h'); + expect(ctx.openAutoHeal).toHaveBeenCalled(); + }); + + it('ignores "h" when !canViewMonitor', () => { + const ctx = makeCtx({ canViewMonitor: false }); + renderHook(() => useStackKeyboardShortcuts('web.yml', () => ctx)); + pressKey('h'); + expect(ctx.openAutoHeal).not.toHaveBeenCalled(); + }); + + it('still gates "u" (check updates) on canCheckUpdates', () => { + const ctx = makeCtx({ canCheckUpdates: false }); + renderHook(() => useStackKeyboardShortcuts('web.yml', () => ctx)); + pressKey('u'); + expect(ctx.checkUpdates).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index a9e3bf58..d6f6ca7e 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -21,7 +21,9 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true }, openAlertSheet: vi.fn(), openAutoHeal: vi.fn(), + canViewMonitor: true, checkUpdates: vi.fn(), + canCheckUpdates: true, openStackApp: vi.fn(), deploy: vi.fn(), stop: vi.fn(), @@ -54,18 +56,25 @@ describe('useStackMenuItems', () => { expect(result.current.map(g => g.id)).toEqual(['inspect', 'organize', 'lifecycle', 'destructive']); }); - it('always includes Alerts in Inspect', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx())); + it('includes Alerts in Inspect when canViewMonitor', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canViewMonitor: true }))); const inspect = result.current.find(g => g.id === 'inspect')!; expect(inspect.items.some(i => i.icon === BellRing)).toBe(true); }); - it('always includes Auto-Heal in Inspect', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx())); + it('includes Auto-Heal in Inspect when canViewMonitor', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canViewMonitor: true }))); const inspect = result.current.find(g => g.id === 'inspect')!; expect(inspect.items.find(i => i.id === 'auto-heal')).toBeDefined(); }); + it('hides Alerts and Auto-Heal in Inspect when !canViewMonitor', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canViewMonitor: false }))); + const inspect = result.current.find(g => g.id === 'inspect')!; + expect(inspect.items.find(i => i.id === 'alerts')).toBeUndefined(); + expect(inspect.items.find(i => i.id === 'auto-heal')).toBeUndefined(); + }); + it('shows Open App when running and canOpenApp', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx())); const inspect = result.current.find(g => g.id === 'inspect')!; @@ -84,6 +93,18 @@ describe('useStackMenuItems', () => { expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined(); }); + it('shows Check updates when canCheckUpdates', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: true }))); + const inspect = result.current.find(g => g.id === 'inspect')!; + expect(inspect.items.find(i => i.id === 'check-updates')).toBeDefined(); + }); + + it('hides Check updates when !canCheckUpdates', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: false }))); + const inspect = result.current.find(g => g.id === 'inspect')!; + expect(inspect.items.find(i => i.id === 'check-updates')).toBeUndefined(); + }); + it('toggles Pin / Unpin label based on isPinned', () => { const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true }))); const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false }))); @@ -117,12 +138,18 @@ describe('useStackMenuItems', () => { expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); - it('hides Schedule task when not admin', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false }))); + it('hides Schedule task when canDeploy is false', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canDeploy: false }))); const lifecycle = result.current.find(g => g.id === 'lifecycle'); expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); }); + it('shows Schedule task when canDeploy is true even when not admin', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false, canDeploy: true }))); + const lifecycle = result.current.find(g => g.id === 'lifecycle'); + expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeTruthy(); + }); + it('includes Mute submenu in Inspect when canMuteNotifications', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true }))); const inspect = result.current.find(g => g.id === 'inspect')!; @@ -190,11 +217,8 @@ describe('useStackMenuItems', () => { canDeploy: false, menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true }, }))); - const lifecycle = result.current.find(g => g.id === 'lifecycle')!; - const ids = lifecycle.items.map(i => i.id); - expect(ids).not.toContain('deploy'); - expect(ids).not.toContain('take-down'); - expect(ids).toEqual(['schedule']); + // With canDeploy false, the entire lifecycle group is empty and omitted. + expect(result.current.find(g => g.id === 'lifecycle')).toBeUndefined(); }); it('disables take down for the self stack', () => { diff --git a/frontend/src/hooks/useStackKeyboardShortcuts.ts b/frontend/src/hooks/useStackKeyboardShortcuts.ts index c70ca6f5..2f828431 100644 --- a/frontend/src/hooks/useStackKeyboardShortcuts.ts +++ b/frontend/src/hooks/useStackKeyboardShortcuts.ts @@ -54,12 +54,15 @@ export function useStackKeyboardShortcuts( } if (key === 'a') { + if (!ctx.canViewMonitor) return; e.preventDefault(); ctx.openAlertSheet(); } else if (key === 'h') { + if (!ctx.canViewMonitor) return; e.preventDefault(); ctx.openAutoHeal(); } else if (key === 'u') { + if (!ctx.canCheckUpdates) return; e.preventDefault(); ctx.checkUpdates(); } else if (key === 'p') { diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index aebc999e..07bf312b 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -20,8 +20,8 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] { const { - stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels, - openAlertSheet, openAutoHeal, checkUpdates, openStackApp, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, + openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules, @@ -31,11 +31,14 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return useMemo(() => { const groups: MenuGroup[] = []; - const inspect: MenuItem[] = [ - { id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet }, - { id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal }, - ]; - inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates }); + const inspect: MenuItem[] = []; + if (canViewMonitor) { + inspect.push({ id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet }); + inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal }); + } + if (canCheckUpdates) { + inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates }); + } if (stackStatus === 'running' && canOpenApp) { inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp }); } @@ -86,7 +89,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack }); } - if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); + if (canDeploy) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -106,9 +109,9 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return groups; }, [ - stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, showTakeDown, - openAlertSheet, openAutoHeal, checkUpdates, openStackApp, + openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask, canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules, ]); diff --git a/frontend/src/lib/__tests__/resolveCan.test.ts b/frontend/src/lib/__tests__/resolveCan.test.ts new file mode 100644 index 00000000..6b177746 --- /dev/null +++ b/frontend/src/lib/__tests__/resolveCan.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCan, type PermissionsSnapshot } from '../resolveCan'; + +const viewerBase: PermissionsSnapshot = { + globalRole: 'viewer', + globalPermissions: ['stack:read', 'node:read'], + scopedPermissions: {}, +}; + +describe('resolveCan', () => { + it('admin bypasses all checks', () => { + const perms: PermissionsSnapshot = { + globalRole: 'admin', + globalPermissions: [], + scopedPermissions: {}, + }; + expect(resolveCan(perms, 'system:users')).toBe(true); + expect(resolveCan(perms, 'stack:delete', 'stack', 'app', 1)).toBe(true); + }); + + it('grants from the global matrix without needing a resource', () => { + expect(resolveCan(viewerBase, 'stack:read')).toBe(true); + expect(resolveCan(viewerBase, 'stack:deploy')).toBe(false); + }); + + it('treats same stack name on different nodes as independent grants', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:read', 'stack:deploy'], + 'stack:2:frontend': ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 1)).toBe(false); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 2)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 2)).toBe(true); + }); + + it('fails closed for stack lookups when nodeId is missing', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:deploy'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend')).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', null)).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + }); + + it('keeps node scopes keyed as node:id without a nodeId argument', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'node:manage', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'node:manage', 'node', '8')).toBe(false); + }); + + it('node-scoped grants authorize stack actions on that node only', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 7)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'other', 7)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 8)).toBe(false); + }); + + it('returns false when permissions are null', () => { + expect(resolveCan(null, 'stack:read')).toBe(false); + }); +}); diff --git a/frontend/src/lib/canManageNode.ts b/frontend/src/lib/canManageNode.ts new file mode 100644 index 00000000..098fcb4b --- /dev/null +++ b/frontend/src/lib/canManageNode.ts @@ -0,0 +1,19 @@ +import type { PermissionAction } from '@/context/AuthContext'; + +type CanFn = ( + action: PermissionAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, +) => boolean; + +/** + * Resolve node:manage for the active node so scoped Node Admin grants apply. + * When nodeId is missing, falls back to the unscoped check (same as Auth.can()). + */ +export function canManageNode(can: CanFn, nodeId: number | null | undefined): boolean { + if (nodeId != null) { + return can('node:manage', 'node', String(nodeId), nodeId); + } + return can('node:manage'); +} diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 97c5f20d..87bcb9c9 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -40,6 +40,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; export type Capability = (typeof CAPABILITIES)[number]; @@ -54,3 +55,4 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability; export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability; export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = 'scoped-stack-auth-evidence' as const satisfies Capability; diff --git a/frontend/src/lib/monacoLoader.tsx b/frontend/src/lib/monacoLoader.tsx index 72ac30a4..f1708dcc 100644 --- a/frontend/src/lib/monacoLoader.tsx +++ b/frontend/src/lib/monacoLoader.tsx @@ -26,7 +26,7 @@ function setupMonaco(): Promise { const [monacoMod, reactMonaco, editorWorkerMod] = await Promise.all([ import('monaco-editor'), import('@monaco-editor/react'), - import('monaco-editor/esm/vs/editor/editor.worker?worker'), + import('monaco-editor/editor/editor.worker?worker'), ]); window.MonacoEnvironment = { getWorker(): Worker { diff --git a/frontend/src/lib/navigation/buildNavigationModel.test.ts b/frontend/src/lib/navigation/buildNavigationModel.test.ts index f1a24944..3bc76af7 100644 --- a/frontend/src/lib/navigation/buildNavigationModel.test.ts +++ b/frontend/src/lib/navigation/buildNavigationModel.test.ts @@ -14,6 +14,7 @@ function makeCtx(overrides: Partial = {}): ReachabilityCont licenseStatus: 'ready', experimental: true, experimentalReady: true, + scheduledOpsAccessible: true, ...overrides, }; } @@ -78,30 +79,34 @@ describe('buildNavigationModel', () => { }); it('includes Console for system:console regardless of experimental discovery', () => { - expect( - buildNavigationModel(makeCtx({ - experimentalReady: true, - experimental: false, - isPaid: false, - can: (a) => a === 'system:console' || a === 'node:read', - })) - .allPageItems.map((i) => i.value), - ).toContain('host-console'); - expect( - buildNavigationModel(makeCtx({ - experimentalReady: false, - experimental: false, - can: (a) => a === 'system:console' || a === 'node:read', - })) - .allPageItems.map((i) => i.value), - ).toContain('host-console'); + const canConsole = (a: string) => a === 'system:console' || a === 'node:read'; + for (const experimentalReady of [true, false]) { + const values = buildNavigationModel( + makeCtx({ experimentalReady, experimental: false, isPaid: false, can: canConsole }), + ).allPageItems.map((i) => i.value); + expect(values).toContain('host-console'); + } + }); + + it('includes Audit for system:audit on Community', () => { + const values = buildNavigationModel( + makeCtx({ isPaid: false, can: (a) => a === 'system:audit' || a === 'node:read' }), + ).allPageItems.map((i) => i.value); + expect(values).toContain('audit-log'); + }); + + it('omits Audit without system:audit', () => { + const values = buildNavigationModel( + makeCtx({ isPaid: true, can: (a) => a === 'node:read' }), + ).allPageItems.map((i) => i.value); + expect(values).not.toContain('audit-log'); }); it('omits Console without system:console', () => { - expect( - buildNavigationModel(makeCtx({ can: () => false, isAdmin: false })) - .allPageItems.map((i) => i.value), - ).not.toContain('host-console'); + const values = buildNavigationModel( + makeCtx({ can: () => false, isAdmin: false }), + ).allPageItems.map((i) => i.value); + expect(values).not.toContain('host-console'); }); it('excludes hidden views from quick-link candidates', () => { diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts index 6e4951b2..e615163d 100644 --- a/frontend/src/lib/notificationCategories.ts +++ b/frontend/src/lib/notificationCategories.ts @@ -17,6 +17,7 @@ export const CATEGORY_LABELS: Record = { update_started: 'Update started', health_gate_passed: 'Health gate passed', health_gate_failed: 'Health gate failed', + rollback_generation_released: 'Rollback protection released', node_update_available: 'Node update', system: 'System', }; diff --git a/frontend/src/lib/prunePlan.ts b/frontend/src/lib/prunePlan.ts new file mode 100644 index 00000000..2b1f7421 --- /dev/null +++ b/frontend/src/lib/prunePlan.ts @@ -0,0 +1,135 @@ +export type PruneTarget = 'containers' | 'images' | 'volumes' | 'networks'; +export type FleetPruneTarget = Exclude; +export type PruneScope = 'managed' | 'all'; + +interface PrunePlanItemBase { + id: string; + name: string; + sizeBytes?: number; + managed: boolean; + reason: string; + stackName?: string; +} + +export type PrunePlanItem = + | (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never }) + | (PrunePlanItemBase & { + target: 'images'; + image: { + references: string[]; + digest?: string; + createdAt?: number; + }; + volume?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'volumes'; + volume: { + driver?: string; + ownershipLabels?: Record; + }; + image?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'networks'; + network: { + driver?: string; + scope?: string; + ownershipLabels?: Record; + }; + image?: never; + volume?: never; + }); + +export interface PrunePlan { + scope: PruneScope; + targets: PruneTarget[]; + items: PrunePlanItem[]; + reclaimableBytes: number; + fingerprint: string; + createdAt: number; + nodeId: number; +} + +export type PruneItemOutcome = + | { id: string; target: PruneTarget; status: 'removed'; sizeBytes?: number } + | { id: string; target: PruneTarget; status: 'skipped'; reason: string } + | { id: string; target: PruneTarget; status: 'failed'; error: string }; + +export interface FleetPruneTargetResult { + target: FleetPruneTarget; + success: boolean; + reclaimedBytes: number; + dryRun: boolean; + removed?: number; + skipped?: number; + failed?: number; + error?: string; +} + +export interface FleetPruneNodeResult { + nodeId: number; + nodeName: string; + reachable: boolean; + code?: string; + error?: string; + fingerprint?: string; + items?: PrunePlanItem[]; + reclaimableBytes?: number; + reclaimedBytes?: number; + outcomes?: PruneItemOutcome[]; + targets: FleetPruneTargetResult[]; +} + +function finiteNonnegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function isPrunePlanItem(value: unknown): value is PrunePlanItem { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + if (typeof item.id !== 'string' || typeof item.name !== 'string' + || typeof item.managed !== 'boolean' || typeof item.reason !== 'string' + || (item.sizeBytes !== undefined && !finiteNonnegative(item.sizeBytes))) return false; + if (item.target === 'containers') return true; + if (item.target === 'images') { + const image = item.image as Record | undefined; + return Boolean(image && Array.isArray(image.references) && image.references.every((ref) => typeof ref === 'string')); + } + if (item.target === 'volumes') return Boolean(item.volume && typeof item.volume === 'object'); + if (item.target === 'networks') return Boolean(item.network && typeof item.network === 'object'); + return false; +} + +export function isPruneItemOutcome(value: unknown): value is PruneItemOutcome { + if (!value || typeof value !== 'object') return false; + const outcome = value as Record; + if (typeof outcome.id !== 'string' || typeof outcome.target !== 'string') return false; + if (outcome.status === 'removed') return outcome.sizeBytes === undefined || finiteNonnegative(outcome.sizeBytes); + if (outcome.status === 'skipped') return typeof outcome.reason === 'string'; + if (outcome.status === 'failed') return typeof outcome.error === 'string'; + return false; +} + +export function isPrunePlan(value: unknown): value is PrunePlan { + if (!value || typeof value !== 'object') return false; + const plan = value as Partial; + if ((plan.scope !== 'managed' && plan.scope !== 'all') || !Array.isArray(plan.targets) + || new Set(plan.targets).size !== plan.targets.length || !Array.isArray(plan.items) + || !finiteNonnegative(plan.reclaimableBytes) || typeof plan.fingerprint !== 'string' + || plan.fingerprint.length === 0 || !Number.isInteger(plan.nodeId) + || !finiteNonnegative(plan.createdAt)) return false; + const targets = new Set(plan.targets); + const itemKeys = new Set(); + let total = 0; + for (const item of plan.items) { + if (!isPrunePlanItem(item) || !targets.has(item.target)) return false; + const key = `${item.target}\0${item.id}`; + if (itemKeys.has(key)) return false; + itemKeys.add(key); + total += item.sizeBytes ?? 0; + } + return total === plan.reclaimableBytes; +} diff --git a/frontend/src/lib/resolveCan.ts b/frontend/src/lib/resolveCan.ts new file mode 100644 index 00000000..667a4d74 --- /dev/null +++ b/frontend/src/lib/resolveCan.ts @@ -0,0 +1,48 @@ +/** Mirrors AuthContext PermissionAction / UserRole for the pure resolver (no circular import). */ +export type ResolveCanRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + +export type ResolveCanAction = + | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete' + | 'node:read' | 'node:manage' + | 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks' + | 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries'; + +export interface PermissionsSnapshot { + globalRole: ResolveCanRole; + globalPermissions: ResolveCanAction[]; + scopedPermissions: Record; +} + +/** + * Pure permission resolver for AuthContext.can and unit tests. + * Stack scopes are keyed `stack:${nodeId}:${stackName}`; missing nodeId + * fails closed for stack lookups after the global matrix is checked. + * Node scopes stay `node:${id}` and also authorize that role's stack + * actions for every stack on the node (node-wide semantics). + */ +export function resolveCan( + permissions: PermissionsSnapshot | null, + action: ResolveCanAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, +): boolean { + if (!permissions) return false; + + if (permissions.globalRole === 'admin') return true; + + if (permissions.globalPermissions.includes(action)) return true; + + if (!resourceType || !resourceId) return false; + + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return false; + const stackKey = `stack:${nodeId}:${resourceId}`; + if (permissions.scopedPermissions[stackKey]?.includes(action)) return true; + const nodeKey = `node:${nodeId}`; + return permissions.scopedPermissions[nodeKey]?.includes(action) ?? false; + } + + const key = `${resourceType}:${resourceId}`; + return permissions.scopedPermissions[key]?.includes(action) ?? false; +} diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts index 11362710..951ea1e4 100644 --- a/frontend/src/lib/routing/reachability.test.ts +++ b/frontend/src/lib/routing/reachability.test.ts @@ -20,15 +20,25 @@ function ctx(over: Partial = {}): ReachabilityContext { licenseStatus: 'ready', experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, ...over, }; } describe('reachability', () => { - it('does not hide views while authz is loading', () => { - const loading = ctx({ permissionsStatus: 'loading' }); + it('does not hide views while authz is loading or failed', () => { + const loading = ctx({ + permissionsStatus: 'loading', + can: () => false, + isPaid: false, + }); expect(authzReady(loading)).toBe(false); expect(isViewHidden('audit-log', loading)).toBe(false); + + const failed = ctx({ permissionsStatus: 'error', can: () => false, isAdmin: false }); + expect(authzReady(failed)).toBe(false); + expect(isViewHidden('fleet', failed)).toBe(false); + expect(normalizeHiddenView('fleet', failed)).toBe('fleet'); }); it('hides hub-only views on remote nodes when ready', () => { @@ -44,47 +54,101 @@ describe('reachability', () => { expect(isViewHidden('scheduled-ops', viewer)).toBe(true); }); - it('hides fleet without node:read when ready', () => { - const noFleet = ctx({ can: () => false }); - expect(isViewHidden('fleet', noFleet)).toBe(true); + it('hides fleet and networking without node:read when ready', () => { + const noNodeRead = ctx({ can: () => false }); + expect(isViewHidden('fleet', noNodeRead)).toBe(true); + expect(isViewHidden('networking', noNodeRead)).toBe(true); }); - it('preserves host-console when authz is not ready', () => { + it('gates host-console on system:console only (any tier, any experimental state)', () => { const licenseError = ctx({ licenseStatus: 'error', can: (a) => a === 'system:console' }); expect(isViewHidden('host-console', licenseError)).toBe(false); - }); - it('hides host-console without system:console when ready', () => { const noConsole = ctx({ can: () => false, isPaid: false, experimental: false }); expect(isViewHidden('host-console', noConsole)).toBe(true); expect(normalizeHiddenView('host-console', noConsole)).toBe('dashboard'); - }); - it('keeps host-console for system:console regardless of tier or experimental', () => { const community = ctx({ isPaid: false, experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, can: (a) => a === 'system:console', }); expect(isViewHidden('host-console', community)).toBe(false); }); - it('hides routing and secrets fleet tabs only after experimentalReady when off', () => { + it('gates audit-log on system:audit only (Community and paid)', () => { + expect( + isViewHidden('audit-log', ctx({ isPaid: false, can: (a) => a === 'system:audit' })), + ).toBe(false); + + const noAuditCommunity = ctx({ isPaid: false, can: () => false }); + expect(isViewHidden('audit-log', noAuditCommunity)).toBe(true); + expect(normalizeHiddenView('audit-log', noAuditCommunity)).toBe('dashboard'); + + expect(isViewHidden('audit-log', ctx({ isPaid: true, can: () => false }))).toBe(true); + }); + + it('hides routing fleet tab only after experimentalReady when off; secrets always visible for admin', () => { const loading = ctx({ experimental: false, experimentalReady: false }); expect(isFleetTabHidden('routing', loading)).toBe(false); expect(isFleetTabHidden('secrets', loading)).toBe(false); const off = ctx({ experimental: false, experimentalReady: true }); expect(isFleetTabHidden('routing', off)).toBe(true); - expect(isFleetTabHidden('secrets', off)).toBe(true); + expect(isFleetTabHidden('secrets', off)).toBe(false); expect(isFleetTabHidden('deployments', off)).toBe(false); expect(isFleetTabHidden('federation', off)).toBe(false); expect(isFleetTabHidden('actions', off)).toBe(false); }); + it('hides secrets fleet tab for non-admin after authz ready', () => { + // Cold load: permissions not ready, don't hide yet (deep link survives) + const loading = ctx({ isAdmin: false, permissionsStatus: 'loading' }); + expect(isFleetTabHidden('secrets', loading)).toBe(false); + + // Permissions settled: non-admin deep link normalizes to overview + const ready = ctx({ isAdmin: false, permissionsStatus: 'ready' }); + expect(isFleetTabHidden('secrets', ready)).toBe(true); + }); + it('does not hide fleet-mesh settings for experimental off', () => { - const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true }); + const off = ctx({ experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, isAdmin: true }); expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false); }); + + it('defers settings permission hides until authz is ready', () => { + const loading = ctx({ + permissionsStatus: 'loading', + isAdmin: false, + can: () => false, + }); + expect(isSettingsSectionHidden('webhooks', loading)).toBe(false); + expect(isSettingsSectionHidden('license', loading)).toBe(false); + }); + + it('hides requiredPermission sections when the operator lacks the permission', () => { + const nodeAdmin = ctx({ + isAdmin: false, + can: (a) => a === 'node:read' || a === 'node:manage', + }); + expect(isSettingsSectionHidden('webhooks', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('license', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('users', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('api-tokens', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('registries', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('nodes', nodeAdmin)).toBe(false); + expect(isSettingsSectionHidden('host-alerts', nodeAdmin)).toBe(false); + expect(isSettingsSectionHidden('developer', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('data-retention', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('image-updates', nodeAdmin)).toBe(true); + }); + + it('hides adminOnly settings sections for non-admins', () => { + const nodeAdmin = ctx({ isAdmin: false, can: () => true }); + expect(isSettingsSectionHidden('sso', nodeAdmin)).toBe(true); + expect(isSettingsSectionHidden('recovery', nodeAdmin)).toBe(true); + }); }); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts index 0349e9f8..4b7c7b7c 100644 --- a/frontend/src/lib/routing/reachability.ts +++ b/frontend/src/lib/routing/reachability.ts @@ -1,6 +1,6 @@ import type { FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; -import { getSettingsItem } from '@/components/settings/registry'; +import { getSettingsItem, isItemVisible, isItemLocked } from '@/components/settings/registry'; import type { ActiveView } from '@/lib/router/routeTypes'; import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes'; @@ -19,6 +19,8 @@ export interface ReachabilityContext { experimental: boolean; /** True once /meta experimental has settled (success or fail-closed). */ experimentalReady: boolean; + /** Whether the user can reach the Scheduled Operations view (global or scoped grants). */ + scheduledOpsAccessible: boolean; } /** RBAC/tier gates apply only when permission and license metadata are ready. */ @@ -39,17 +41,17 @@ export function experimentalDiscoveryReady(ctx: ReachabilityContext): boolean { export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolean { if (!authzReady(ctx)) return false; if (ctx.isRemote && HUB_ONLY_VIEWS.has(view)) return true; - if (!ctx.isAdmin && view === 'global-observability') return true; - if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true; - if (!ctx.can('node:read') && view === 'fleet') return true; - if (view === 'host-console') { - return !ctx.can('system:console'); - } - if (!ctx.isPaid) { - if (view === 'audit-log') return true; - } else { - if (view === 'audit-log' && !ctx.can('system:audit')) return true; + if ( + !ctx.isAdmin && + (view === 'global-observability' || view === 'auto-updates') + ) { + return true; } + if (view === 'scheduled-ops' && !ctx.scheduledOpsAccessible) return true; + if (!ctx.can('node:read') && (view === 'fleet' || view === 'networking')) return true; + if (view === 'host-console') return !ctx.can('system:console'); + // Permission-driven on Community and Admiral (14-day window vs paid depth is in-view). + if (view === 'audit-log') return !ctx.can('system:audit'); return false; } @@ -63,8 +65,9 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean { if (!authzReady(ctx)) return false; if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true; + if (tab === 'secrets' && !ctx.isAdmin) return true; // Defer experimental hide until ready so deep links survive cold load. - if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) { + if (tab === 'routing' && experimentalDiscoveryReady(ctx) && !ctx.experimental) { return true; } return false; @@ -74,9 +77,14 @@ export function isSettingsSectionHidden(section: SectionId, ctx: ReachabilityCon if (!authzReady(ctx)) return false; const item = getSettingsItem(section); if (!item) return true; - if (ctx.isRemote && item.hiddenOnRemote) return true; - if (item.adminOnly && !ctx.isAdmin) return true; - if (item.tier === 'paid' && !ctx.isPaid) return true; + const visibility = { + isRemote: ctx.isRemote, + isAdmin: ctx.isAdmin, + isPaid: ctx.isPaid, + can: ctx.can, + }; + if (!isItemVisible(item, visibility)) return true; + if (isItemLocked(item, visibility)) return true; // fleet-mesh stays reachable: snapshot_documentation lives there even when // Mesh discovery is off. return false; diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts index 1252800d..2ef26328 100644 --- a/frontend/src/lib/scheduledActions.ts +++ b/frontend/src/lib/scheduledActions.ts @@ -1,4 +1,5 @@ import type { ScheduledTask } from '@/types/scheduling'; +import type { PermissionAction } from '@/context/AuthContext'; /** * Single source of truth for scheduled-operation action metadata on the @@ -86,6 +87,8 @@ export interface ScheduledActionDefinition { helperText: string; /** Risk level shown as a coloured chip next to the helper text. */ riskLevel: ScheduledActionRiskLevel; + /** Permission required to schedule this action (mirrors backend registry). */ + permission: PermissionAction; } /** Action pre-selected when the create modal opens. Decoupled from picker order. */ @@ -94,24 +97,24 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart'; /** Ordered for the create-flow action picker, grouped by category. */ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [ // Lifecycle - { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' }, - { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' }, - { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' }, - { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' }, - { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' }, - { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' }, - { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' }, - { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' }, + { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe', permission: 'stack:deploy' }, + { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers', permission: 'stack:deploy' }, + { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Updates - { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' }, - { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' }, - { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change' }, + { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, + { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Security - { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' }, + { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only', permission: 'node:manage' }, // Maintenance - { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' }, + { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive', permission: 'system:settings' }, // Backups - { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' }, + { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe', permission: 'node:manage' }, ]; const ACTION_BY_ID = new Map(SCHEDULED_ACTIONS.map(a => [a.id, a])); @@ -201,3 +204,93 @@ export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [ { key: 'maintenance', label: 'Upkeep', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' }, { key: 'backups', label: 'Backups', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)' }, ]; + +// ── Permission helpers ────────────────────────────────────────────────────── + +/** Permission actions that authorize any scheduleable action. */ +const SCHEDULABLE_ACTIONS: readonly PermissionAction[] = ['stack:deploy', 'node:manage', 'system:settings']; + +export interface ScheduleActionTarget { + nodeId?: number | null; + stackName?: string | null; + labelScope?: 'fleet' | 'node'; +} + +/** + * Check whether the user can schedule the given action on the given target. + * Scope resolution mirrors the backend `resolveTaskPermissionScope`: per-action, + * not per target-type bucket. + */ +export function canScheduleAction( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + target: ScheduleActionTarget, +): boolean { + // Stack lifecycle: scoped to (nodeId, stackName) + if (def.targetType === 'stack') { + return can(def.permission, 'stack', target.stackName ?? undefined, target.nodeId); + } + // Prune: always unscoped (admin-only via system:settings in the role matrix) + if (def.id === 'prune') { + return can(def.permission); + } + // Snapshot: unscoped (spans all nodes) + if (def.id === 'snapshot') { + return can(def.permission); + } + // Container targets + scan: node-scoped + if (def.targetType === 'container' || def.id === 'scan') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet update with specific node (non-label): node-scoped + if (def.id === 'update-fleet') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet-wide label update: unscoped when no node; node-scoped when node + if (def.id === 'update-by-label') { + if (target.labelScope === 'node' && target.nodeId != null) { + return can(def.permission, 'node', String(target.nodeId), target.nodeId); + } + return can(def.permission); + } + return can(def.permission); +} + +/** + * True when the user can schedule at least one action. Used to determine whether + * the Scheduled Operations view and its "New scheduled task" button should be + * reachable. + */ +export function canScheduleAny( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + // Global role check + if (can('stack:deploy') || can('node:manage') || can('system:settings')) return true; + // Scoped permissions check: any scoped grant covering a scheduleable action + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if (actions.some(a => (SCHEDULABLE_ACTIONS as readonly string[]).includes(a))) return true; + } + } + return false; +} + +/** + * True when the user can schedule this action on at least one possible target + * (global role or any scoped grant). Used to filter the action picker so + * actions the user can NEVER schedule are not shown. + */ +export function canScheduleActionAnywhere( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + if (can(def.permission)) return true; + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if ((actions as readonly string[]).includes(def.permission)) return true; + } + } + return false; +} diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index 3c81da69..244b7459 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -8,6 +8,8 @@ export interface ScheduledTask { cron_expression: string; enabled: number; created_by: string; + /** The user ID who created this schedule. Null for legacy rows (pre-RBAC). */ + creator_user_id?: number | null; created_at: number; updated_at: number; last_run_at: number | null; diff --git a/package-lock.json b/package-lock.json index 9ea38242..670b47a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.0.2", - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.0", "husky": "^9.1.7", "otplib": "^13.4.1" }, @@ -408,19 +408,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@scure/base": { @@ -597,6 +597,7 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -964,35 +965,35 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/require-from-string": { @@ -1092,8 +1093,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/wrap-ansi": { "version": "9.0.2", diff --git a/package.json b/package.json index bbd84111..c9cc75ab 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.0.2", - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.0", "husky": "^9.1.7", "otplib": "^13.4.1" }