chore: merge main for audit fix

This commit is contained in:
SaelixCode
2026-08-03 16:50:27 -04:00
284 changed files with 16756 additions and 3254 deletions
+5
View File
@@ -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=
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
+2 -2
View File
@@ -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
+78
View File
@@ -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
+1 -1
View File
@@ -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
+175 -176
View File
@@ -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": {
+159 -12
View File
@@ -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<string> {
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 () => {
+93 -39
View File
@@ -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<void> {
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<string[]> {
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')
+96 -10
View File
@@ -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<void> {
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');
+13 -13
View File
@@ -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 () => {
@@ -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 () => {
@@ -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);
});
+28 -19
View File
@@ -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);
}
});
});
@@ -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> = {}): 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: '' }] })],
@@ -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(),
}),
@@ -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();
});
});
@@ -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 = {
@@ -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', () => {
@@ -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();
});
});
+131 -13
View File
@@ -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<number>((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<number>((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<number>((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<number>((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<number>((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<number>((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<boolean>((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<boolean>((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<number>((resolve) => {
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
@@ -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<UserRole, Persona>;
/** Minimal DB interface needed by seedPersonas — avoids InstanceType<T> 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<PersonaMap> = {};
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;
}
@@ -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();
}
});
});
+74 -1
View File
@@ -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
@@ -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<string, string>) ?? {};
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<typeof DockerController.getInstance>);
vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
const calls: Array<{ url: string; auth: string | undefined; body: Record<string, unknown> }> = [];
mockFetch((url, init) => {
const headers = (init?.headers as Record<string, string>) ?? {};
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
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)', () => {
@@ -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<unknown> = 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<typeof DockerController.getInstance>);
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')
+426 -183
View File
@@ -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> = {}): 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<Record<string, number>>; allBytes?: Partial<Record<string, number>>; 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<typeof DockerController.getInstance>);
// 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<string, number>([['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);
}
});
});
+47 -13
View File
@@ -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<string, string | NodeJS.ErrnoException>();
const stats = new Map<string, StatDescriptor | NodeJS.ErrnoException>();
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');
}
@@ -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 };
}
+32 -10
View File
@@ -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 () => {
+244 -4
View File
@@ -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<typeof adjustForArc> =>
({ 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;
});
@@ -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 () => {
@@ -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> = {}): 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',
);
});
});
@@ -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);
});
});
@@ -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')}`);
+382
View File
@@ -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<void>((resolve) => {
releaseRemoval = resolve;
});
const removeSpy = vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending);
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise<void> }, '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<void>((resolve) => {
releaseRemoval = resolve;
});
vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending);
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise<void> }, '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<void> },
'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<void> }, '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<unknown> }, '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<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'web', ports: [8080] }]);
vi.spyOn(svc as unknown as { proxyFetch: () => Promise<Response> }, '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<unknown> }, '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<string[]>((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<string[]>((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<string, unknown> }).pilotAliasOverlay.set('refresh-failure', []);
vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise<void> }, '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<void> }, '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);
});
});
+25
View File
@@ -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 ────────────────────────────────────────────────────────
@@ -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')),
@@ -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<string, PermissionAction[]> = {
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,
});
});
}
});
@@ -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<PermissionAction>;
};
}): 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<PermissionAction>(['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<PermissionAction>(['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<PermissionAction>(['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<PermissionAction>(['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<PermissionAction>(['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);
});
});
@@ -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);
}
});
});
}
});
@@ -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<typeof seedPersonas>;
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<void>((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<void>((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');
});
});
@@ -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);
});
}
});
@@ -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<number> {
await new Promise<void>((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<void>((resolve) => grantedServer.close(() => resolve()));
await new Promise<void>((resolve) => ungrantedServer.close(() => resolve()));
await new Promise<void>((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');
});
});
@@ -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<number> {
await new Promise<void>((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<void>((resolve) => grantedServer.close(() => resolve()));
await new Promise<void>((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);
});
});
@@ -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<number> {
await new Promise<void>((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<void>((resolve) => evidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => noEvidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => failDeleteServer.close(() => resolve()));
await new Promise<void>((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!);
});
});
+162
View File
@@ -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: ['<none>:<none>'],
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: '<none>:<none>',
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([
{
@@ -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);
});
});
@@ -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);
});
});
@@ -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<string, string> {
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);
});
});
@@ -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<typeof DockerController.getInstance>);
});
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> = {}): 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> = {}): StackUpdateRecoveryGenerationRow {
const row = makeRow(overrides);
DatabaseService.getInstance().insertStackUpdateRecoveryGeneration(row);
return row;
}
function insertHealthGate(overrides: Partial<HealthGateRunRow> = {}): 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);
});
});
@@ -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<typeof vi.spyOn>;
/**
* 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<typeof DatabaseService.getInstance>,
username: string,
assignmentRole: 'deployer' | 'node-admin',
resourceType: 'stack' | 'node',
resourceId: string,
nodeId?: number,
): Promise<string> {
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<typeof DatabaseService.getInstance>;
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');
});
});
@@ -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')
@@ -1867,6 +1867,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<S
cron_expression: '0 2 * * *',
enabled: 1,
created_by: 'admin',
creator_user_id: null,
created_at: 0,
updated_at: 0,
last_run_at: null,
@@ -0,0 +1,127 @@
/**
* Machine-auth scoped stack evidence: headers are trusted only under
* node_proxy / pilot_tunnel, and only when the name + actions pair is valid.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import {
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
PROXY_ROLE_HEADER,
} from '../services/license-headers';
import { checkPermission } from '../middleware/permissions';
let tmpDir: string;
let authMiddleware: typeof import('../middleware/auth').authMiddleware;
function runAuth(req: Partial<Request>): Promise<Request> {
return new Promise((resolve, reject) => {
const fullReq = Object.assign(
{ cookies: {} as Record<string, string>, headers: {} as Record<string, string | undefined> },
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();
});
});
+120 -29
View File
@@ -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 ----
@@ -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 () => {
@@ -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);
});
});
@@ -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 });
});
});
@@ -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<Record<UserRole, string>> = {};
async function seedAndLogin(role: UserRole): Promise<string> {
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');
});
});
@@ -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');
@@ -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);
@@ -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')
@@ -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/<name>/... 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',
]);
});
});
@@ -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 () => {
@@ -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<typeof DockerController.getInstance>);
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<void>((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<typeof DockerController.getInstance>);
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 () => {
@@ -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();
});
});
+145 -33
View File
@@ -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<string, unknown> = { 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');
});
});
@@ -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<AssertStackExistsResult> {
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<string, string> = {
[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' };
}
}
+3
View File
@@ -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<string>();
+597
View File
@@ -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<FleetPruneTarget>();
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<number>();
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<number>();
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<string, unknown>;
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<PrunePlan>;
const planTargets = Array.isArray(plan.targets) ? plan.targets : [];
const requestedTargets = new Set(targets);
const uniquePlanTargets = new Set(planTargets);
const itemKeys = new Set<string>();
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<string, unknown>;
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<string>(targets);
const expected = new Set(plan.items.map((item) => `${item.target}\0${item.id}`));
const seen = new Set<string>();
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<Preflight> {
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<Preflight> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!proxyTarget) return { node, reachable: false, error: formatNoTargetError(node) };
const headers: Record<string, string> = { '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<Preflight> {
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<FleetPruneNodeResult> {
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<FleetPruneNodeResult> {
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<string, string> = { '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<string, unknown> : 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<string>,
): Promise<FleetPruneResponse> {
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);
}
}
+203 -49
View File
@@ -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<ReturnType<typeof si.mem>>;
@@ -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<string>();
const loggedErrorCodes = new Set<string>();
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<string | undefined> {
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 (`<name> <type> <value>`). */
@@ -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 `<N> 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: <N> 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<number> {
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<number> {
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<MemData, 'total' | 'available'>, 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<HostMemory> {
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<HostMemory> {
const [mem, arcReclaimable, ballooned] = await Promise.all([
si.mem(),
readReclaimableArc(),
readBalloonedMemory(),
]);
return adjustForBalloon(adjustForArc(mem, arcReclaimable), ballooned);
}
+239
View File
@@ -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<string, PermissionAction>(
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/<name>/...` 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/<name>/... 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<PermissionAction>();
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<PermissionAction>): string {
return [...new Set(actions)].join(',');
}
+24 -1
View File
@@ -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;
}
+147 -4
View File
@@ -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<UserRole, PermissionAction[]> = {
],
};
/** 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<PermissionAction>,
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<PermissionAction>();
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;
}
+338 -3
View File
@@ -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<string, unknown>;
// 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<PermissionAction>();
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;
@@ -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.
+3 -3
View File
@@ -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<void> => {
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;
+19 -7
View File
@@ -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);
+4 -4
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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;
+10 -4
View File
@@ -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'));
+12 -8
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<vo
res.status(400).json({ error: 'nodeId must be a positive integer or null' });
return;
}
if (!requirePermission(req, res, 'node:manage', nodeId === null ? undefined : 'node', nodeId === null ? undefined : String(nodeId))) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
+1 -1
View File
@@ -2,8 +2,8 @@ import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { requireAdmin } from '../middleware/tierGates';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
export const containersRouter = Router();
+2
View File
@@ -1,5 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
import { buildLocalGraph } from '../services/DependencyGraphService';
@@ -11,6 +12,7 @@ export const dependencyMapRouter = Router();
* remotes. Served against the local Docker of whichever node handles it.
*/
dependencyMapRouter.get('/node-graph', authMiddleware, async (req: Request, res: Response): Promise<void> => {
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';
+51 -177
View File
@@ -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<FleetNodeOverview> {
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<void> => {
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<void> => {
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<void> => {
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<FleetPruneTarget>();
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<NodeResult> => {
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<string, string> = { '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<void> => {
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<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const summaries = await collectFleetLabelSummaries();
const agg = new Map<string, { nodeCount: number; stackCount: number; nodes: string[] }>();
+32 -8
View File
@@ -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<string>,
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<void> => {
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<void> => {
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);
+83 -13
View File
@@ -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<void> => {
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<void> => {
if (!requireAdmin(req, res)) return;
try {
const result = await CacheService.getInstance().getOrFetch<Record<number, Record<string, boolean>>>(
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<void> => {
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<void> => {
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<string>,
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<void> => {
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;
+19 -13
View File
@@ -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<string>();
// 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<void> => {
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<void> => {
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<void> => {
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 }[] = [];
+6 -5
View File
@@ -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<void> => {
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<voi
licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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.' });
+18 -5
View File
@@ -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<void> => {
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<void> =>
*/
meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> =>
meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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');
+4 -9
View File
@@ -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,
+7 -6
View File
@@ -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<void> => {
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<vo
});
networkingRouter.get('/overview', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/networks', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<v
});
networkingRouter.get('/findings', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/findings/:id', async (req: Request, res: Response): Promise<void> => {
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' });
+7 -3
View File
@@ -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) {
+5 -1
View File
@@ -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;
+3 -1
View File
@@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void
const scopedPermissions: Record<string, PermissionAction[]> = {};
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])];
+9 -8
View File
@@ -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<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;
@@ -248,7 +249,7 @@ registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<v
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<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;
@@ -267,7 +268,7 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<
registriesRouter.post('/test', async (req: Request, res: Response): Promise<void> => {
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;
+163 -35
View File
@@ -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<string | null> {
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 actionpermission mapping.
*/
function checkTaskPermission(
req: Request,
task: Pick<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'node_id' | 'selector_type'>,
): 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<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'node_id' | 'selector_type'>,
): 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<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'node_id' | 'selector_type'>,
): 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<void> => {
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<void> => {
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);
+1 -10
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
+19 -17
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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)) {
+89 -39
View File
@@ -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<string, PermissionAction> = {
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<PermissionAction>();
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<void> => {
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<void> => {
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) {

Some files were not shown because too many files have changed in this diff Show More