mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
chore: merge main for audit fix
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Generated
+175
-176
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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!);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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[] }>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 action→permission 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,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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user