mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
42 Commits
4.13.1-bui
...
v4.18.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d89682a3f | ||
|
|
bc15bd3d70 | ||
|
|
7c3aee8f3f | ||
|
|
c7c3bb57ea | ||
|
|
99dbad57d5 | ||
|
|
c42f79d406 | ||
|
|
4d8588b173 | ||
|
|
0d1d27064e | ||
|
|
0fe2c2c1c8 | ||
|
|
a8e4119270 | ||
|
|
372a4ebb42 | ||
|
|
4e945f5f56 | ||
|
|
6356f9c41d | ||
|
|
a1ee915ca5 | ||
|
|
c147a6b507 | ||
|
|
9d42b36f74 | ||
|
|
26a95af953 | ||
|
|
0ead267838 | ||
|
|
163763f9e5 | ||
|
|
6469d002b7 | ||
|
|
ab11e7ff7f | ||
|
|
7316dc753f | ||
|
|
1bf74e9d6c | ||
|
|
9cd0d6ac65 | ||
|
|
f0348aa038 | ||
|
|
c1ab3a4746 | ||
|
|
7d67a40433 | ||
|
|
674323fd87 | ||
|
|
6947b5d4af | ||
|
|
c4cc54923c | ||
|
|
c508366702 | ||
|
|
9df6a3f5eb | ||
|
|
aa588883cc | ||
|
|
b2e7801238 | ||
|
|
fd895cacf0 | ||
|
|
6edd3a3d16 | ||
|
|
ac198d5d1a | ||
|
|
f1c043fe5f | ||
|
|
d0c66020e1 | ||
|
|
335f949b53 | ||
|
|
26aeca3624 | ||
|
|
2b4c2a264b |
2
.github/workflows/build-plugin.yml
vendored
2
.github/workflows/build-plugin.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
with:
|
||||
workflow: release-production.yml
|
||||
inputs: '{ "version": "${{ steps.vars.outputs.API_VERSION }}" }'
|
||||
token: ${{ secrets.WORKFLOW_TRIGGER_PAT }}
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Upload to Cloudflare
|
||||
if: inputs.RELEASE_CREATED == 'false'
|
||||
|
||||
34
.github/workflows/main.yml
vendored
34
.github/workflows/main.yml
vendored
@@ -117,42 +117,62 @@ jobs:
|
||||
# Verify libvirt is running using sudo to bypass group membership delays
|
||||
sudo virsh list --all || true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build UI Package First
|
||||
run: |
|
||||
echo "🔧 Building UI package for web tests dependency..."
|
||||
cd ../unraid-ui && pnpm run build
|
||||
|
||||
- name: Run Tests Concurrently
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Run all tests in parallel with labeled output
|
||||
# Run all tests in parallel with labeled output and coverage generation
|
||||
echo "🚀 Starting API coverage tests..."
|
||||
pnpm run coverage > api-test.log 2>&1 &
|
||||
API_PID=$!
|
||||
|
||||
echo "🚀 Starting Connect plugin tests..."
|
||||
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
|
||||
(cd ../packages/unraid-api-plugin-connect && pnpm test --coverage 2>/dev/null || pnpm test) > connect-test.log 2>&1 &
|
||||
CONNECT_PID=$!
|
||||
|
||||
echo "🚀 Starting Shared package tests..."
|
||||
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
|
||||
(cd ../packages/unraid-shared && pnpm test --coverage 2>/dev/null || pnpm test) > shared-test.log 2>&1 &
|
||||
SHARED_PID=$!
|
||||
|
||||
echo "🚀 Starting Web package coverage tests..."
|
||||
(cd ../web && (pnpm test --coverage || pnpm test)) > web-test.log 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
echo "🚀 Starting UI package coverage tests..."
|
||||
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
|
||||
UI_PID=$!
|
||||
|
||||
# Wait for all processes and capture exit codes
|
||||
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
|
||||
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
|
||||
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
|
||||
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
|
||||
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
|
||||
|
||||
# Display all outputs
|
||||
echo "📋 API Test Results:" && cat api-test.log
|
||||
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
|
||||
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
||||
echo "📋 Web Package Test Results:" && cat web-test.log
|
||||
echo "📋 UI Package Test Results:" && cat ui-test.log
|
||||
|
||||
# Exit with error if any test failed
|
||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
|
||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload all coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/coverage-final.json,../web/coverage/coverage-final.json,../unraid-ui/coverage/coverage-final.json,../packages/unraid-api-plugin-connect/coverage/coverage-final.json,../packages/unraid-shared/coverage/coverage-final.json
|
||||
fail_ci_if_error: false
|
||||
|
||||
build-api:
|
||||
name: Build API
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/test-libvirt.yml
vendored
4
.github/workflows/test-libvirt.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13.6"
|
||||
python-version: "3.13.7"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.14.0
|
||||
version: 10.15.0
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.13.1"}
|
||||
{".":"4.18.2"}
|
||||
|
||||
@@ -233,8 +233,8 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
color: var(--gray12);
|
||||
border: 1px solid var(--gray4);
|
||||
color: hsl(var(--foreground));
|
||||
border: 1px solid hsl(var(--border));
|
||||
transform: var(--toast-close-button-transform);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
@@ -243,7 +243,7 @@
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-close-button] {
|
||||
background: var(--gray1);
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
|
||||
@@ -255,8 +255,8 @@
|
||||
}
|
||||
|
||||
[data-sonner-toast]:hover [data-close-button]:hover {
|
||||
background: var(--gray2);
|
||||
border-color: var(--gray5);
|
||||
background: hsl(var(--muted));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Leave a ghost div to avoid setting hover to false when swiping out */
|
||||
@@ -414,10 +414,27 @@
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray4);
|
||||
--normal-text: var(--gray12);
|
||||
--normal-bg: hsl(var(--background));
|
||||
--normal-border: hsl(var(--border));
|
||||
--normal-text: hsl(var(--foreground));
|
||||
|
||||
--success-bg: hsl(var(--background));
|
||||
--success-border: hsl(var(--border));
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
|
||||
--info-bg: hsl(var(--background));
|
||||
--info-border: hsl(var(--border));
|
||||
--info-text: hsl(210, 92%, 45%);
|
||||
|
||||
--warning-bg: hsl(var(--background));
|
||||
--warning-border: hsl(var(--border));
|
||||
--warning-text: hsl(31, 92%, 45%);
|
||||
|
||||
--error-bg: hsl(var(--background));
|
||||
--error-border: hsl(var(--border));
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
|
||||
/* Old colors, preserved for reference
|
||||
--success-bg: hsl(143, 85%, 96%);
|
||||
--success-border: hsl(145, 92%, 91%);
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
@@ -432,26 +449,43 @@
|
||||
|
||||
--error-bg: hsl(359, 100%, 97%);
|
||||
--error-border: hsl(359, 100%, 94%);
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
--error-text: hsl(360, 100%, 45%); */
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
--normal-bg: hsl(0 0% 3.9%);
|
||||
--normal-border: hsl(0 0% 14.9%);
|
||||
--normal-text: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark'] [data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray3);
|
||||
--normal-text: var(--gray12);
|
||||
--normal-bg: hsl(0 0% 100%);
|
||||
--normal-border: hsl(0 0% 89.8%);
|
||||
--normal-text: hsl(0 0% 3.9%);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
--normal-bg: hsl(var(--background));
|
||||
--normal-border: hsl(var(--border));
|
||||
--normal-text: hsl(var(--foreground));
|
||||
|
||||
--success-bg: hsl(var(--background));
|
||||
--success-border: hsl(var(--border));
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
|
||||
--info-bg: hsl(var(--background));
|
||||
--info-border: hsl(var(--border));
|
||||
--info-text: hsl(216, 87%, 65%);
|
||||
|
||||
--warning-bg: hsl(var(--background));
|
||||
--warning-border: hsl(var(--border));
|
||||
--warning-text: hsl(46, 87%, 65%);
|
||||
|
||||
--error-bg: hsl(var(--background));
|
||||
--error-border: hsl(var(--border));
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
|
||||
/* Old colors, preserved for reference
|
||||
--success-bg: hsl(150, 100%, 6%);
|
||||
--success-border: hsl(147, 100%, 12%);
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
@@ -466,7 +500,7 @@
|
||||
|
||||
--error-bg: hsl(358, 76%, 10%);
|
||||
--error-border: hsl(357, 89%, 16%);
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
--error-text: hsl(358, 100%, 81%); */
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {
|
||||
@@ -541,7 +575,7 @@
|
||||
|
||||
.sonner-loading-bar {
|
||||
animation: sonner-spin 1.2s linear infinite;
|
||||
background: var(--gray11);
|
||||
background: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
height: 8%;
|
||||
left: -10%;
|
||||
|
||||
@@ -156,4 +156,8 @@ Enables GraphQL playground at `http://tower.local/graphql`
|
||||
## Development Memories
|
||||
|
||||
- We are using tailwind v4 we do not need a tailwind config anymore
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- never run or restart the API server or web server. I will handle the lifecycle, simply wait and ask me to do this for you
|
||||
- Never use the `any` type. Always prefer proper typing
|
||||
- Avoid using casting whenever possible, prefer proper typing from the start
|
||||
- **IMPORTANT:** cache-manager v7 expects TTL values in **milliseconds**, not seconds. Always use milliseconds when setting cache TTL (e.g., 600000 for 10 minutes, not 600)
|
||||
|
||||
@@ -18,6 +18,7 @@ PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
|
||||
PATHS_LOCAL_SESSION_FILE=./dev/local-session
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
|
||||
@@ -14,5 +14,6 @@ PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_LOCAL_SESSION_FILE=./dev/local-session
|
||||
PORT=5000
|
||||
NODE_ENV="test"
|
||||
|
||||
2
api/.gitignore
vendored
2
api/.gitignore
vendored
@@ -88,6 +88,8 @@ dev/connectStatus.json
|
||||
dev/configs/*
|
||||
# local status - doesn't need to be tracked
|
||||
dev/connectStatus.json
|
||||
# mock local session file
|
||||
dev/local-session
|
||||
|
||||
# local OIDC config for testing - contains secrets
|
||||
dev/configs/oidc.local.json
|
||||
|
||||
@@ -1,5 +1,99 @@
|
||||
# Changelog
|
||||
|
||||
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing CPU guest metrics to CPU responses ([#1644](https://github.com/unraid/api/issues/1644)) ([99dbad5](https://github.com/unraid/api/commit/99dbad57d55a256f5f3f850f9a47a6eaa6348065))
|
||||
* **plugin:** raise minimum unraid os version to 6.12.15 ([#1649](https://github.com/unraid/api/issues/1649)) ([bc15bd3](https://github.com/unraid/api/commit/bc15bd3d7008acb416ac3c6fb1f4724c685ec7e7))
|
||||
* update GitHub Actions token for workflow trigger ([4d8588b](https://github.com/unraid/api/commit/4d8588b17331afa45ba8caf84fcec8c0ea03591f))
|
||||
* update OIDC URL validation and add tests ([#1646](https://github.com/unraid/api/issues/1646)) ([c7c3bb5](https://github.com/unraid/api/commit/c7c3bb57ea482633a7acff064b39fbc8d4e07213))
|
||||
* use shared bg & border color for styled toasts ([#1647](https://github.com/unraid/api/issues/1647)) ([7c3aee8](https://github.com/unraid/api/commit/7c3aee8f3f9ba82ae8c8ed3840c20ab47f3cb00f))
|
||||
|
||||
## [4.18.1](https://github.com/unraid/api/compare/v4.18.0...v4.18.1) (2025-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OIDC and API Key management issues ([#1642](https://github.com/unraid/api/issues/1642)) ([0fe2c2c](https://github.com/unraid/api/commit/0fe2c2c1c85dcc547e4b1217a3b5636d7dd6d4b4))
|
||||
* rm redundant emission to `$HOME/.pm2/logs` ([#1640](https://github.com/unraid/api/issues/1640)) ([a8e4119](https://github.com/unraid/api/commit/a8e4119270868a1dabccd405853a7340f8dcd8a5))
|
||||
|
||||
## [4.18.0](https://github.com/unraid/api/compare/v4.17.0...v4.18.0) (2025-09-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** enhance OIDC redirect URI handling in service and tests ([#1618](https://github.com/unraid/api/issues/1618)) ([4e945f5](https://github.com/unraid/api/commit/4e945f5f56ce059eb275a9576caf3194a5df8a90))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* api key creation cli ([#1637](https://github.com/unraid/api/issues/1637)) ([c147a6b](https://github.com/unraid/api/commit/c147a6b5075969e77798210c4a5cfd1fa5b96ae3))
|
||||
* **cli:** support `--log-level` for `start` and `restart` cmds ([#1623](https://github.com/unraid/api/issues/1623)) ([a1ee915](https://github.com/unraid/api/commit/a1ee915ca52e5a063eccf8facbada911a63f37f6))
|
||||
* confusing server -> status query ([#1635](https://github.com/unraid/api/issues/1635)) ([9d42b36](https://github.com/unraid/api/commit/9d42b36f74274cad72490da5152fdb98fdc5b89b))
|
||||
* use unraid css variables in sonner ([#1634](https://github.com/unraid/api/issues/1634)) ([26a95af](https://github.com/unraid/api/commit/26a95af9539d05a837112d62dc6b7dd46761c83f))
|
||||
|
||||
## [4.17.0](https://github.com/unraid/api/compare/v4.16.0...v4.17.0) (2025-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tailwind class sort plugin ([#1562](https://github.com/unraid/api/issues/1562)) ([ab11e7f](https://github.com/unraid/api/commit/ab11e7ff7ff74da1f1cd5e49938459d00bfc846b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* cleanup obsoleted legacy api keys on api startup (cli / connect) ([#1630](https://github.com/unraid/api/issues/1630)) ([6469d00](https://github.com/unraid/api/commit/6469d002b7b18e49c77ee650a4255974ab43e790))
|
||||
|
||||
## [4.16.0](https://github.com/unraid/api/compare/v4.15.1...v4.16.0) (2025-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `parityCheckStatus` field to `array` query ([#1611](https://github.com/unraid/api/issues/1611)) ([c508366](https://github.com/unraid/api/commit/c508366702b9fa20d9ed05559fe73da282116aa6))
|
||||
* generated UI API key management + OAuth-like API Key Flows ([#1609](https://github.com/unraid/api/issues/1609)) ([674323f](https://github.com/unraid/api/commit/674323fd87bbcc55932e6b28f6433a2de79b7ab0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **connect:** clear `wanport` upon disabling remote access ([#1624](https://github.com/unraid/api/issues/1624)) ([9df6a3f](https://github.com/unraid/api/commit/9df6a3f5ebb0319aa7e3fe3be6159d39ec6f587f))
|
||||
* **connect:** valid LAN FQDN while remote access is enabled ([#1625](https://github.com/unraid/api/issues/1625)) ([aa58888](https://github.com/unraid/api/commit/aa588883cc2e2fe4aa4aea1d035236c888638f5b))
|
||||
* correctly parse periods in share names from ini file ([#1629](https://github.com/unraid/api/issues/1629)) ([7d67a40](https://github.com/unraid/api/commit/7d67a404333a38d6e1ba5c3febf02be8b1b71901))
|
||||
* **rc.unraid-api:** remove profile sourcing ([#1622](https://github.com/unraid/api/issues/1622)) ([6947b5d](https://github.com/unraid/api/commit/6947b5d4aff70319116eb65cf4c639444f3749e9))
|
||||
* remove unused api key calls ([#1628](https://github.com/unraid/api/issues/1628)) ([9cd0d6a](https://github.com/unraid/api/commit/9cd0d6ac658475efa25683ef6e3f2e1d68f7e903))
|
||||
* retry VMs init for up to 2 min ([#1612](https://github.com/unraid/api/issues/1612)) ([b2e7801](https://github.com/unraid/api/commit/b2e78012384e6b3f2630341281fc811026be23b9))
|
||||
|
||||
## [4.15.1](https://github.com/unraid/api/compare/v4.15.0...v4.15.1) (2025-08-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* minor duplicate click handler and version resolver nullability issue ([ac198d5](https://github.com/unraid/api/commit/ac198d5d1a3073fdeb053c2ff8f704b0dba0d047))
|
||||
|
||||
## [4.15.0](https://github.com/unraid/api/compare/v4.14.0...v4.15.0) (2025-08-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** restructure versioning information in GraphQL schema ([#1600](https://github.com/unraid/api/issues/1600)) ([d0c6602](https://github.com/unraid/api/commit/d0c66020e1d1d5b6fcbc4ee8979bba4b3d34c7ad))
|
||||
|
||||
## [4.14.0](https://github.com/unraid/api/compare/v4.13.1...v4.14.0) (2025-08-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add cpu utilization query and subscription ([#1590](https://github.com/unraid/api/issues/1590)) ([2b4c2a2](https://github.com/unraid/api/commit/2b4c2a264bb2769f88c3000d16447889cae57e98))
|
||||
* enhance OIDC claim evaluation with array handling ([#1596](https://github.com/unraid/api/issues/1596)) ([b7798b8](https://github.com/unraid/api/commit/b7798b82f44aae9a428261270fd9dbde35ff7751))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unraid-api sso users & always apply sso modification on < 7.2 ([#1595](https://github.com/unraid/api/issues/1595)) ([4262830](https://github.com/unraid/api/commit/426283011afd41e3af7e48cfbb2a2d351c014bd1))
|
||||
* update Docusaurus PR workflow to process and copy API docs ([3a10871](https://github.com/unraid/api/commit/3a10871918fe392a1974b69d16a135546166e058))
|
||||
* update OIDC provider setup documentation for navigation clarity ([1a01696](https://github.com/unraid/api/commit/1a01696dc7b947abf5f2f097de1b231d5593c2ff))
|
||||
* update OIDC provider setup documentation for redirect URI and screenshots ([1bc5251](https://github.com/unraid/api/commit/1bc52513109436b3ce8237c3796af765e208f9fc))
|
||||
|
||||
## [4.13.1](https://github.com/unraid/api/compare/v4.13.0...v4.13.1) (2025-08-15)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.18.1",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
],
|
||||
"buttonText": "Login With Unraid.net"
|
||||
}
|
||||
]
|
||||
],
|
||||
"defaultAllowedOrigins": []
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"createdAt": "2025-01-27T16:22:56.501Z",
|
||||
"description": "API key for Connect user",
|
||||
"id": "b5b4aa3d-8e40-4c92-bc40-d50182071886",
|
||||
"key": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"name": "Connect",
|
||||
"permissions": [],
|
||||
"roles": [
|
||||
"CONNECT"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"createdAt": "2025-07-23T17:34:06.301Z",
|
||||
"description": "Internal admin API key used by CLI commands for system operations",
|
||||
"id": "fc91da7b-0284-46f4-9018-55aa9759fba9",
|
||||
"key": "_______SUPER_SECRET_KEY_______",
|
||||
"name": "CliInternal",
|
||||
"permissions": [],
|
||||
"roles": [
|
||||
"ADMIN"
|
||||
]
|
||||
}
|
||||
@@ -65,4 +65,38 @@ color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
["system.with.periods"]
|
||||
name="system.with.periods"
|
||||
nameOrig="system.with.periods"
|
||||
comment="system data with periods"
|
||||
allocator="highwater"
|
||||
splitLevel="1"
|
||||
floor="0"
|
||||
include=""
|
||||
exclude=""
|
||||
useCache="prefer"
|
||||
cachePool="cache"
|
||||
cow="auto"
|
||||
color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
["system.with.🚀"]
|
||||
name="system.with.🚀"
|
||||
nameOrig="system.with.🚀"
|
||||
comment="system data with 🚀"
|
||||
allocator="highwater"
|
||||
splitLevel="1"
|
||||
floor="0"
|
||||
include=""
|
||||
exclude=""
|
||||
useCache="prefer"
|
||||
cachePool="cache"
|
||||
cow="auto"
|
||||
color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
100
api/docs/public/api-key-app-developer-authorization-flow.md
Normal file
100
api/docs/public/api-key-app-developer-authorization-flow.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# API Key Authorization Flow
|
||||
|
||||
This document describes the self-service API key creation flow for third-party applications.
|
||||
|
||||
## Overview
|
||||
|
||||
Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Application initiates request**: The app redirects the user to:
|
||||
|
||||
```
|
||||
https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123
|
||||
```
|
||||
|
||||
2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth)
|
||||
|
||||
3. **Consent screen**: User sees:
|
||||
- Application name and description
|
||||
- Requested permissions (with checkboxes to approve/deny specific scopes)
|
||||
- API key name field (pre-filled)
|
||||
- Authorize & Cancel buttons
|
||||
|
||||
4. **API key creation**: Upon authorization:
|
||||
- API key is created with approved scopes
|
||||
- Key is displayed to the user
|
||||
- If `redirect_uri` is provided, user is redirected back with the key
|
||||
|
||||
5. **Callback**: App receives the API key:
|
||||
```
|
||||
https://myapp.com/callback?api_key=xxx&state=abc123
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
- `name` (required): Name of the requesting application
|
||||
- `description` (optional): Description of the application
|
||||
- `scopes` (required): Comma-separated list of requested scopes
|
||||
- `redirect_uri` (optional): URL to redirect after authorization
|
||||
- `state` (optional): Opaque value for maintaining state
|
||||
|
||||
## Scope Format
|
||||
|
||||
Scopes follow the pattern: `resource:action`
|
||||
|
||||
### Examples:
|
||||
|
||||
- `docker:read` - Read access to Docker
|
||||
- `vm:*` - Full access to VMs
|
||||
- `system:update` - Update access to system
|
||||
- `role:viewer` - Viewer role access
|
||||
- `role:admin` - Admin role access
|
||||
|
||||
### Available Resources:
|
||||
|
||||
- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc.
|
||||
|
||||
### Available Actions:
|
||||
|
||||
- `create`, `read`, `update`, `delete` or `*` for all
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development)
|
||||
2. **User consent**: Users explicitly approve each permission
|
||||
3. **Session-based**: Uses existing Unraid authentication session
|
||||
4. **One-time display**: API keys are shown once and must be saved securely
|
||||
|
||||
## Example Integration
|
||||
|
||||
```javascript
|
||||
// JavaScript example
|
||||
const unraidServer = 'tower.local';
|
||||
const appName = 'My Docker Manager';
|
||||
const scopes = 'docker:*,system:read';
|
||||
const redirectUri = 'https://myapp.com/unraid/callback';
|
||||
const state = generateRandomState();
|
||||
|
||||
// Store state for verification
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
|
||||
// Redirect user to authorization page
|
||||
window.location.href =
|
||||
`https://${unraidServer}/ApiKeyAuthorize?` +
|
||||
`name=${encodeURIComponent(appName)}&` +
|
||||
`scopes=${encodeURIComponent(scopes)}&` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||
`state=${encodeURIComponent(state)}`;
|
||||
|
||||
// Handle callback
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const apiKey = urlParams.get('api_key');
|
||||
const returnedState = urlParams.get('state');
|
||||
|
||||
if (returnedState === sessionStorage.getItem('oauth_state')) {
|
||||
// Save API key securely
|
||||
saveApiKey(apiKey);
|
||||
}
|
||||
```
|
||||
@@ -21,7 +21,14 @@ unraid-api start [--log-level <level>]
|
||||
Starts the Unraid API service.
|
||||
|
||||
Options:
|
||||
- `--log-level`: Set logging level (trace|debug|info|warn|error)
|
||||
|
||||
- `--log-level`: Set logging level (trace|debug|info|warn|error|fatal)
|
||||
|
||||
Alternative: You can also set the log level using the `LOG_LEVEL` environment variable:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=trace unraid-api start
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
@@ -36,11 +43,21 @@ Stops the Unraid API service.
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
unraid-api restart
|
||||
unraid-api restart [--log-level <level>]
|
||||
```
|
||||
|
||||
Restarts the Unraid API service.
|
||||
|
||||
Options:
|
||||
|
||||
- `--log-level`: Set logging level (trace|debug|info|warn|error|fatal)
|
||||
|
||||
Alternative: You can also set the log level using the `LOG_LEVEL` environment variable:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=trace unraid-api restart
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,32 +7,34 @@ sidebar_position: 1
|
||||
# Welcome to Unraid API
|
||||
|
||||
:::tip[What's New]
|
||||
Native integration in Unraid v7.2+ brings the API directly into the OS - no plugin needed!
|
||||
Starting with Unraid OS v7.2, the API comes built into the operating system - no plugin installation required!
|
||||
:::
|
||||
|
||||
The Unraid API provides a GraphQL interface for programmatic interaction with your Unraid server. It enables automation, monitoring, and integration capabilities.
|
||||
|
||||
## 📦 Availability
|
||||
|
||||
### ✨ Native Integration (Unraid v7.2-beta.1+)
|
||||
### ✨ Native Integration (Unraid OS v7.2+)
|
||||
|
||||
Starting with Unraid v7.2-beta.1, the API is integrated directly into the Unraid operating system:
|
||||
Starting with Unraid OS v7.2, the API is integrated directly into the operating system:
|
||||
|
||||
- No plugin installation required
|
||||
- Automatically available on system startup
|
||||
- Deep system integration
|
||||
- Access through **Settings** → **Management Access** → **API**
|
||||
|
||||
### 🔌 Plugin Installation (Earlier Versions)
|
||||
### 🔌 Plugin Installation (Pre-7.2 and Advanced Users)
|
||||
|
||||
For Unraid versions prior to v7.2:
|
||||
For Unraid versions prior to v7.2 or to access newer API features:
|
||||
|
||||
1. Install Unraid Connect Plugin from Apps
|
||||
1. Install the Unraid Connect Plugin from Community Apps
|
||||
2. [Configure the plugin](./how-to-use-the-api.md#enabling-the-graphql-sandbox)
|
||||
3. Access API functionality through the [GraphQL Sandbox](./how-to-use-the-api.md)
|
||||
|
||||
:::tip Pre-release Versions
|
||||
You can install the Unraid Connect plugin on any version to access pre-release versions of the API and get early access to new features before they're included in Unraid OS releases.
|
||||
:::info Important Notes
|
||||
- The Unraid Connect plugin provides the API for pre-7.2 versions
|
||||
- You do NOT need to sign in to Unraid Connect to use the API locally
|
||||
- Installing the plugin on 7.2+ gives you access to newer API features before they're included in OS releases
|
||||
:::
|
||||
|
||||
## 📚 Documentation Sections
|
||||
@@ -69,20 +71,22 @@ The API provides:
|
||||
## 🚀 Get Started
|
||||
|
||||
<tabs>
|
||||
<tabItem value="v72" label="Unraid v7.2+" default>
|
||||
<tabItem value="v72" label="Unraid OS v7.2+" default>
|
||||
|
||||
1. Access the API settings at **Settings** → **Management Access** → **API**
|
||||
2. Enable the GraphQL Sandbox for development
|
||||
3. Create your first API key
|
||||
4. Start making GraphQL queries!
|
||||
1. The API is already installed and running
|
||||
2. Access settings at **Settings** → **Management Access** → **API**
|
||||
3. Enable the GraphQL Sandbox for development
|
||||
4. Create your first API key
|
||||
5. Start making GraphQL queries!
|
||||
|
||||
</tabItem>
|
||||
<tabItem value="older" label="Earlier Versions">
|
||||
<tabItem value="older" label="Pre-7.2 Versions">
|
||||
|
||||
1. Install the Unraid Connect plugin from Apps
|
||||
2. Configure the plugin settings
|
||||
3. Enable the GraphQL Sandbox
|
||||
4. Start exploring the API!
|
||||
1. Install the Unraid Connect plugin from Community Apps
|
||||
2. No Unraid Connect login required for local API access
|
||||
3. Configure the plugin settings
|
||||
4. Enable the GraphQL Sandbox
|
||||
5. Start exploring the API!
|
||||
|
||||
</tabItem>
|
||||
</tabs>
|
||||
|
||||
252
api/docs/public/programmatic-api-key-management.md
Normal file
252
api/docs/public/programmatic-api-key-management.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: Programmatic API Key Management
|
||||
description: Create, use, and delete API keys programmatically for automated workflows
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Programmatic API Key Management
|
||||
|
||||
This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts.
|
||||
|
||||
## Overview
|
||||
|
||||
The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for:
|
||||
|
||||
- Automated deployment scripts
|
||||
- CI/CD pipelines
|
||||
- Temporary access provisioning
|
||||
- Infrastructure as code workflows
|
||||
|
||||
:::tip[Quick Start]
|
||||
Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action.
|
||||
:::
|
||||
|
||||
## Creating API Keys Programmatically
|
||||
|
||||
### Basic Creation with JSON Output
|
||||
|
||||
Use the `--json` flag to get machine-readable output:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create --name "workflow key" --roles ADMIN --json
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your-generated-api-key-here",
|
||||
"name": "workflow key",
|
||||
"id": "generated-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Creation with Permissions
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create \
|
||||
--name "limited access key" \
|
||||
--permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \
|
||||
--description "Read-only access for monitoring" \
|
||||
--json
|
||||
```
|
||||
|
||||
### Handling Existing Keys
|
||||
|
||||
If a key with the same name exists, use `--overwrite`:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json
|
||||
```
|
||||
|
||||
:::warning[Key Replacement]
|
||||
The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated.
|
||||
:::
|
||||
|
||||
## Deleting API Keys Programmatically
|
||||
|
||||
### Non-Interactive Deletion
|
||||
|
||||
Delete a key by name without prompts:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "workflow key"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Successfully deleted 1 API key
|
||||
```
|
||||
|
||||
### JSON Output for Deletion
|
||||
|
||||
Use `--json` flag for machine-readable delete confirmation:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "workflow key" --json
|
||||
```
|
||||
|
||||
**Success Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 1,
|
||||
"keys": [
|
||||
{
|
||||
"id": "generated-uuid",
|
||||
"name": "workflow key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 0,
|
||||
"error": "No API key found with name: nonexistent key"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
When the specified key doesn't exist:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "nonexistent key"
|
||||
# Output: No API keys found to delete
|
||||
```
|
||||
|
||||
**JSON Error Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 0,
|
||||
"message": "No API keys found to delete"
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
Here's a complete example for temporary access provisioning:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 1. Create temporary API key
|
||||
echo "Creating temporary API key..."
|
||||
KEY_DATA=$(unraid-api apikey --create \
|
||||
--name "temp deployment key" \
|
||||
--roles ADMIN \
|
||||
--description "Temporary key for deployment $(date)" \
|
||||
--json)
|
||||
|
||||
# 2. Extract the API key
|
||||
API_KEY=$(echo "$KEY_DATA" | jq -r '.key')
|
||||
echo "API key created successfully"
|
||||
|
||||
# 3. Use the key for operations
|
||||
echo "Configuring services..."
|
||||
curl -H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"provider": "azure", "clientId": "your-client-id"}' \
|
||||
http://localhost:3001/graphql
|
||||
|
||||
# 4. Clean up (always runs, even on error)
|
||||
trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT
|
||||
|
||||
echo "Deployment completed successfully"
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Create Command Options
|
||||
|
||||
| Flag | Description | Example |
|
||||
| ----------------------- | ----------------------- | --------------------------------- |
|
||||
| `--name <name>` | Key name (required) | `--name "my key"` |
|
||||
| `--roles <roles>` | Comma-separated roles | `--roles ADMIN,VIEWER` |
|
||||
| `--permissions <perms>` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` |
|
||||
| `--description <desc>` | Key description | `--description "CI/CD key"` |
|
||||
| `--overwrite` | Replace existing key | `--overwrite` |
|
||||
| `--json` | Machine-readable output | `--json` |
|
||||
|
||||
### Available Roles
|
||||
|
||||
- `ADMIN` - Full system access
|
||||
- `CONNECT` - Unraid Connect features
|
||||
- `VIEWER` - Read-only access
|
||||
- `GUEST` - Limited access
|
||||
|
||||
### Available Resources and Actions
|
||||
|
||||
**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME`
|
||||
|
||||
**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN`
|
||||
|
||||
### Delete Command Options
|
||||
|
||||
| Flag | Description | Example |
|
||||
| --------------- | ------------------------ | ----------------- |
|
||||
| `--delete` | Enable delete mode | `--delete` |
|
||||
| `--name <name>` | Key to delete (optional) | `--name "my key"` |
|
||||
|
||||
**Note:** If `--name` is omitted, the command runs interactively.
|
||||
|
||||
## Best Practices
|
||||
|
||||
:::info[Security Best Practices]
|
||||
**Minimal Permissions**
|
||||
|
||||
- Use specific permissions instead of ADMIN role when possible
|
||||
- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN`
|
||||
|
||||
**Key Lifecycle Management**
|
||||
|
||||
- Always clean up temporary keys after use
|
||||
- Store API keys securely (environment variables, secrets management)
|
||||
- Use descriptive names and descriptions for audit trails
|
||||
:::
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Check exit codes (`$?`) after each command
|
||||
- Use `set -e` in bash scripts to fail fast
|
||||
- Implement proper cleanup with `trap`
|
||||
|
||||
### Key Naming
|
||||
|
||||
- Use descriptive names that include purpose and date
|
||||
- Names must contain only letters, numbers, and spaces
|
||||
- Unicode letters are supported
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
:::note[Common Error Messages]
|
||||
|
||||
**"API key name must contain only letters, numbers, and spaces"**
|
||||
|
||||
- **Solution:** Remove special characters like hyphens, underscores, or symbols
|
||||
|
||||
**"API key with name 'x' already exists"**
|
||||
|
||||
- **Solution:** Use `--overwrite` flag or choose a different name
|
||||
|
||||
**"Please add at least one role or permission to the key"**
|
||||
|
||||
- **Solution:** Specify either `--roles` or `--permissions` (or both)
|
||||
|
||||
:::
|
||||
|
||||
### Debug Mode
|
||||
|
||||
For troubleshooting, run with debug logging:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN
|
||||
```
|
||||
@@ -13,7 +13,9 @@
|
||||
"watch": false,
|
||||
"interpreter": "/usr/local/bin/node",
|
||||
"ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"],
|
||||
"log_file": "/var/log/graphql-api.log",
|
||||
"out_file": "/var/log/graphql-api.log",
|
||||
"error_file": "/var/log/graphql-api.log",
|
||||
"merge_logs": true,
|
||||
"kill_timeout": 10000
|
||||
}
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.13.1",
|
||||
"version": "4.18.2",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"pnpm": "10.14.0"
|
||||
"pnpm": "10.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"// Development": "",
|
||||
@@ -51,7 +51,7 @@
|
||||
"unraid-api": "dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "3.13.9",
|
||||
"@apollo/client": "3.14.0",
|
||||
"@apollo/server": "4.12.2",
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
@@ -82,7 +82,7 @@
|
||||
"atomically": "2.0.3",
|
||||
"bycontract": "2.0.11",
|
||||
"bytes": "3.1.2",
|
||||
"cache-manager": "7.1.1",
|
||||
"cache-manager": "7.2.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"camelcase-keys": "9.1.3",
|
||||
"casbin": "5.38.0",
|
||||
@@ -99,6 +99,7 @@
|
||||
"diff": "8.0.2",
|
||||
"dockerode": "4.0.7",
|
||||
"dotenv": "17.2.1",
|
||||
"escape-html": "1.0.3",
|
||||
"execa": "9.6.0",
|
||||
"exit-hook": "4.0.0",
|
||||
"fastify": "5.5.0",
|
||||
@@ -115,22 +116,22 @@
|
||||
"graphql-ws": "6.0.6",
|
||||
"ini": "5.0.0",
|
||||
"ip": "2.0.1",
|
||||
"jose": "6.0.12",
|
||||
"jose": "6.0.13",
|
||||
"json-bigint-patch": "0.0.8",
|
||||
"lodash-es": "4.17.21",
|
||||
"multi-ini": "2.3.2",
|
||||
"mustache": "4.2.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"nest-commander": "3.18.0",
|
||||
"nest-commander": "3.19.0",
|
||||
"nestjs-pino": "4.4.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-window-polyfill": "1.0.4",
|
||||
"openid-client": "6.6.2",
|
||||
"openid-client": "6.6.4",
|
||||
"p-retry": "6.2.1",
|
||||
"passport-custom": "1.1.1",
|
||||
"passport-http-header-strategy": "1.1.0",
|
||||
"path-type": "6.0.0",
|
||||
"pino": "9.8.0",
|
||||
"pino": "9.9.0",
|
||||
"pino-http": "10.5.0",
|
||||
"pino-pretty": "13.1.1",
|
||||
"pm2": "6.0.8",
|
||||
@@ -138,8 +139,8 @@
|
||||
"rxjs": "7.8.2",
|
||||
"semver": "7.7.2",
|
||||
"strftime": "0.10.3",
|
||||
"systeminformation": "5.27.7",
|
||||
"undici": "7.13.0",
|
||||
"systeminformation": "5.27.8",
|
||||
"undici": "7.15.0",
|
||||
"uuid": "11.1.0",
|
||||
"ws": "8.18.3",
|
||||
"zen-observable-ts": "1.1.0",
|
||||
@@ -154,7 +155,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.33.0",
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
@@ -164,17 +165,17 @@
|
||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.1",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@originjs/vite-plugin-commonjs": "1.0.3",
|
||||
"@rollup/plugin-node-resolve": "16.0.1",
|
||||
"@swc/core": "1.13.3",
|
||||
"@swc/core": "1.13.5",
|
||||
"@types/async-exit-hook": "2.0.2",
|
||||
"@types/bytes": "3.1.5",
|
||||
"@types/cli-table": "0.3.4",
|
||||
"@types/command-exists": "1.2.3",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/dockerode": "3.3.42",
|
||||
"@types/dockerode": "3.3.43",
|
||||
"@types/graphql-fields": "1.3.9",
|
||||
"@types/graphql-type-uuid": "0.2.6",
|
||||
"@types/ini": "4.1.1",
|
||||
@@ -182,7 +183,7 @@
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/node": "22.18.0",
|
||||
"@types/pify": "6.1.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/sendmail": "1.4.7",
|
||||
@@ -191,28 +192,28 @@
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/wtfnode": "0.7.3",
|
||||
"@types/wtfnode": "0.10.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.33.0",
|
||||
"eslint": "9.34.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"jiti": "2.5.1",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"rollup-plugin-node-externals": "8.0.1",
|
||||
"rollup-plugin-node-externals": "8.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"tsx": "4.20.3",
|
||||
"tsx": "4.20.5",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.39.1",
|
||||
"unplugin-swc": "1.5.5",
|
||||
"vite": "7.1.1",
|
||||
"typescript-eslint": "8.41.0",
|
||||
"unplugin-swc": "1.5.7",
|
||||
"vite": "7.1.3",
|
||||
"vite-plugin-node": "7.0.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"zx": "8.8.0"
|
||||
"zx": "8.8.1"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": {
|
||||
@@ -227,5 +228,5 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,48 @@ test('Returns both disk and user shares', async () => {
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
@@ -211,6 +253,48 @@ test('Returns shares by type', async () => {
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(getShares('disk')).toMatchInlineSnapshot('null');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
@@ -446,6 +447,44 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(nfsShares).toMatchInlineSnapshot(`
|
||||
@@ -1110,3 +1149,209 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
describe('Share parsing with periods in names', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('parseConfig handles periods in INI section names', () => {
|
||||
const mockIniContent = `
|
||||
["share.with.periods"]
|
||||
name=share.with.periods
|
||||
useCache=yes
|
||||
include=
|
||||
exclude=
|
||||
|
||||
[normal_share]
|
||||
name=normal_share
|
||||
useCache=no
|
||||
include=
|
||||
exclude=
|
||||
`;
|
||||
|
||||
const result = parseConfig<any>({
|
||||
file: mockIniContent,
|
||||
type: 'ini',
|
||||
});
|
||||
|
||||
// The result should now have properly flattened keys
|
||||
|
||||
expect(result).toHaveProperty('shareWithPeriods');
|
||||
expect(result).toHaveProperty('normalShare');
|
||||
expect(result.shareWithPeriods.name).toBe('share.with.periods');
|
||||
expect(result.normalShare.name).toBe('normal_share');
|
||||
});
|
||||
|
||||
test('shares parser handles periods in share names correctly', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/shares.js');
|
||||
|
||||
// The parser expects an object where values are share configs
|
||||
const mockSharesState = {
|
||||
shareWithPeriods: {
|
||||
name: 'share.with.periods',
|
||||
free: '1000000',
|
||||
used: '500000',
|
||||
size: '1500000',
|
||||
include: '',
|
||||
exclude: '',
|
||||
useCache: 'yes',
|
||||
},
|
||||
normalShare: {
|
||||
name: 'normal_share',
|
||||
free: '2000000',
|
||||
used: '750000',
|
||||
size: '2750000',
|
||||
include: '',
|
||||
exclude: '',
|
||||
useCache: 'no',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockSharesState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.id).toBe('share.with.periods');
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.cache).toBe(true);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.id).toBe('normal_share');
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.cache).toBe(false);
|
||||
});
|
||||
|
||||
test('SMB parser handles periods in share names', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/smb.js');
|
||||
|
||||
const mockSmbState = {
|
||||
'share.with.periods': {
|
||||
export: 'e',
|
||||
security: 'public',
|
||||
writeList: '',
|
||||
readList: '',
|
||||
volsizelimit: '0',
|
||||
},
|
||||
normal_share: {
|
||||
export: 'e',
|
||||
security: 'private',
|
||||
writeList: 'user1,user2',
|
||||
readList: '',
|
||||
volsizelimit: '1000',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockSmbState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.enabled).toBe(true);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.writeList).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
test('NFS parser handles periods in share names', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/nfs.js');
|
||||
|
||||
const mockNfsState = {
|
||||
'share.with.periods': {
|
||||
export: 'e',
|
||||
security: 'public',
|
||||
writeList: '',
|
||||
readList: 'user1',
|
||||
hostList: '',
|
||||
},
|
||||
normal_share: {
|
||||
export: 'd',
|
||||
security: 'private',
|
||||
writeList: 'user2',
|
||||
readList: '',
|
||||
hostList: '192.168.1.0/24',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockNfsState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.enabled).toBe(true);
|
||||
expect(periodShare?.readList).toEqual(['user1']);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Share lookup with periods in names', () => {
|
||||
test('getShares finds user shares with periods in names', async () => {
|
||||
// Mock the store state
|
||||
const mockStore = await import('@app/store/index.js');
|
||||
const mockEmhttpState = {
|
||||
shares: [
|
||||
{
|
||||
id: 'share.with.periods',
|
||||
name: 'share.with.periods',
|
||||
cache: true,
|
||||
free: 1000000,
|
||||
used: 500000,
|
||||
size: 1500000,
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
{
|
||||
id: 'normal_share',
|
||||
name: 'normal_share',
|
||||
cache: false,
|
||||
free: 2000000,
|
||||
used: 750000,
|
||||
size: 2750000,
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
],
|
||||
smbShares: [
|
||||
{ name: 'share.with.periods', enabled: true, security: 'public' },
|
||||
{ name: 'normal_share', enabled: true, security: 'private' },
|
||||
],
|
||||
nfsShares: [
|
||||
{ name: 'share.with.periods', enabled: false },
|
||||
{ name: 'normal_share', enabled: true },
|
||||
],
|
||||
disks: [],
|
||||
};
|
||||
|
||||
const gettersSpy = vi.spyOn(mockStore, 'getters', 'get').mockReturnValue({
|
||||
emhttp: () => mockEmhttpState,
|
||||
} as any);
|
||||
|
||||
const { getShares } = await import('@app/core/utils/shares/get-shares.js');
|
||||
|
||||
const periodShare = getShares('user', { name: 'share.with.periods' });
|
||||
const normalShare = getShares('user', { name: 'normal_share' });
|
||||
|
||||
expect(periodShare).not.toBeNull();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.type).toBe('user');
|
||||
|
||||
expect(normalShare).not.toBeNull();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.type).toBe('user');
|
||||
|
||||
gettersSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,44 @@ test('Returns parsed state file', async () => {
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -29,8 +29,24 @@ const stream = SUPPRESS_LOGS
|
||||
singleLine: true,
|
||||
hideObject: false,
|
||||
colorize: true,
|
||||
colorizeObjects: true,
|
||||
levelFirst: false,
|
||||
ignore: 'hostname,pid',
|
||||
destination: logDestination,
|
||||
translateTime: 'HH:mm:ss',
|
||||
customPrettifiers: {
|
||||
time: (timestamp: string | object) => `[${timestamp}`,
|
||||
level: (logLevel: string | object, key: string, log: any, extras: any) => {
|
||||
// Use labelColorized which preserves the colors
|
||||
const { labelColorized } = extras;
|
||||
const context = log.context || log.logger || 'app';
|
||||
return `${labelColorized} ${context}]`;
|
||||
},
|
||||
},
|
||||
messageFormat: (log: any, messageKey: string) => {
|
||||
const msg = log[messageKey] || log.msg || '';
|
||||
return msg;
|
||||
},
|
||||
})
|
||||
: logDestination;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { sum } from 'lodash-es';
|
||||
|
||||
import { getParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import {
|
||||
@@ -61,5 +62,6 @@ export const getArrayData = (getState = store.getState): UnraidArray => {
|
||||
parities,
|
||||
disks,
|
||||
caches,
|
||||
parityCheckStatus: getParityCheckStatus(emhttp.var),
|
||||
};
|
||||
};
|
||||
|
||||
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
api/src/core/modules/array/parity-check-status.ts
Normal file
72
api/src/core/modules/array/parity-check-status.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { toNumberAlways } from '@unraid/shared/util/data.js';
|
||||
|
||||
import type { Var } from '@app/core/types/states/var.js';
|
||||
import type { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
export enum ParityCheckStatus {
|
||||
NEVER_RUN = 'never_run',
|
||||
RUNNING = 'running',
|
||||
PAUSED = 'paused',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
function calculateParitySpeed(deltaTime: number, deltaBlocks: number) {
|
||||
if (deltaTime === 0 || deltaBlocks === 0) return 0;
|
||||
const deltaBytes = deltaBlocks * 1024;
|
||||
const speedMBps = deltaBytes / deltaTime / 1024 / 1024;
|
||||
return Math.round(speedMBps);
|
||||
}
|
||||
|
||||
type RelevantVarData = Pick<
|
||||
Var,
|
||||
| 'mdResyncPos'
|
||||
| 'mdResyncDt'
|
||||
| 'sbSyncExit'
|
||||
| 'sbSynced'
|
||||
| 'sbSynced2'
|
||||
| 'mdResyncDb'
|
||||
| 'mdResyncSize'
|
||||
>;
|
||||
|
||||
function getStatusFromVarData(varData: RelevantVarData): ParityCheckStatus {
|
||||
const { mdResyncPos, mdResyncDt, sbSyncExit, sbSynced, sbSynced2 } = varData;
|
||||
const mdResyncDtNumber = toNumberAlways(mdResyncDt, 0);
|
||||
const sbSyncExitNumber = toNumberAlways(sbSyncExit, 0);
|
||||
|
||||
switch (true) {
|
||||
case mdResyncPos > 0:
|
||||
return mdResyncDtNumber > 0 ? ParityCheckStatus.RUNNING : ParityCheckStatus.PAUSED;
|
||||
case sbSynced === 0:
|
||||
return ParityCheckStatus.NEVER_RUN;
|
||||
case sbSyncExitNumber === -4:
|
||||
return ParityCheckStatus.CANCELLED;
|
||||
case sbSyncExitNumber !== 0:
|
||||
return ParityCheckStatus.FAILED;
|
||||
case sbSynced2 > 0:
|
||||
return ParityCheckStatus.COMPLETED;
|
||||
default:
|
||||
return ParityCheckStatus.NEVER_RUN;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParityCheckStatus(varData: RelevantVarData): ParityCheck {
|
||||
const { sbSynced, sbSynced2, mdResyncDt, mdResyncDb, mdResyncPos, mdResyncSize } = varData;
|
||||
const deltaTime = toNumberAlways(mdResyncDt, 0);
|
||||
const deltaBlocks = toNumberAlways(mdResyncDb, 0);
|
||||
|
||||
// seconds since epoch (unix timestamp)
|
||||
const now = sbSynced2 > 0 ? sbSynced2 : Date.now() / 1000;
|
||||
return {
|
||||
status: getStatusFromVarData(varData),
|
||||
speed: String(calculateParitySpeed(deltaTime, deltaBlocks)),
|
||||
date: sbSynced > 0 ? new Date(sbSynced * 1000) : undefined,
|
||||
duration: sbSynced > 0 ? Math.round(now - sbSynced) : undefined,
|
||||
// percentage as integer, clamped to [0, 100]
|
||||
progress:
|
||||
mdResyncSize <= 0
|
||||
? 0
|
||||
: Math.round(Math.min(100, Math.max(0, (mdResyncPos / mdResyncSize) * 100))),
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,11 @@ export const pubsub = new PubSub({ eventEmitter });
|
||||
|
||||
/**
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
* @param channel The pubsub channel to subscribe to. Can be either a predefined GRAPHQL_PUBSUB_CHANNEL
|
||||
* or a dynamic string for runtime-generated topics (e.g., log file paths like "LOG_FILE:/var/log/test.log")
|
||||
*/
|
||||
export const createSubscription = (channel: GRAPHQL_PUBSUB_CHANNEL) => {
|
||||
return pubsub.asyncIterableIterator(channel);
|
||||
export const createSubscription = <T = any>(
|
||||
channel: GRAPHQL_PUBSUB_CHANNEL | string
|
||||
): AsyncIterableIterator<T> => {
|
||||
return pubsub.asyncIterableIterator<T>(channel);
|
||||
};
|
||||
|
||||
@@ -68,11 +68,24 @@ export type Var = {
|
||||
mdNumStripes: number;
|
||||
mdNumStripesDefault: number;
|
||||
mdNumStripesStatus: string;
|
||||
/**
|
||||
* Serves a dual purpose depending on context:
|
||||
* - Total size of the operation (in sectors/blocks)
|
||||
* - Running state indicator (0 = paused, >0 = running)
|
||||
*/
|
||||
mdResync: number;
|
||||
mdResyncAction: string;
|
||||
mdResyncCorr: string;
|
||||
mdResyncDb: string;
|
||||
/** Average time interval (delta time) in seconds of current parity operations */
|
||||
mdResyncDt: string;
|
||||
/**
|
||||
* Current position in the parity operation (in sectors/blocks).
|
||||
* When mdResyncPos > 0, a parity operation is active.
|
||||
* When mdResyncPos = 0, no parity operation is running.
|
||||
*
|
||||
* Used to calculate progress percentage.
|
||||
*/
|
||||
mdResyncPos: number;
|
||||
mdResyncSize: number;
|
||||
mdState: ArrayState;
|
||||
@@ -136,9 +149,36 @@ export type Var = {
|
||||
sbName: string;
|
||||
sbNumDisks: number;
|
||||
sbState: string;
|
||||
/**
|
||||
* Unix timestamp when parity operation started.
|
||||
* When sbSynced = 0, indicates no parity check has ever been run.
|
||||
*
|
||||
* Used to calculate elapsed time during active operations.
|
||||
*/
|
||||
sbSynced: number;
|
||||
sbSynced2: number;
|
||||
/**
|
||||
* Unix timestamp when parity operation completed (successfully or with errors).
|
||||
* Used to display completion time in status messages.
|
||||
*
|
||||
* When sbSynced2 = 0, indicates operation started but not yet finished
|
||||
*/
|
||||
sbSyncErrs: number;
|
||||
/**
|
||||
* Exit status code that indicates how the last parity operation completed, following standard Unix conventions.
|
||||
*
|
||||
* sbSyncExit = 0 - Successful Completion
|
||||
* - Parity operation completed normally without errors
|
||||
* - Used to calculate speed and display success message
|
||||
*
|
||||
* sbSyncExit = -4 - Aborted/Cancelled
|
||||
* - Operation was manually cancelled by user
|
||||
* - Displays as "aborted" in the UI
|
||||
*
|
||||
* sbSyncExit ≠ 0 (other values) - Failed/Incomplete
|
||||
* - Operation failed due to errors or other issues
|
||||
* - Displays the numeric error code
|
||||
*/
|
||||
sbSyncExit: string;
|
||||
sbUpdated: string;
|
||||
sbVersion: string;
|
||||
|
||||
@@ -23,6 +23,54 @@ type OptionsWithLoadedFile = {
|
||||
type: ConfigType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens nested objects that were incorrectly created by periods in INI section names.
|
||||
* For example: { system: { with: { periods: {...} } } } -> { "system.with.periods": {...} }
|
||||
*/
|
||||
const flattenPeriodSections = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
const isNestedObject = (value: unknown) =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
// prevent prototype pollution/injection
|
||||
const isUnsafeKey = (k: string) => k === '__proto__' || k === 'prototype' || k === 'constructor';
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (isUnsafeKey(key)) continue;
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (!isNestedObject(value)) {
|
||||
result[fullKey] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = {};
|
||||
const nestedObjs = {};
|
||||
let hasSectionProps = false;
|
||||
|
||||
for (const [propKey, propValue] of Object.entries(value)) {
|
||||
if (isUnsafeKey(propKey)) continue;
|
||||
if (isNestedObject(propValue)) {
|
||||
nestedObjs[propKey] = propValue;
|
||||
} else {
|
||||
section[propKey] = propValue;
|
||||
hasSectionProps = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process direct properties first to maintain order
|
||||
if (hasSectionProps) {
|
||||
result[fullKey] = section;
|
||||
}
|
||||
|
||||
// Then process nested objects
|
||||
if (Object.keys(nestedObjs).length > 0) {
|
||||
Object.assign(result, flattenPeriodSections(nestedObjs, fullKey));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the following
|
||||
* ```
|
||||
@@ -127,6 +175,8 @@ export const parseConfig = <T extends Record<string, any>>(
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = parseIni(fileContents);
|
||||
// Fix nested objects created by periods in section names
|
||||
data = flattenPeriodSections(data);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
||||
17
api/src/core/utils/validation/enum-validator.ts
Normal file
17
api/src/core/utils/validation/enum-validator.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isValidEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): value is T[keyof T] {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(enumObject).includes(value as T[keyof T]);
|
||||
}
|
||||
|
||||
export function validateEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): T[keyof T] | undefined {
|
||||
return isValidEnumValue(value, enumObject) ? (value as T[keyof T]) : undefined;
|
||||
}
|
||||
@@ -108,3 +108,6 @@ export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-
|
||||
|
||||
export const PATHS_CONFIG_MODULES =
|
||||
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
||||
|
||||
export const PATHS_LOCAL_SESSION_FILE =
|
||||
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -9,7 +10,7 @@ describe('Module Dependencies Integration', () => {
|
||||
let module;
|
||||
try {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [RestModule],
|
||||
imports: [CacheModule.register({ isGlobal: true }), RestModule],
|
||||
}).compile();
|
||||
|
||||
expect(module).toBeDefined();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuthZGuard } from 'nest-authz';
|
||||
@@ -23,6 +24,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
GlobalDepsModule,
|
||||
LegacyConfigModule,
|
||||
PubSubModule,
|
||||
ScheduleModule.forRoot(),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: apiLogger,
|
||||
@@ -32,6 +34,15 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
req: () => undefined,
|
||||
res: () => undefined,
|
||||
},
|
||||
formatters: {
|
||||
log: (obj) => {
|
||||
// Map NestJS context to Pino context field for pino-pretty
|
||||
if (obj.context && !obj.logger) {
|
||||
return { ...obj, logger: obj.context };
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
AuthModule,
|
||||
|
||||
@@ -2,15 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { ensureDir, ensureDirSync } from 'fs-extra';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
@@ -48,28 +47,14 @@ describe('ApiKeyService', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -130,21 +115,23 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create ApiKeyWithSecret with generated key', async () => {
|
||||
it('should create ApiKey with generated key', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||
const { id, description, roles } = mockApiKey;
|
||||
const name = 'Test API Key';
|
||||
|
||||
const result = await apiKeyService.create({ name, description: description ?? '', roles });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id,
|
||||
key,
|
||||
name: name,
|
||||
description,
|
||||
roles,
|
||||
createdAt: expect.any(String),
|
||||
});
|
||||
expect(result.key).toBeDefined();
|
||||
expect(typeof result.key).toBe('string');
|
||||
expect(result.key.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(result);
|
||||
});
|
||||
@@ -177,8 +164,8 @@ describe('ApiKeyService', () => {
|
||||
describe('findAll', () => {
|
||||
it('should return all API keys', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
mockApiKeyWithSecret,
|
||||
{ ...mockApiKeyWithSecret, id: 'second-id' },
|
||||
mockApiKey,
|
||||
{ ...mockApiKey, id: 'second-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -191,7 +178,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -202,7 +189,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -219,17 +206,17 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return API key by id when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findById(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toMatchObject({ ...mockApiKey, createdAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, id: 'different-id' },
|
||||
{ ...mockApiKey, id: 'different-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -239,21 +226,21 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdWithSecret', () => {
|
||||
it('should return API key with secret when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
describe('findById', () => {
|
||||
it('should return API key when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret('non-existent-id');
|
||||
const result = await apiKeyService.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -274,23 +261,20 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findByKey', () => {
|
||||
it('should return API key by key value when multiple keys exist', async () => {
|
||||
const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
differentKey,
|
||||
mockApiKeyWithSecret,
|
||||
]);
|
||||
const differentKey = { ...mockApiKey, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([differentKey, mockApiKey]);
|
||||
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key);
|
||||
const result = await apiKeyService.findByKey(mockApiKey.key);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if key not found in any file', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-1' },
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-2' },
|
||||
{ ...mockApiKey, key: 'different-key-1' },
|
||||
{ ...mockApiKey, key: 'different-key-2' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -314,21 +298,21 @@ describe('ApiKeyService', () => {
|
||||
it('should save API key to file', async () => {
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await apiKeyService.saveApiKey(mockApiKeyWithSecret);
|
||||
await apiKeyService.saveApiKey(mockApiKey);
|
||||
|
||||
const writeFileCalls = vi.mocked(writeFile).mock.calls;
|
||||
|
||||
expect(writeFileCalls.length).toBe(1);
|
||||
|
||||
const [filePath, fileContent] = writeFileCalls[0] ?? [];
|
||||
const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`);
|
||||
const expectedPath = join(mockBasePath, `${mockApiKey.id}.json`);
|
||||
|
||||
expect(filePath).toBe(expectedPath);
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
const savedApiKey = JSON.parse(fileContent);
|
||||
|
||||
expect(savedApiKey).toEqual(mockApiKeyWithSecret);
|
||||
expect(savedApiKey).toEqual(mockApiKey);
|
||||
} else {
|
||||
throw new Error('File content should be a string');
|
||||
}
|
||||
@@ -337,16 +321,16 @@ describe('ApiKeyService', () => {
|
||||
it('should throw GraphQLError on write error', async () => {
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow(
|
||||
await expect(apiKeyService.saveApiKey(mockApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Write failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on invalid API key structure', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
name: '', // Invalid: name cannot be empty
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Invalid data structure'
|
||||
@@ -355,10 +339,10 @@ describe('ApiKeyService', () => {
|
||||
|
||||
it('should throw GraphQLError when roles and permissions array is empty', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
permissions: [],
|
||||
roles: [],
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'At least one of permissions or roles must be specified'
|
||||
@@ -367,9 +351,9 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
let updateMockApiKey: ApiKeyWithSecret;
|
||||
let updateMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
// Create a fresh copy of the mock data for update tests
|
||||
updateMockApiKey = {
|
||||
id: 'test-api-id',
|
||||
@@ -380,15 +364,17 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([updateMockApiKey]);
|
||||
// Initialize the memoryApiKeys with the test data
|
||||
// The loadAllFromDisk mock will be called by onModuleInit
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([{ ...updateMockApiKey }]);
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
apiKeyService.onModuleInit();
|
||||
await apiKeyService.onModuleInit();
|
||||
});
|
||||
|
||||
it('should update name and description', async () => {
|
||||
@@ -400,7 +386,6 @@ describe('ApiKeyService', () => {
|
||||
name: updatedName,
|
||||
description: updatedDescription,
|
||||
});
|
||||
|
||||
expect(result.name).toBe(updatedName);
|
||||
expect(result.description).toBe(updatedDescription);
|
||||
expect(result.roles).toEqual(updateMockApiKey.roles);
|
||||
@@ -427,7 +412,7 @@ describe('ApiKeyService', () => {
|
||||
const updatedPermissions = [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -474,7 +459,7 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('loadAllFromDisk', () => {
|
||||
let loadMockApiKey: ApiKeyWithSecret;
|
||||
let loadMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for loadAllFromDisk tests
|
||||
@@ -487,7 +472,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -550,15 +535,62 @@ describe('ApiKeyService', () => {
|
||||
key: 'unique-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize permission actions to lowercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseActions = {
|
||||
...loadMockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['READ:ANY', 'Update:Any', 'create:any', 'DELETE:ANY'], // Mixed case actions
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
actions: ['Read:Any'], // Mixed case
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseActions));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All actions should be normalized to lowercase
|
||||
expect(result[0].permissions[0].actions).toEqual([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]);
|
||||
expect(result[0].permissions[1].actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should normalize roles to uppercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseRoles = {
|
||||
...loadMockApiKey,
|
||||
roles: ['admin', 'Viewer', 'CONNECT'], // Mixed case roles
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseRoles));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All roles should be normalized to uppercase
|
||||
expect(result[0].roles).toEqual(['ADMIN', 'VIEWER', 'CONNECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadApiKeyFile', () => {
|
||||
it('should load and parse a valid API key file', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('test.json');
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8');
|
||||
});
|
||||
|
||||
@@ -592,7 +624,7 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('Error validating API key file test.json')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('An instance of ApiKeyWithSecret has failed the validation')
|
||||
expect.stringContaining('An instance of ApiKey has failed the validation')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property key'));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property id'));
|
||||
@@ -603,5 +635,150 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('property permissions')
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize legacy action formats when loading API keys', async () => {
|
||||
const legacyApiKey = {
|
||||
...mockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['create', 'READ', 'Update', 'DELETE'], // Mixed case legacy verbs
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: ['READ_ANY', 'UPDATE_OWN'], // GraphQL enum style
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: ['read:own', 'update:any'], // Casbin colon format
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(legacyApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('legacy.json');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertRolesStringArrayToRoles', () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(getters.paths).mockReturnValue({
|
||||
'auth-keys': mockBasePath,
|
||||
} as ReturnType<typeof getters.paths>);
|
||||
|
||||
// Create a fresh mock logger for each test
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
};
|
||||
|
||||
apiKeyService = new ApiKeyService();
|
||||
// Replace the logger with our mock
|
||||
(apiKeyService as any).logger = mockLogger;
|
||||
});
|
||||
|
||||
it('should convert uppercase role strings to Role enum values', () => {
|
||||
const roles = ['ADMIN', 'CONNECT', 'VIEWER'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should convert lowercase role strings to Role enum values', () => {
|
||||
const roles = ['admin', 'connect', 'guest'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST]);
|
||||
});
|
||||
|
||||
it('should convert mixed case role strings to Role enum values', () => {
|
||||
const roles = ['Admin', 'CoNnEcT', 'ViEwEr'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should handle roles with whitespace', () => {
|
||||
const roles = [' ADMIN ', ' CONNECT ', 'VIEWER '];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should filter out invalid roles and warn', () => {
|
||||
const roles = ['ADMIN', 'INVALID_ROLE', 'VIEWER', 'ANOTHER_INVALID'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Ignoring invalid roles: INVALID_ROLE, ANOTHER_INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when all roles are invalid', () => {
|
||||
const roles = ['INVALID1', 'INVALID2', 'INVALID3'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Ignoring invalid roles: INVALID1, INVALID2, INVALID3'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle all valid Role enum values', () => {
|
||||
const roles = Object.values(Role);
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual(Object.values(Role));
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deduplicate roles', () => {
|
||||
const roles = ['ADMIN', 'admin', 'ADMIN', 'VIEWER', 'viewer'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
// Note: Current implementation doesn't deduplicate, but this test documents the behavior
|
||||
expect(result).toEqual([Role.ADMIN, Role.ADMIN, Role.ADMIN, Role.VIEWER, Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid roles correctly', () => {
|
||||
const roles = ['ADMIN', 'invalid', 'CONNECT', 'bad_role', 'GUEST', 'VIEWER'];
|
||||
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
|
||||
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST, Role.VIEWER]);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith('Ignoring invalid roles: invalid, bad_role');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import crypto from 'crypto';
|
||||
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { normalizeLegacyActions } from '@unraid/shared/util/permissions.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
@@ -16,7 +16,6 @@ import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
@@ -26,7 +25,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
protected readonly basePath: string;
|
||||
protected memoryApiKeys: Array<ApiKeyWithSecret> = [];
|
||||
protected memoryApiKeys: Array<ApiKey> = [];
|
||||
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
constructor() {
|
||||
@@ -36,23 +35,31 @@ export class ApiKeyService implements OnModuleInit {
|
||||
|
||||
async onModuleInit() {
|
||||
this.memoryApiKeys = await this.loadAllFromDisk();
|
||||
await this.cleanupLegacyInternalKeys();
|
||||
if (environment.IS_MAIN_PROCESS) {
|
||||
this.setupWatch();
|
||||
}
|
||||
}
|
||||
|
||||
public convertApiKeyWithSecretToApiKey(key: ApiKeyWithSecret): ApiKey {
|
||||
const { key: _, ...rest } = key;
|
||||
return rest;
|
||||
private async cleanupLegacyInternalKeys() {
|
||||
const legacyNames = ['CliInternal', 'ConnectInternal'];
|
||||
const keysToDelete = this.memoryApiKeys.filter((key) => legacyNames.includes(key.name));
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
try {
|
||||
await this.deleteApiKeys(keysToDelete.map((key) => key.id));
|
||||
this.logger.log(`Cleaned up ${keysToDelete.length} legacy internal keys`);
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
error,
|
||||
`Failed to delete legacy internal keys: ${keysToDelete.map((key) => key.name).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async findAll(): Promise<ApiKey[]> {
|
||||
return Promise.all(
|
||||
this.memoryApiKeys.map(async (key) => {
|
||||
const keyWithoutSecret = this.convertApiKeyWithSecretToApiKey(key);
|
||||
return keyWithoutSecret;
|
||||
})
|
||||
);
|
||||
return this.memoryApiKeys;
|
||||
}
|
||||
|
||||
private setupWatch() {
|
||||
@@ -76,17 +83,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
public getAllValidPermissions(): Permission[] {
|
||||
return Object.values(Resource).map((res) => ({
|
||||
resource: res,
|
||||
actions: Object.values(AuthActionVerb),
|
||||
actions: Object.values(AuthAction),
|
||||
}));
|
||||
}
|
||||
|
||||
public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] {
|
||||
return permissions.reduce<Array<Permission>>((acc, permission) => {
|
||||
const [resource, action] = permission.split(':');
|
||||
const [resource, ...actionParts] = permission.split(':');
|
||||
const action = actionParts.join(':'); // Handle actions like "read:any"
|
||||
const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null;
|
||||
// Pull the actual enum value from the graphql schema
|
||||
const validatedAction =
|
||||
AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null;
|
||||
AuthAction[action.toUpperCase().replace(':', '_') as keyof typeof AuthAction] ?? null;
|
||||
if (validatedAction && validatedResource) {
|
||||
const existingEntry = acc.find((p) => p.resource === validatedResource);
|
||||
if (existingEntry) {
|
||||
@@ -102,9 +110,25 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
|
||||
public convertRolesStringArrayToRoles(roles: string[]): Role[] {
|
||||
return roles
|
||||
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
|
||||
.filter(Boolean);
|
||||
const validRoles: Role[] = [];
|
||||
const invalidRoles: string[] = [];
|
||||
|
||||
for (const roleStr of roles) {
|
||||
const upperRole = roleStr.trim().toUpperCase();
|
||||
const role = Role[upperRole as keyof typeof Role];
|
||||
|
||||
if (role && ApiKeyService.validRoles.has(role)) {
|
||||
validRoles.push(role);
|
||||
} else {
|
||||
invalidRoles.push(roleStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidRoles.length > 0) {
|
||||
this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validRoles;
|
||||
}
|
||||
|
||||
async create({
|
||||
@@ -119,7 +143,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
}): Promise<ApiKey> {
|
||||
const trimmedName = name?.trim();
|
||||
const sanitizedName = this.sanitizeName(trimmedName);
|
||||
|
||||
@@ -139,7 +163,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (!overwrite && existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
const apiKey: Partial<ApiKeyWithSecret> = {
|
||||
const apiKey: Partial<ApiKey> = {
|
||||
id: uuidv4(),
|
||||
key: this.generateApiKey(),
|
||||
name: sanitizedName,
|
||||
@@ -152,18 +176,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
// Update createdAt date
|
||||
apiKey.createdAt = new Date().toISOString();
|
||||
|
||||
await this.saveApiKey(apiKey as ApiKeyWithSecret);
|
||||
await this.saveApiKey(apiKey as ApiKey);
|
||||
|
||||
return apiKey as ApiKeyWithSecret;
|
||||
return apiKey as ApiKey;
|
||||
}
|
||||
|
||||
async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
|
||||
async loadAllFromDisk(): Promise<ApiKey[]> {
|
||||
const files = await readdir(this.basePath).catch((error) => {
|
||||
this.logger.error(`Failed to read API key directory: ${error}`);
|
||||
throw new Error('Failed to list API keys');
|
||||
});
|
||||
|
||||
const apiKeys: ApiKeyWithSecret[] = [];
|
||||
const apiKeys: ApiKey[] = [];
|
||||
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
@@ -186,7 +210,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
* @param file The file to load
|
||||
* @returns The API key with secret
|
||||
*/
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKeyWithSecret | null> {
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
|
||||
@@ -196,7 +220,17 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (parsedContent.roles) {
|
||||
parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase());
|
||||
}
|
||||
return await validateObject(ApiKeyWithSecret, parsedContent);
|
||||
|
||||
// Normalize permission actions to AuthAction enum values
|
||||
// Uses shared helper to handle all legacy formats
|
||||
if (parsedContent.permissions) {
|
||||
parsedContent.permissions = parsedContent.permissions.map((permission: any) => ({
|
||||
...permission,
|
||||
actions: normalizeLegacyActions(permission.actions || []),
|
||||
}));
|
||||
}
|
||||
|
||||
return await validateObject(ApiKey, parsedContent);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
this.logger.error(`Corrupted key file: ${file}`);
|
||||
@@ -216,12 +250,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const key = this.findByField('id', id);
|
||||
|
||||
if (key) {
|
||||
return this.convertApiKeyWithSecretToApiKey(key);
|
||||
}
|
||||
return null;
|
||||
return this.findByField('id', id);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
this.logApiKeyValidationError(id, error);
|
||||
@@ -231,17 +260,13 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
public findByIdWithSecret(id: string): ApiKeyWithSecret | null {
|
||||
return this.findByField('id', id);
|
||||
}
|
||||
|
||||
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||
public findByField(field: keyof ApiKey, value: string): ApiKey | null {
|
||||
if (!value) return null;
|
||||
|
||||
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
|
||||
}
|
||||
|
||||
findByKey(key: string): ApiKeyWithSecret | null {
|
||||
findByKey(key: string): ApiKey | null {
|
||||
return this.findByField('key', key);
|
||||
}
|
||||
|
||||
@@ -254,9 +279,9 @@ export class ApiKeyService implements OnModuleInit {
|
||||
Errors: ${JSON.stringify(error.constraints, null, 2)}`);
|
||||
}
|
||||
|
||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||
public async saveApiKey(apiKey: ApiKey): Promise<void> {
|
||||
try {
|
||||
const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey);
|
||||
const validatedApiKey = await validateObject(ApiKey, apiKey);
|
||||
if (!validatedApiKey.permissions?.length && !validatedApiKey.roles?.length) {
|
||||
throw new GraphQLError('At least one of permissions or roles must be specified');
|
||||
}
|
||||
@@ -266,7 +291,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = validatedApiKey[key];
|
||||
return acc;
|
||||
}, {} as ApiKeyWithSecret);
|
||||
}, {} as ApiKey);
|
||||
|
||||
await writeFile(
|
||||
join(this.basePath, `${validatedApiKey.id}.json`),
|
||||
@@ -334,8 +359,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = this.findByIdWithSecret(id);
|
||||
}): Promise<ApiKey> {
|
||||
const apiKey = await this.findById(id);
|
||||
if (!apiKey) {
|
||||
throw new GraphQLError('API key not found');
|
||||
}
|
||||
@@ -345,13 +370,15 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (description !== undefined) {
|
||||
apiKey.description = description;
|
||||
}
|
||||
if (roles) {
|
||||
if (roles !== undefined) {
|
||||
// Handle both empty array (to clear roles) and populated array
|
||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||
throw new GraphQLError('Invalid role specified');
|
||||
}
|
||||
apiKey.roles = roles;
|
||||
}
|
||||
if (permissions) {
|
||||
if (permissions !== undefined) {
|
||||
// Handle both empty array (to clear permissions) and populated array
|
||||
apiKey.permissions = permissions;
|
||||
}
|
||||
await this.saveApiKey(apiKey);
|
||||
|
||||
@@ -11,13 +11,19 @@ import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js'
|
||||
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { LocalSessionLifecycleService } from '@app/unraid-api/auth/local-session-lifecycle.service.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
|
||||
import { getRequest } from '@app/utils.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({
|
||||
defaultStrategy: [ServerHeaderStrategy.key, UserCookieStrategy.key],
|
||||
defaultStrategy: [
|
||||
ServerHeaderStrategy.key,
|
||||
LocalSessionStrategy.key,
|
||||
UserCookieStrategy.key,
|
||||
],
|
||||
}),
|
||||
CasbinModule,
|
||||
AuthZModule.register({
|
||||
@@ -51,10 +57,12 @@ import { getRequest } from '@app/utils.js';
|
||||
providers: [
|
||||
AuthService,
|
||||
ApiKeyService,
|
||||
AdminKeyService,
|
||||
ServerHeaderStrategy,
|
||||
LocalSessionStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
LocalSessionService,
|
||||
LocalSessionLifecycleService,
|
||||
{
|
||||
provide: SESSION_COOKIE_CONFIG,
|
||||
useValue: CookieService.defaultOpts(),
|
||||
@@ -65,8 +73,11 @@ import { getRequest } from '@app/utils.js';
|
||||
ApiKeyService,
|
||||
PassportModule,
|
||||
ServerHeaderStrategy,
|
||||
LocalSessionStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
LocalSessionService,
|
||||
LocalSessionLifecycleService,
|
||||
AuthZModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthActionVerb, AuthZService } from 'nest-authz';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
@@ -17,17 +18,9 @@ describe('AuthService', () => {
|
||||
let apiKeyService: ApiKeyService;
|
||||
let authzService: AuthZService;
|
||||
let cookieService: CookieService;
|
||||
let localSessionService: LocalSessionService;
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: '10f356da-1e9e-43b8-9028-a26a645539a6',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST, Role.CONNECT],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
@@ -36,7 +29,7 @@ describe('AuthService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ.toUpperCase()],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -64,7 +57,10 @@ describe('AuthService', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
localSessionService = {
|
||||
validateLocalSession: vi.fn(),
|
||||
} as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,6 +94,43 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate API key with only permissions (no roles)', async () => {
|
||||
const apiKeyWithOnlyPermissions: ApiKey = {
|
||||
...mockApiKey,
|
||||
roles: [], // No roles, only permissions
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(apiKeyWithOnlyPermissions);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(undefined);
|
||||
vi.spyOn(authService, 'syncApiKeyPermissions').mockResolvedValue(undefined);
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.validateApiKeyCasbin('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: apiKeyWithOnlyPermissions.id,
|
||||
name: apiKeyWithOnlyPermissions.name,
|
||||
description: apiKeyWithOnlyPermissions.description,
|
||||
roles: [],
|
||||
permissions: apiKeyWithOnlyPermissions.permissions,
|
||||
});
|
||||
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(apiKeyWithOnlyPermissions.id, []);
|
||||
expect(authService.syncApiKeyPermissions).toHaveBeenCalledWith(
|
||||
apiKeyWithOnlyPermissions.id,
|
||||
apiKeyWithOnlyPermissions.permissions
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when session user is missing', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount);
|
||||
@@ -195,10 +228,6 @@ describe('AuthService', () => {
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.ADMIN],
|
||||
});
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
vi.spyOn(authzService, 'addRoleForUser').mockResolvedValue(true);
|
||||
|
||||
@@ -206,9 +235,8 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKeyWithoutRole,
|
||||
roles: [Role.ADMIN, role],
|
||||
});
|
||||
expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role);
|
||||
@@ -226,13 +254,8 @@ describe('AuthService', () => {
|
||||
describe('removeRoleFromApiKey', () => {
|
||||
it('should remove role from API key', async () => {
|
||||
const apiKey = { ...mockApiKey, roles: [Role.ADMIN, Role.GUEST] };
|
||||
const apiKeyWithSecret = {
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.ADMIN, Role.GUEST],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(apiKey);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue(apiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
vi.spyOn(authzService, 'deleteRoleForUser').mockResolvedValue(true);
|
||||
|
||||
@@ -240,9 +263,8 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...apiKeyWithSecret,
|
||||
...apiKey,
|
||||
roles: [Role.GUEST],
|
||||
});
|
||||
expect(authzService.deleteRoleForUser).toHaveBeenCalledWith(apiKey.id, Role.ADMIN);
|
||||
@@ -256,4 +278,229 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VIEWER role API_KEY access restriction', () => {
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Test that VIEWER role cannot access API_KEY resource
|
||||
const mockCasbinPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => ['VIEWER', resource, AuthAction.READ_ANY]);
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.VIEWER);
|
||||
|
||||
// VIEWER should have read access to all resources EXCEPT API_KEY
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
|
||||
// Should NOT have API_KEY in the permissions
|
||||
expect(result.has(Resource.API_KEY)).toBe(false);
|
||||
|
||||
// Should have read access to other resources
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.CONFIG)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ME)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role access to API_KEY resource', async () => {
|
||||
// Test that ADMIN role CAN access API_KEY resource
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'], // Admin has wildcard access
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
// ADMIN should have access to API_KEY through wildcard
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.has(Resource.API_KEY)).toBe(true);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImplicitPermissionsForRole', () => {
|
||||
it('should return permissions for a role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'VMS', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should handle wildcard permissions for admin role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'],
|
||||
['ADMIN', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// Should have expanded CRUD actions with proper format for all resources
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.CREATE_ANY); // Also gets CRUD from wildcard
|
||||
expect(result.has('*' as any)).toBe(false); // Still shouldn't have literal wildcard
|
||||
});
|
||||
|
||||
it('should handle connect role with wildcard resource and specific action', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['CONNECT', '*', 'READ'],
|
||||
['CONNECT', 'CONNECT__REMOTE_ACCESS', 'UPDATE'],
|
||||
['CONNECT', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.CONNECT);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// All resources should have READ
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ARRAY)).toContain(AuthAction.READ_ANY);
|
||||
// CONNECT__REMOTE_ACCESS should have both READ and UPDATE
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.UPDATE_ANY);
|
||||
});
|
||||
|
||||
it('should expand resource-specific wildcard actions to CRUD', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['DOCKER_MANAGER', 'DOCKER', '*'],
|
||||
['DOCKER_MANAGER', 'ARRAY', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
// Docker should have all CRUD actions with proper format
|
||||
expect(result.get(Resource.DOCKER)).toEqual(
|
||||
expect.arrayContaining([
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
])
|
||||
);
|
||||
// Array should only have READ
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should skip invalid resources', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'INVALID_RESOURCE', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', '', 'READ'],
|
||||
] as string[][];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle empty permissions', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle malformed permission entries', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN'], // Too short
|
||||
['ADMIN', 'DOCKER'], // Missing action
|
||||
['ADMIN', 'DOCKER', 'READ', 'EXTRA'], // Extra fields are ok
|
||||
['ADMIN', 'VMS', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should not duplicate actions for the same resource', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockRejectedValue(
|
||||
new Error('Casbin error')
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
convertPermissionSetsToArrays,
|
||||
expandWildcardAction,
|
||||
parseActionToAuthAction,
|
||||
reconcileWildcardPermissions,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
@@ -18,6 +26,7 @@ export class AuthService {
|
||||
constructor(
|
||||
private cookieService: CookieService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
private localSessionService: LocalSessionService,
|
||||
private authzService: AuthZService
|
||||
) {}
|
||||
|
||||
@@ -83,6 +92,30 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateLocalSession(localSessionToken: string): Promise<UserAccount> {
|
||||
try {
|
||||
const isValid = await this.localSessionService.validateLocalSession(localSessionToken);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid local session token');
|
||||
}
|
||||
|
||||
// Local session has admin privileges
|
||||
const user = await this.getLocalSessionUser();
|
||||
|
||||
// Sync the user's roles before checking them
|
||||
await this.syncUserRoles(user.id, user.roles);
|
||||
|
||||
// Now get the updated roles
|
||||
const existingRoles = await this.authzService.getRolesForUser(user.id);
|
||||
this.logger.debug(`Local session user ${user.id} has roles: ${existingRoles}`);
|
||||
|
||||
return user;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to validate local session', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async syncApiKeyRoles(apiKeyId: string, roles: string[]): Promise<void> {
|
||||
try {
|
||||
// Get existing roles and convert to Set
|
||||
@@ -111,12 +144,36 @@ export class AuthService {
|
||||
await this.authzService.deletePermissionsForUser(apiKeyId);
|
||||
|
||||
// Create array of permission-action pairs for processing
|
||||
const permissionActions = permissions.flatMap((permission) =>
|
||||
(permission.actions || []).map((action) => ({
|
||||
resource: permission.resource,
|
||||
action,
|
||||
}))
|
||||
);
|
||||
// Filter out any permissions with empty or undefined resources
|
||||
const permissionActions = permissions
|
||||
.filter((permission) => permission.resource && permission.resource.trim() !== '')
|
||||
.flatMap((permission) =>
|
||||
(permission.actions || [])
|
||||
.filter((action) => action && String(action).trim() !== '')
|
||||
.flatMap((action) => {
|
||||
const actionStr = String(action);
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
return expandWildcardAction().map((expandedAction) => ({
|
||||
resource: permission.resource,
|
||||
action: expandedAction,
|
||||
}));
|
||||
}
|
||||
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
|
||||
// Only include valid AuthAction values
|
||||
return parsedAction
|
||||
? [
|
||||
{
|
||||
resource: permission.resource,
|
||||
action: parsedAction,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
const { errors, errorOccurred: errorOccured } = await batchProcess(
|
||||
permissionActions,
|
||||
@@ -144,15 +201,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!apiKey.roles) {
|
||||
apiKey.roles = [];
|
||||
}
|
||||
if (!apiKey.roles.includes(role)) {
|
||||
const apiKeyWithSecret = await this.apiKeyService.findByIdWithSecret(apiKeyId);
|
||||
|
||||
if (!apiKeyWithSecret) {
|
||||
throw new UnauthorizedException('API key not found with secret');
|
||||
}
|
||||
|
||||
apiKeyWithSecret.roles.push(role);
|
||||
await this.apiKeyService.saveApiKey(apiKeyWithSecret);
|
||||
apiKey.roles.push(role);
|
||||
await this.apiKeyService.saveApiKey(apiKey);
|
||||
await this.authzService.addRoleForUser(apiKeyId, role);
|
||||
}
|
||||
|
||||
@@ -174,14 +228,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyWithSecret = await this.apiKeyService.findByIdWithSecret(apiKeyId);
|
||||
|
||||
if (!apiKeyWithSecret) {
|
||||
throw new UnauthorizedException('API key not found with secret');
|
||||
if (!apiKey.roles) {
|
||||
apiKey.roles = [];
|
||||
}
|
||||
|
||||
apiKeyWithSecret.roles = apiKeyWithSecret.roles.filter((r) => r !== role);
|
||||
await this.apiKeyService.saveApiKey(apiKeyWithSecret);
|
||||
apiKey.roles = apiKey.roles.filter((r) => r !== role);
|
||||
await this.apiKeyService.saveApiKey(apiKey);
|
||||
await this.authzService.deleteRoleForUser(apiKeyId, role);
|
||||
|
||||
return true;
|
||||
@@ -224,7 +275,67 @@ export class AuthService {
|
||||
}
|
||||
|
||||
public validateCsrfToken(token?: string): boolean {
|
||||
return Boolean(token) && token === getters.emhttp().var.csrfToken;
|
||||
if (!token) return false;
|
||||
const csrfToken = getters.emhttp().var.csrfToken;
|
||||
if (!csrfToken) return false;
|
||||
return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(csrfToken, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get implicit permissions for a role (including inherited permissions)
|
||||
*/
|
||||
public async getImplicitPermissionsForRole(role: Role): Promise<Map<Resource, AuthAction[]>> {
|
||||
// Use Set internally for efficient deduplication, with '*' as a special key for wildcards
|
||||
const permissionsWithSets = new Map<Resource | '*', Set<AuthAction>>();
|
||||
|
||||
// Load permissions from Casbin, defaulting to empty array on error
|
||||
let casbinPermissions: string[][] = [];
|
||||
try {
|
||||
casbinPermissions = await this.authzService.getImplicitPermissionsForUser(role);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get permissions for role ${role}:`, error);
|
||||
}
|
||||
|
||||
// Parse the Casbin permissions format: [["role", "resource", "action"], ...]
|
||||
for (const perm of casbinPermissions) {
|
||||
if (perm.length < 3) continue;
|
||||
|
||||
const resourceStr = perm[1];
|
||||
const action = perm[2];
|
||||
|
||||
if (!resourceStr) continue;
|
||||
|
||||
// Skip invalid resources (except wildcard)
|
||||
if (resourceStr !== '*' && !Object.values(Resource).includes(resourceStr as Resource)) {
|
||||
this.logger.debug(`Skipping invalid resource from Casbin: ${resourceStr}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize Set if needed
|
||||
if (!permissionsWithSets.has(resourceStr as Resource | '*')) {
|
||||
permissionsWithSets.set(resourceStr as Resource | '*', new Set());
|
||||
}
|
||||
|
||||
const actionsSet = permissionsWithSets.get(resourceStr as Resource | '*')!;
|
||||
|
||||
// Handle wildcard or parse to valid AuthAction
|
||||
if (action === '*') {
|
||||
// Expand wildcard action to CRUD operations
|
||||
expandWildcardAction().forEach((a) => actionsSet.add(a));
|
||||
} else {
|
||||
// Use shared helper to parse and validate action
|
||||
const parsedAction = parseActionToAuthAction(action);
|
||||
if (parsedAction) {
|
||||
actionsSet.add(parsedAction);
|
||||
} else {
|
||||
this.logger.debug(`Skipping invalid action from Casbin: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile wildcard permissions and convert to final format
|
||||
reconcileWildcardPermissions(permissionsWithSets);
|
||||
return convertPermissionSetsToArrays(permissionsWithSets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +345,7 @@ export class AuthService {
|
||||
* @returns a service account that represents the user session (i.e. a webgui user).
|
||||
*/
|
||||
async getSessionUser(): Promise<UserAccount> {
|
||||
this.logger.debug('getSessionUser called!');
|
||||
this.logger.verbose('getSessionUser called!');
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'Session receives administrator permissions',
|
||||
@@ -243,4 +354,21 @@ export class AuthService {
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user object representing a local session.
|
||||
* Note: Does NOT perform validation.
|
||||
*
|
||||
* @returns a service account that represents the local session user (i.e. CLI/system operations).
|
||||
*/
|
||||
async getLocalSessionUser(): Promise<UserAccount> {
|
||||
this.logger.verbose('getLocalSessionUser called!');
|
||||
return {
|
||||
id: '-2',
|
||||
description: 'Local session receives administrator permissions for CLI/system operations',
|
||||
name: 'local-admin',
|
||||
roles: [Role.ADMIN],
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
import { apiLogger } from '@app/core/log.js';
|
||||
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
|
||||
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
|
||||
import { IS_PUBLIC_ENDPOINT_KEY } from '@app/unraid-api/auth/public.decorator.js';
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ type GraphQLContext =
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationGuard
|
||||
extends AuthGuard([ServerHeaderStrategy.key, UserCookieStrategy.key])
|
||||
extends AuthGuard([ServerHeaderStrategy.key, LocalSessionStrategy.key, UserCookieStrategy.key])
|
||||
implements CanActivate
|
||||
{
|
||||
protected logger = new Logger(AuthenticationGuard.name);
|
||||
|
||||
@@ -12,7 +12,7 @@ g = _, _
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
|
||||
regexMatch(lower(r.obj), lower(p.obj)) && \
|
||||
(regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*'))))
|
||||
m = (r.sub == p.sub || g(r.sub, p.sub)) && \
|
||||
(r.obj == p.obj || p.obj == '*') && \
|
||||
(r.act == p.act || p.act == '*')
|
||||
`;
|
||||
|
||||
566
api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts
Normal file
566
api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Comprehensive Casbin Permissions Tests', () => {
|
||||
describe('All UsePermissions decorator combinations', () => {
|
||||
// Test all resource/action combinations used in the codebase
|
||||
const testCases = [
|
||||
// API_KEY permissions
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// PERMISSION resource (for listing possible permissions)
|
||||
{
|
||||
resource: Resource.PERMISSION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ARRAY permissions
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// CONFIG permissions
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// DOCKER permissions
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// VMS permissions
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// FLASH permissions (includes rclone operations)
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// INFO permissions (system information)
|
||||
{
|
||||
resource: Resource.INFO,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// LOGS permissions
|
||||
{
|
||||
resource: Resource.LOGS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ME permissions (current user info)
|
||||
{
|
||||
resource: Resource.ME,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST],
|
||||
deniedRoles: [],
|
||||
},
|
||||
|
||||
// NOTIFICATIONS permissions
|
||||
{
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// Other read-only resources for VIEWER
|
||||
{
|
||||
resource: Resource.DISK,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DISPLAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ONLINE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.OWNER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.REGISTRATION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVERS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVICES,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SHARE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VARS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// CONNECT special permission for remote access
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.CONNECT],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ resource, action, allowedRoles, deniedRoles }) => {
|
||||
describe(`${resource} - ${action}`, () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
allowedRoles.forEach((role) => {
|
||||
it(`should allow ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
deniedRoles.forEach((role) => {
|
||||
it(`should deny ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action matching and normalization', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should match actions exactly as stored (uppercase)', async () => {
|
||||
// Our policies store actions as uppercase (e.g., 'READ_ANY')
|
||||
// The matcher now requires exact matching for security
|
||||
|
||||
// Uppercase actions should work
|
||||
const adminUpperResult = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminUpperResult).toBe(true);
|
||||
|
||||
const viewerUpperResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(viewerUpperResult).toBe(true);
|
||||
|
||||
// For non-wildcard roles, lowercase actions won't match
|
||||
const viewerLowerResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'read:any');
|
||||
expect(viewerLowerResult).toBe(false);
|
||||
|
||||
// Mixed case won't match for VIEWER either
|
||||
const viewerMixedResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'Read_Any');
|
||||
expect(viewerMixedResult).toBe(false);
|
||||
|
||||
// GUEST also requires exact lowercase
|
||||
const guestUpperResult = await enforcer.enforce(Role.GUEST, Resource.ME, 'READ:ANY');
|
||||
expect(guestUpperResult).toBe(false);
|
||||
|
||||
const guestLowerResult = await enforcer.enforce(
|
||||
Role.GUEST,
|
||||
Resource.ME,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(guestLowerResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow wildcard actions for ADMIN regardless of case', async () => {
|
||||
// ADMIN has wildcard permissions (*, *, *) which match any action
|
||||
const adminWildcardActions = [
|
||||
'read:any',
|
||||
'create:any',
|
||||
'update:any',
|
||||
'delete:any',
|
||||
'READ:ANY', // Even uppercase works due to wildcard
|
||||
'ANYTHING', // Any action works due to wildcard
|
||||
];
|
||||
|
||||
for (const action of adminWildcardActions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.DOCKER, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match different actions even with correct case', async () => {
|
||||
// VIEWER should not be able to UPDATE even with correct lowercase
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.UPDATE_ANY);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// VIEWER should not be able to DELETE
|
||||
const deleteResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(deleteResult).toBe(false);
|
||||
|
||||
// VIEWER should not be able to CREATE
|
||||
const createResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(createResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should ensure actions are normalized when stored', async () => {
|
||||
// This test documents that our auth service normalizes actions to uppercase
|
||||
// when syncing permissions, ensuring consistency
|
||||
|
||||
// The BASE_POLICY uses AuthAction.READ_ANY which is 'READ_ANY' (uppercase)
|
||||
expect(BASE_POLICY).toContain('READ_ANY');
|
||||
expect(BASE_POLICY).not.toContain('read:any');
|
||||
|
||||
// All our stored policies should be uppercase
|
||||
const policies = await enforcer.getPolicy();
|
||||
for (const policy of policies) {
|
||||
const action = policy[2]; // Third element is the action
|
||||
if (action && action !== '*') {
|
||||
// All non-wildcard actions should be uppercase
|
||||
expect(action).toBe(action.toUpperCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wildcard permissions', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should allow ADMIN wildcard access to all resources and actions', async () => {
|
||||
const resources = Object.values(Resource);
|
||||
const actions = [
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
];
|
||||
|
||||
for (const resource of resources) {
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, resource, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow CONNECT read access to most resources but NOT API_KEY', async () => {
|
||||
const resources = Object.values(Resource).filter(
|
||||
(r) => r !== Resource.CONNECT__REMOTE_ACCESS && r !== Resource.API_KEY
|
||||
);
|
||||
|
||||
for (const resource of resources) {
|
||||
// Should be able to read most resources
|
||||
const readResult = await enforcer.enforce(Role.CONNECT, resource, AuthAction.READ_ANY);
|
||||
expect(readResult).toBe(true);
|
||||
|
||||
// Should NOT be able to write (except CONNECT__REMOTE_ACCESS)
|
||||
const updateResult = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
resource,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(updateResult).toBe(false);
|
||||
}
|
||||
|
||||
// CONNECT should NOT be able to read API_KEY
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// CONNECT should NOT be able to perform any action on API_KEY
|
||||
const apiKeyCreate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(apiKeyCreate).toBe(false);
|
||||
const apiKeyUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(apiKeyUpdate).toBe(false);
|
||||
const apiKeyDelete = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(apiKeyDelete).toBe(false);
|
||||
|
||||
// Special case: CONNECT can update CONNECT__REMOTE_ACCESS
|
||||
const remoteAccessUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.CONNECT__REMOTE_ACCESS,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(remoteAccessUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should explicitly deny CONNECT role from accessing API_KEY to prevent secret exposure', async () => {
|
||||
// CONNECT should NOT be able to read API_KEY (which would expose secrets)
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// Verify all API_KEY operations are denied for CONNECT
|
||||
const actions = ['create:any', 'read:any', 'update:any', 'delete:any'];
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.API_KEY, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
|
||||
// Verify ADMIN can still access API_KEY
|
||||
const adminApiKeyRead = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminApiKeyRead).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role inheritance', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER', async () => {
|
||||
// VIEWER inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for CONNECT', async () => {
|
||||
// CONNECT inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for ADMIN', async () => {
|
||||
// ADMIN inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and security', () => {
|
||||
it('should deny access with empty action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with empty resource', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, '', AuthAction.READ_ANY);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with undefined role', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(
|
||||
'UNDEFINED_ROLE',
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with malformed action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const malformedActions = [
|
||||
'read', // Missing possession
|
||||
':any', // Missing verb
|
||||
'read:', // Empty possession
|
||||
'read:own', // Different possession format
|
||||
'READ', // Uppercase without possession
|
||||
];
|
||||
|
||||
for (const action of malformedActions) {
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
147
api/src/unraid-api/auth/casbin/policy.spec.ts
Normal file
147
api/src/unraid-api/auth/casbin/policy.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Casbin Policy - VIEWER role restrictions', () => {
|
||||
it('should validate matcher does not allow empty policies', async () => {
|
||||
// Test that empty policies don't match everything
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
|
||||
// Test with a policy that has an empty object
|
||||
const emptyPolicy = `p, VIEWER, , ${AuthAction.READ_ANY}`;
|
||||
const adapter = new StringAdapter(emptyPolicy);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Empty policy should not match a real resource
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
expect(canReadApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER cannot access API_KEY with any action
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(false);
|
||||
expect(canCreateApiKey).toBe(false);
|
||||
expect(canUpdateApiKey).toBe(false);
|
||||
expect(canDeleteApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow VIEWER role access to other resources', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER can read other resources
|
||||
const canReadDocker = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.READ_ANY);
|
||||
const canReadArray = await enforcer.enforce(Role.VIEWER, Resource.ARRAY, AuthAction.READ_ANY);
|
||||
const canReadConfig = await enforcer.enforce(Role.VIEWER, Resource.CONFIG, AuthAction.READ_ANY);
|
||||
const canReadVms = await enforcer.enforce(Role.VIEWER, Resource.VMS, AuthAction.READ_ANY);
|
||||
|
||||
expect(canReadDocker).toBe(true);
|
||||
expect(canReadArray).toBe(true);
|
||||
expect(canReadConfig).toBe(true);
|
||||
expect(canReadVms).toBe(true);
|
||||
|
||||
// But VIEWER cannot write to these resources
|
||||
const canUpdateDocker = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteArray = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.ARRAY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canUpdateDocker).toBe(false);
|
||||
expect(canDeleteArray).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role full access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that ADMIN can access API_KEY with all actions
|
||||
const canReadApiKey = await enforcer.enforce(Role.ADMIN, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(true);
|
||||
expect(canCreateApiKey).toBe(true);
|
||||
expect(canUpdateApiKey).toBe(true);
|
||||
expect(canDeleteApiKey).toBe(true);
|
||||
});
|
||||
|
||||
it('should ensure VIEWER permissions exclude API_KEY in generated policy', () => {
|
||||
// Verify that the generated policy string doesn't contain VIEWER + API_KEY combination
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).not.toContain(
|
||||
`p, ${Role.VIEWER}, ${Resource.API_KEY}, ${AuthAction.READ_ANY}`
|
||||
);
|
||||
|
||||
// Count VIEWER permissions - should be total resources minus API_KEY
|
||||
const viewerPermissionLines = BASE_POLICY.split('\n').filter((line) =>
|
||||
line.startsWith(`p, ${Role.VIEWER},`)
|
||||
);
|
||||
const totalResources = Object.values(Resource).length;
|
||||
expect(viewerPermissionLines.length).toBe(totalResources - 1); // All resources except API_KEY
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER role', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// VIEWER inherits from GUEST, so should have access to ME resource
|
||||
const canReadMe = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(canReadMe).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
// Generate VIEWER permissions for all resources except API_KEY
|
||||
const viewerPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => `p, ${Role.VIEWER}, ${resource}, ${AuthAction.READ_ANY}`)
|
||||
.join('\n');
|
||||
|
||||
export const BASE_POLICY = `
|
||||
# Admin permissions
|
||||
# Admin permissions - full access
|
||||
p, ${Role.ADMIN}, *, *
|
||||
|
||||
# Connect Permissions
|
||||
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
|
||||
# Connect permissions - inherits from VIEWER plus can manage remote access
|
||||
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
|
||||
|
||||
# Guest permissions
|
||||
# Guest permissions - basic profile access
|
||||
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
||||
|
||||
# Viewer permissions - read-only access to all resources except API_KEY
|
||||
${viewerPermissions}
|
||||
|
||||
# Role inheritance
|
||||
g, ${Role.ADMIN}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.VIEWER}
|
||||
g, ${Role.VIEWER}, ${Role.GUEST}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
@@ -9,7 +9,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
/** token for dependency injection of a session cookie options object */
|
||||
export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';
|
||||
|
||||
type SessionCookieConfig = {
|
||||
export type SessionCookieConfig = {
|
||||
namePrefix: string;
|
||||
sessionDir: string;
|
||||
secure: boolean;
|
||||
@@ -68,13 +68,17 @@ export class CookieService {
|
||||
}
|
||||
try {
|
||||
const sessionData = await readFile(sessionFile, 'ascii');
|
||||
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
|
||||
return this.isSessionValid(sessionData);
|
||||
} catch (e) {
|
||||
this.logger.error(e, 'Error reading session file');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isSessionValid(sessionData: string): boolean {
|
||||
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a session id, returns the full path to the session file on disk.
|
||||
*
|
||||
@@ -91,4 +95,33 @@ export class CookieService {
|
||||
const sanitizedSessionId = sessionId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return join(this.opts.sessionDir, `sess_${sanitizedSessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active session id, if any.
|
||||
* @returns the active session id, if any, or null if no active session is found.
|
||||
*/
|
||||
async getActiveSession(): Promise<string | null> {
|
||||
let sessionFiles: string[] = [];
|
||||
try {
|
||||
sessionFiles = await readdir(this.opts.sessionDir);
|
||||
} catch (e) {
|
||||
this.logger.warn(e, 'Error reading session directory');
|
||||
return null;
|
||||
}
|
||||
for (const sessionFile of sessionFiles) {
|
||||
if (!sessionFile.startsWith('sess_')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const sessionData = await readFile(join(this.opts.sessionDir, sessionFile), 'ascii');
|
||||
if (this.isSessionValid(sessionData)) {
|
||||
return sessionFile.replace('sess_', '');
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable files and continue scanning
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
21
api/src/unraid-api/auth/local-session-lifecycle.service.ts
Normal file
21
api/src/unraid-api/auth/local-session-lifecycle.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
|
||||
/**
|
||||
* Service for managing the lifecycle of the local session.
|
||||
*
|
||||
* Used for tying the local session's lifecycle to the API's life, rather
|
||||
* than the LocalSessionService's lifecycle, since it may also be used by
|
||||
* other applications, like the CLI.
|
||||
*
|
||||
* This service is only used in the API, and not in the CLI.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionLifecycleService implements OnModuleInit {
|
||||
constructor(private readonly localSessionService: LocalSessionService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.localSessionService.generateLocalSession();
|
||||
}
|
||||
}
|
||||
97
api/src/unraid-api/auth/local-session.service.ts
Normal file
97
api/src/unraid-api/auth/local-session.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { chmod, mkdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { PATHS_LOCAL_SESSION_FILE } from '@app/environment.js';
|
||||
|
||||
/**
|
||||
* Service that manages a local session file for internal CLI/system authentication.
|
||||
* Creates a secure token on startup that can be used for local system operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionService {
|
||||
private readonly logger = new Logger(LocalSessionService.name);
|
||||
private sessionToken: string | null = null;
|
||||
private static readonly SESSION_FILE_PATH = PATHS_LOCAL_SESSION_FILE;
|
||||
|
||||
/**
|
||||
* Generate a secure local session token and write it to file
|
||||
*/
|
||||
async generateLocalSession(): Promise<void> {
|
||||
// Generate a cryptographically secure random token
|
||||
this.sessionToken = randomBytes(32).toString('hex');
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await mkdir(dirname(LocalSessionService.getSessionFilePath()), { recursive: true });
|
||||
|
||||
// Write token to file
|
||||
await writeFile(LocalSessionService.getSessionFilePath(), this.sessionToken, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600, // Owner read/write only
|
||||
});
|
||||
|
||||
// Ensure proper permissions (redundant but explicit)
|
||||
// Check if file exists first to handle race conditions in test environments
|
||||
await chmod(LocalSessionService.getSessionFilePath(), 0o600).catch((error) => {
|
||||
this.logger.warn(error, 'Failed to set permissions on local session file');
|
||||
});
|
||||
|
||||
this.logger.debug(`Local session written to ${LocalSessionService.getSessionFilePath()}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write local session: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and return the current local session token from file
|
||||
*/
|
||||
public async getLocalSession(): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(LocalSessionService.getSessionFilePath(), 'utf-8');
|
||||
} catch (error) {
|
||||
this.logger.warn(error, 'Local session file not found or not readable');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a given token matches the current local session
|
||||
*/
|
||||
public async validateLocalSession(token: string): Promise<boolean> {
|
||||
// Coerce inputs to strings (or empty string if undefined)
|
||||
const tokenStr = token || '';
|
||||
const currentToken = await this.getLocalSession();
|
||||
const currentTokenStr = currentToken || '';
|
||||
|
||||
// Early return if either is empty
|
||||
if (!tokenStr || !currentTokenStr) return false;
|
||||
|
||||
// Create buffers
|
||||
const tokenBuffer = Buffer.from(tokenStr, 'utf-8');
|
||||
const currentTokenBuffer = Buffer.from(currentTokenStr, 'utf-8');
|
||||
|
||||
// Check length equality first to prevent timingSafeEqual from throwing
|
||||
if (tokenBuffer.length !== currentTokenBuffer.length) return false;
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
return timingSafeEqual(tokenBuffer, currentTokenBuffer);
|
||||
}
|
||||
|
||||
public async deleteLocalSession(): Promise<void> {
|
||||
try {
|
||||
await unlink(LocalSessionService.getSessionFilePath());
|
||||
} catch (error) {
|
||||
this.logger.error(error, 'Error deleting local session file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for the local session (useful for external readers)
|
||||
*/
|
||||
public static getSessionFilePath(): string {
|
||||
return LocalSessionService.SESSION_FILE_PATH;
|
||||
}
|
||||
}
|
||||
46
api/src/unraid-api/auth/local-session.strategy.ts
Normal file
46
api/src/unraid-api/auth/local-session.strategy.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
|
||||
import { Strategy } from 'passport-custom';
|
||||
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
/**
|
||||
* Passport strategy for local session authentication.
|
||||
* Validates the x-local-session header for internal CLI/system operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionStrategy extends PassportStrategy(Strategy, 'local-session') {
|
||||
static readonly key = 'local-session';
|
||||
private readonly logger = new Logger(LocalSessionStrategy.name);
|
||||
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(request: FastifyRequest): Promise<UserAccount | null> {
|
||||
try {
|
||||
const localSessionToken = request.headers['x-local-session'] as string;
|
||||
|
||||
if (!localSessionToken) {
|
||||
this.logger.verbose('No local session token found in request headers');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.verbose('Attempting to validate local session token');
|
||||
const user = await this.authService.validateLocalSession(localSessionToken);
|
||||
|
||||
if (user) {
|
||||
this.logger.verbose(`Local session authenticated user: ${user.name}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.verbose(error, `Local session validation failed`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
192
api/src/unraid-api/cli/__test__/api-key.command.test.ts
Normal file
192
api/src/unraid-api/cli/__test__/api-key.command.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
describe('ApiKeyCommand', () => {
|
||||
let command: ApiKeyCommand;
|
||||
let apiKeyService: ApiKeyService;
|
||||
let logService: LogService;
|
||||
let inquirerService: InquirerService;
|
||||
let questionSet: AddApiKeyQuestionSet;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyCommand,
|
||||
AddApiKeyQuestionSet,
|
||||
{
|
||||
provide: ApiKeyService,
|
||||
useValue: {
|
||||
findByField: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
deleteApiKeys: vi.fn(),
|
||||
convertRolesStringArrayToRoles: vi.fn((roles) => roles),
|
||||
convertPermissionsStringArrayToPermissions: vi.fn((perms) => perms),
|
||||
getAllValidPermissions: vi.fn(() => []),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InquirerService,
|
||||
useValue: {
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<ApiKeyCommand>(ApiKeyCommand);
|
||||
apiKeyService = module.get<ApiKeyService>(ApiKeyService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
inquirerService = module.get<InquirerService>(InquirerService);
|
||||
questionSet = module.get<AddApiKeyQuestionSet>(AddApiKeyQuestionSet);
|
||||
});
|
||||
|
||||
describe('AddApiKeyQuestionSet', () => {
|
||||
describe('shouldAskOverwrite', () => {
|
||||
it('should return true when an API key with the given name exists', () => {
|
||||
vi.mocked(apiKeyService.findByField).mockReturnValue({
|
||||
key: 'existing-key',
|
||||
name: 'test-key',
|
||||
description: 'Test key',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
} as any);
|
||||
|
||||
const result = questionSet.shouldAskOverwrite({ name: 'test-key' });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key');
|
||||
});
|
||||
|
||||
it('should return false when no API key with the given name exists', () => {
|
||||
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
|
||||
|
||||
const result = questionSet.shouldAskOverwrite({ name: 'non-existent-key' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'non-existent-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should find and return existing key when not creating', async () => {
|
||||
const mockKey = { key: 'test-api-key-123', name: 'test-key' };
|
||||
vi.mocked(apiKeyService.findByField).mockReturnValue(mockKey as any);
|
||||
|
||||
await command.run([], { name: 'test-key', create: false });
|
||||
|
||||
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key');
|
||||
expect(logService.log).toHaveBeenCalledWith('test-api-key-123');
|
||||
});
|
||||
|
||||
it('should create new key when key does not exist and create flag is set', async () => {
|
||||
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
|
||||
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'new-api-key-456' } as any);
|
||||
|
||||
await command.run([], {
|
||||
name: 'new-key',
|
||||
create: true,
|
||||
roles: ['ADMIN'] as any,
|
||||
description: 'Test description',
|
||||
});
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'new-key',
|
||||
description: 'Test description',
|
||||
roles: ['ADMIN'],
|
||||
permissions: undefined,
|
||||
overwrite: false,
|
||||
});
|
||||
expect(logService.log).toHaveBeenCalledWith('new-api-key-456');
|
||||
});
|
||||
|
||||
it('should error when key exists and overwrite is not set in non-interactive mode', async () => {
|
||||
const mockKey = { key: 'existing-key', name: 'test-key' };
|
||||
vi.mocked(apiKeyService.findByField)
|
||||
.mockReturnValueOnce(null) // First call in line 131
|
||||
.mockReturnValueOnce(mockKey as any); // Second call in non-interactive check
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
await expect(
|
||||
command.run([], {
|
||||
name: 'test-key',
|
||||
create: true,
|
||||
roles: ['ADMIN'] as any,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"API key with name 'test-key' already exists. Use --overwrite to replace it."
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create key with overwrite when key exists and overwrite is set', async () => {
|
||||
const mockKey = { key: 'existing-key', name: 'test-key' };
|
||||
vi.mocked(apiKeyService.findByField)
|
||||
.mockReturnValueOnce(null) // First call in line 131
|
||||
.mockReturnValueOnce(mockKey as any); // Second call in non-interactive check
|
||||
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'overwritten-key' } as any);
|
||||
|
||||
await command.run([], {
|
||||
name: 'test-key',
|
||||
create: true,
|
||||
roles: ['ADMIN'] as any,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'test-key',
|
||||
description: 'CLI generated key: test-key',
|
||||
roles: ['ADMIN'],
|
||||
permissions: undefined,
|
||||
overwrite: true,
|
||||
});
|
||||
expect(logService.log).toHaveBeenCalledWith('overwritten-key');
|
||||
});
|
||||
|
||||
it('should prompt for missing fields when creating without sufficient info', async () => {
|
||||
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
|
||||
vi.mocked(inquirerService.prompt).mockResolvedValue({
|
||||
name: 'prompted-key',
|
||||
roles: ['USER'],
|
||||
permissions: [],
|
||||
description: 'Prompted description',
|
||||
overwrite: false,
|
||||
} as any);
|
||||
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'prompted-api-key' } as any);
|
||||
|
||||
await command.run([], { name: '', create: true });
|
||||
|
||||
expect(inquirerService.prompt).toHaveBeenCalledWith('add-api-key', {
|
||||
name: '',
|
||||
create: true,
|
||||
});
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'prompted-key',
|
||||
description: 'Prompted description',
|
||||
roles: ['USER'],
|
||||
permissions: [],
|
||||
overwrite: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
@@ -40,7 +41,7 @@ describe('ApiReportService', () => {
|
||||
providers: [
|
||||
ApiReportService,
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClientService },
|
||||
{ provide: CANONICAL_INTERNAL_CLIENT_TOKEN, useValue: mockInternalClientService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -64,9 +65,13 @@ describe('ApiReportService', () => {
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
versions: {
|
||||
unraid: '6.12.0',
|
||||
kernel: '5.19.17',
|
||||
openssl: '3.0.8',
|
||||
core: {
|
||||
unraid: '6.12.0',
|
||||
kernel: '5.19.17',
|
||||
},
|
||||
packages: {
|
||||
openssl: '3.0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('DeveloperToolsService', () => {
|
||||
let service: DeveloperToolsService;
|
||||
let logService: LogService;
|
||||
let restartCommand: RestartCommand;
|
||||
let internalClient: CliInternalClientService;
|
||||
let internalClient: CanonicalInternalClientService;
|
||||
|
||||
const mockClient = {
|
||||
mutate: vi.fn(),
|
||||
@@ -42,7 +43,7 @@ describe('DeveloperToolsService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CliInternalClientService,
|
||||
provide: CANONICAL_INTERNAL_CLIENT_TOKEN,
|
||||
useValue: {
|
||||
getClient: vi.fn().mockResolvedValue(mockClient),
|
||||
},
|
||||
@@ -53,7 +54,7 @@ describe('DeveloperToolsService', () => {
|
||||
service = module.get<DeveloperToolsService>(DeveloperToolsService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
restartCommand = module.get<RestartCommand>(RestartCommand);
|
||||
internalClient = module.get<CliInternalClientService>(CliInternalClientService);
|
||||
internalClient = module.get<CanonicalInternalClientService>(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
});
|
||||
|
||||
describe('setSandboxMode', () => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type { ApiKeyService } from '@unraid/shared/services/api-key.js';
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
|
||||
|
||||
/**
|
||||
* Service that creates and manages the admin API key used by CLI commands.
|
||||
* Uses the standard API key storage location via helper methods in ApiKeyService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminKeyService.name);
|
||||
private static readonly ADMIN_KEY_NAME = 'CliInternal';
|
||||
private static readonly ADMIN_KEY_DESCRIPTION =
|
||||
'Internal admin API key used by CLI commands for system operations';
|
||||
|
||||
constructor(
|
||||
@Inject(API_KEY_SERVICE_TOKEN)
|
||||
private readonly apiKeyService: ApiKeyService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.getOrCreateLocalAdminKey();
|
||||
this.logger.log('Admin API key initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize admin API key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a local admin API key for CLI operations.
|
||||
* Uses the standard API key storage location.
|
||||
*/
|
||||
public async getOrCreateLocalAdminKey(): Promise<string> {
|
||||
return this.apiKeyService.ensureKey({
|
||||
name: AdminKeyService.ADMIN_KEY_NAME,
|
||||
description: AdminKeyService.ADMIN_KEY_DESCRIPTION,
|
||||
roles: [Role.ADMIN], // Full admin privileges for CLI operations
|
||||
legacyNames: ['CLI', 'Internal', 'CliAdmin'], // Clean up old keys
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
|
||||
import type { ConnectStatusQuery, SystemReportQuery } from '@app/unraid-api/cli/generated/graphql.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
@@ -60,7 +62,8 @@ export interface ApiReportData {
|
||||
@Injectable()
|
||||
export class ApiReportService {
|
||||
constructor(
|
||||
private readonly internalClient: CliInternalClientService,
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService,
|
||||
private readonly logger: LogService
|
||||
) {}
|
||||
|
||||
@@ -82,7 +85,7 @@ export class ApiReportService {
|
||||
? {
|
||||
id: systemData.info.system.uuid,
|
||||
name: systemData.server?.name || 'Unknown',
|
||||
version: systemData.info.versions.unraid || 'Unknown',
|
||||
version: systemData.info.versions.core.unraid || 'Unknown',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: systemData.info.system.manufacturer,
|
||||
model: systemData.info.system.model,
|
||||
@@ -135,7 +138,7 @@ export class ApiReportService {
|
||||
});
|
||||
}
|
||||
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
|
||||
// Query system data
|
||||
let systemResult: { data: SystemReportQuery } | null = null;
|
||||
@@ -190,7 +193,7 @@ export class ApiReportService {
|
||||
|
||||
return this.createApiReportData({
|
||||
apiRunning,
|
||||
systemData: systemResult.data,
|
||||
systemData: systemResult?.data,
|
||||
connectData,
|
||||
servicesData,
|
||||
});
|
||||
|
||||
@@ -39,6 +39,12 @@ export class AddApiKeyQuestionSet {
|
||||
return this.apiKeyService.convertRolesStringArrayToRoles(val);
|
||||
}
|
||||
|
||||
@WhenFor({ name: 'roles' })
|
||||
shouldAskRoles(options: { roles?: Role[]; permissions?: Permission[] }): boolean {
|
||||
// Ask for roles if they weren't provided or are empty
|
||||
return !options.roles || options.roles.length === 0;
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'roles' })
|
||||
async getRoles() {
|
||||
return Object.values(Role);
|
||||
@@ -53,6 +59,12 @@ export class AddApiKeyQuestionSet {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(val);
|
||||
}
|
||||
|
||||
@WhenFor({ name: 'permissions' })
|
||||
shouldAskPermissions(options: { roles?: Role[]; permissions?: Permission[] }): boolean {
|
||||
// Ask for permissions if they weren't provided or are empty
|
||||
return !options.permissions || options.permissions.length === 0;
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'permissions' })
|
||||
async getPermissions() {
|
||||
return this.apiKeyService
|
||||
@@ -72,6 +84,6 @@ export class AddApiKeyQuestionSet {
|
||||
|
||||
@WhenFor({ name: 'overwrite' })
|
||||
shouldAskOverwrite(options: { name: string }): boolean {
|
||||
return Boolean(this.apiKeyService.findByKey(options.name));
|
||||
return Boolean(this.apiKeyService.findByField('name', options.name));
|
||||
}
|
||||
}
|
||||
|
||||
434
api/src/unraid-api/cli/apikey/api-key.command.spec.ts
Normal file
434
api/src/unraid-api/cli/apikey/api-key.command.spec.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
describe('ApiKeyCommand', () => {
|
||||
let command: ApiKeyCommand;
|
||||
let apiKeyService: ApiKeyService;
|
||||
let logService: LogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyCommand,
|
||||
{
|
||||
provide: ApiKeyService,
|
||||
useValue: {
|
||||
findByField: vi.fn(),
|
||||
create: vi.fn(),
|
||||
convertRolesStringArrayToRoles: vi.fn(),
|
||||
convertPermissionsStringArrayToPermissions: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
deleteApiKeys: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InquirerService,
|
||||
useValue: {
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<ApiKeyCommand>(ApiKeyCommand);
|
||||
apiKeyService = module.get<ApiKeyService>(ApiKeyService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
});
|
||||
|
||||
describe('parseRoles', () => {
|
||||
it('should parse valid roles correctly', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockReturnValue([Role.ADMIN, Role.CONNECT]);
|
||||
|
||||
const result = command.parseRoles('ADMIN,CONNECT');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'CONNECT']);
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
|
||||
});
|
||||
|
||||
it('should return GUEST role when no roles provided', () => {
|
||||
const result = command.parseRoles('');
|
||||
|
||||
expect(result).toEqual([Role.GUEST]);
|
||||
});
|
||||
|
||||
it('should handle roles with spaces', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockReturnValue([Role.ADMIN, Role.VIEWER]);
|
||||
|
||||
const result = command.parseRoles('ADMIN, VIEWER');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', ' VIEWER']);
|
||||
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should throw error when no valid roles found', () => {
|
||||
vi.spyOn(apiKeyService, 'convertRolesStringArrayToRoles').mockReturnValue([]);
|
||||
|
||||
expect(() => command.parseRoles('INVALID_ROLE')).toThrow(
|
||||
`Invalid roles. Valid options are: ${Object.values(Role).join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid roles with warning', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockImplementation((roles) => {
|
||||
const validRoles: Role[] = [];
|
||||
const invalidRoles: string[] = [];
|
||||
|
||||
for (const roleStr of roles) {
|
||||
const upperRole = roleStr.trim().toUpperCase();
|
||||
const role = Role[upperRole as keyof typeof Role];
|
||||
|
||||
if (role) {
|
||||
validRoles.push(role);
|
||||
} else {
|
||||
invalidRoles.push(roleStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidRoles.length > 0) {
|
||||
logService.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validRoles;
|
||||
});
|
||||
|
||||
const result = command.parseRoles('ADMIN,INVALID,VIEWER');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'INVALID', 'VIEWER']);
|
||||
expect(logService.warn).toHaveBeenCalledWith('Ignoring invalid roles: INVALID');
|
||||
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should create API key with roles without prompting', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key-123',
|
||||
name: 'TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
permissions: undefined,
|
||||
description: 'Test description',
|
||||
});
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'TEST',
|
||||
description: 'Test description',
|
||||
roles: [Role.ADMIN],
|
||||
permissions: undefined,
|
||||
overwrite: false,
|
||||
});
|
||||
expect(logService.log).toHaveBeenCalledWith('test-key-123');
|
||||
});
|
||||
|
||||
it('should create API key with permissions only without prompting', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key-456',
|
||||
name: 'TEST_PERMS',
|
||||
roles: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
const mockPermissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'TEST_PERMS',
|
||||
create: true,
|
||||
roles: undefined,
|
||||
permissions: mockPermissions,
|
||||
description: 'Test with permissions',
|
||||
});
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'TEST_PERMS',
|
||||
description: 'Test with permissions',
|
||||
roles: undefined,
|
||||
permissions: mockPermissions,
|
||||
overwrite: false,
|
||||
});
|
||||
expect(logService.log).toHaveBeenCalledWith('test-key-456');
|
||||
});
|
||||
|
||||
it('should use default description when not provided', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key-789',
|
||||
name: 'NO_DESC',
|
||||
roles: [Role.VIEWER],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'NO_DESC',
|
||||
create: true,
|
||||
roles: [Role.VIEWER],
|
||||
permissions: undefined,
|
||||
});
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'NO_DESC',
|
||||
description: 'CLI generated key: NO_DESC',
|
||||
roles: [Role.VIEWER],
|
||||
permissions: undefined,
|
||||
overwrite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return existing key when found', async () => {
|
||||
const existingKey = {
|
||||
id: 'existing-id',
|
||||
key: 'existing-key-123',
|
||||
name: 'EXISTING',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'EXISTING',
|
||||
create: false,
|
||||
});
|
||||
|
||||
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'EXISTING');
|
||||
expect(logService.log).toHaveBeenCalledWith('existing-key-123');
|
||||
expect(apiKeyService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle uppercase role conversion', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockImplementation((roles) => {
|
||||
return roles
|
||||
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const result = command.parseRoles('admin,connect');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['admin', 'connect']);
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
|
||||
});
|
||||
|
||||
it('should handle lowercase role conversion', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockImplementation((roles) => {
|
||||
return roles
|
||||
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const result = command.parseRoles('viewer');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['viewer']);
|
||||
expect(result).toEqual([Role.VIEWER]);
|
||||
});
|
||||
|
||||
it('should handle mixed case role conversion', () => {
|
||||
const mockConvert = vi
|
||||
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
|
||||
.mockImplementation((roles) => {
|
||||
return roles
|
||||
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const result = command.parseRoles('Admin,CoNnEcT');
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith(['Admin', 'CoNnEcT']);
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON output functionality', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should output JSON when creating key with --json flag', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id-123',
|
||||
key: 'test-key-456',
|
||||
name: 'JSON_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'JSON_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'test-key-456', name: 'JSON_TEST', id: 'test-id-123' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('test-key-456');
|
||||
});
|
||||
|
||||
it('should output JSON when fetching existing key with --json flag', async () => {
|
||||
const existingKey = {
|
||||
id: 'existing-id-456',
|
||||
key: 'existing-key-789',
|
||||
name: 'EXISTING_JSON',
|
||||
roles: [Role.VIEWER],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'EXISTING_JSON',
|
||||
create: false,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'existing-key-789', name: 'EXISTING_JSON', id: 'existing-id-456' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('existing-key-789');
|
||||
});
|
||||
|
||||
it('should output JSON when deleting key with --json flag', async () => {
|
||||
const existingKeys = [
|
||||
{
|
||||
id: 'delete-id-123',
|
||||
name: 'DELETE_JSON',
|
||||
key: 'delete-key-456',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
},
|
||||
];
|
||||
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(existingKeys);
|
||||
vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue();
|
||||
|
||||
await command.run([], {
|
||||
name: 'DELETE_JSON',
|
||||
delete: true,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
deleted: 1,
|
||||
keys: [{ id: 'delete-id-123', name: 'DELETE_JSON' }],
|
||||
})
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('Successfully deleted 1 API key');
|
||||
});
|
||||
|
||||
it('should output JSON error when deleting non-existent key with --json flag', async () => {
|
||||
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue([]);
|
||||
|
||||
await command.run([], {
|
||||
name: 'NONEXISTENT',
|
||||
delete: true,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ deleted: 0, message: 'No API keys found to delete' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('No API keys found to delete');
|
||||
});
|
||||
|
||||
it('should not suppress creation message when not using JSON', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key',
|
||||
name: 'NO_JSON_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'NO_JSON_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: false,
|
||||
});
|
||||
|
||||
expect(logService.log).toHaveBeenCalledWith('Creating API Key...');
|
||||
expect(logService.log).toHaveBeenCalledWith('test-key');
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should suppress creation message when using JSON', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key',
|
||||
name: 'JSON_SUPPRESS_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'JSON_SUPPRESS_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(logService.log).not.toHaveBeenCalledWith('Creating API Key...');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'test-key', name: 'JSON_SUPPRESS_TEST', id: 'test-id' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
|
||||
|
||||
import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
@@ -11,11 +10,13 @@ import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.mode
|
||||
|
||||
interface KeyOptions {
|
||||
name: string;
|
||||
create: boolean;
|
||||
create?: boolean;
|
||||
delete?: boolean;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[];
|
||||
overwrite?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
@@ -53,29 +54,22 @@ export class ApiKeyCommand extends CommandRunner {
|
||||
})
|
||||
parseRoles(roles: string): Role[] {
|
||||
if (!roles) return [Role.GUEST];
|
||||
const validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role);
|
||||
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
|
||||
const roleArray = roles.split(',').filter(Boolean);
|
||||
const validRoles = this.apiKeyService.convertRolesStringArrayToRoles(roleArray);
|
||||
|
||||
if (validRequestedRoles.length === 0) {
|
||||
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
|
||||
if (validRoles.length === 0) {
|
||||
throw new Error(`Invalid roles. Valid options are: ${Object.values(Role).join(', ')}`);
|
||||
}
|
||||
|
||||
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
|
||||
|
||||
if (invalidRoles.length > 0) {
|
||||
this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validRequestedRoles;
|
||||
return validRoles;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-p, --permissions <permissions>',
|
||||
description: `Comma separated list of permissions to assign to the key (in the form of "resource:action")
|
||||
RESOURCES: ${Object.values(Resource).join(', ')}
|
||||
ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
})
|
||||
parsePermissions(permissions: string): Array<Permission> {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(
|
||||
@@ -99,48 +93,137 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
|
||||
private async deleteKeys() {
|
||||
@Option({
|
||||
flags: '--overwrite',
|
||||
description: 'Overwrite existing API key if it exists',
|
||||
})
|
||||
parseOverwrite(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--json',
|
||||
description: 'Output machine-readable JSON format',
|
||||
})
|
||||
parseJson(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Helper to output either JSON or regular log message */
|
||||
private output(message: string, jsonData?: object, jsonOutput?: boolean): void {
|
||||
if (jsonOutput && jsonData) {
|
||||
console.log(JSON.stringify(jsonData));
|
||||
} else {
|
||||
this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to output either JSON or regular error message */
|
||||
private outputError(message: string, jsonData?: object, jsonOutput?: boolean): void {
|
||||
if (jsonOutput && jsonData) {
|
||||
console.log(JSON.stringify(jsonData));
|
||||
} else {
|
||||
this.logger.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete API keys either by name (non-interactive) or by prompting user selection (interactive). */
|
||||
private async deleteKeys(name?: string, jsonOutput?: boolean) {
|
||||
const allKeys = await this.apiKeyService.findAll();
|
||||
if (allKeys.length === 0) {
|
||||
this.logger.log('No API keys found to delete');
|
||||
this.output(
|
||||
'No API keys found to delete',
|
||||
{ deleted: 0, message: 'No API keys found to delete' },
|
||||
jsonOutput
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
|
||||
DeleteApiKeyQuestionSet.name,
|
||||
{}
|
||||
);
|
||||
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
|
||||
this.logger.log('No keys selected for deletion');
|
||||
return;
|
||||
let selectedKeyIds: string[];
|
||||
let deletedKeys: { id: string; name: string }[] = [];
|
||||
|
||||
if (name) {
|
||||
// Non-interactive mode: delete by name
|
||||
const keyToDelete = allKeys.find((key) => key.name === name);
|
||||
if (!keyToDelete) {
|
||||
this.outputError(
|
||||
`No API key found with name: ${name}`,
|
||||
{ deleted: 0, error: `No API key found with name: ${name}` },
|
||||
jsonOutput
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
selectedKeyIds = [keyToDelete.id];
|
||||
deletedKeys = [{ id: keyToDelete.id, name: keyToDelete.name }];
|
||||
} else {
|
||||
// Interactive mode: prompt user to select keys
|
||||
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
|
||||
DeleteApiKeyQuestionSet.name,
|
||||
{}
|
||||
);
|
||||
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
|
||||
this.output(
|
||||
'No keys selected for deletion',
|
||||
{ deleted: 0, message: 'No keys selected for deletion' },
|
||||
jsonOutput
|
||||
);
|
||||
return;
|
||||
}
|
||||
selectedKeyIds = answers.selectedKeys;
|
||||
deletedKeys = allKeys
|
||||
.filter((key) => selectedKeyIds.includes(key.id))
|
||||
.map((key) => ({ id: key.id, name: key.name }));
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiKeyService.deleteApiKeys(answers.selectedKeys);
|
||||
this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`);
|
||||
await this.apiKeyService.deleteApiKeys(selectedKeyIds);
|
||||
const message = `Successfully deleted ${selectedKeyIds.length} API key${selectedKeyIds.length === 1 ? '' : 's'}`;
|
||||
this.output(message, { deleted: selectedKeyIds.length, keys: deletedKeys }, jsonOutput);
|
||||
} catch (error) {
|
||||
this.logger.error(error as any);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.outputError(errorMessage, { deleted: 0, error: errorMessage }, jsonOutput);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
_: string[],
|
||||
options: KeyOptions = { create: false, name: '', delete: false }
|
||||
): Promise<void> {
|
||||
async run(_: string[], options: KeyOptions = { name: '', delete: false }): Promise<void> {
|
||||
try {
|
||||
if (options.delete) {
|
||||
await this.deleteKeys();
|
||||
await this.deleteKeys(options.name, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.apiKeyService.findByField('name', options.name);
|
||||
if (key) {
|
||||
this.logger.log(key.key);
|
||||
} else if (options.create) {
|
||||
options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options);
|
||||
this.logger.log('Creating API Key...' + JSON.stringify(options));
|
||||
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
|
||||
} else if (options.create === true) {
|
||||
// Check if we have minimum required info from flags (name + at least one role or permission)
|
||||
const hasMinimumInfo =
|
||||
options.name &&
|
||||
((options.roles && options.roles.length > 0) ||
|
||||
(options.permissions && options.permissions.length > 0));
|
||||
|
||||
if (!hasMinimumInfo) {
|
||||
// Interactive mode - prompt for missing fields
|
||||
options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options);
|
||||
} else {
|
||||
// Non-interactive mode - check if key exists and handle overwrite
|
||||
const existingKey = this.apiKeyService.findByField('name', options.name);
|
||||
if (existingKey && !options.overwrite) {
|
||||
this.outputError(
|
||||
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
|
||||
{
|
||||
error: `API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
|
||||
},
|
||||
options.json
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.json) {
|
||||
this.logger.log('Creating API Key...');
|
||||
}
|
||||
|
||||
if (!options.roles && !options.permissions) {
|
||||
this.logger.error('Please add at least one role or permission to the key.');
|
||||
@@ -155,10 +238,10 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
description: options.description || `CLI generated key: ${options.name}`,
|
||||
roles: options.roles,
|
||||
permissions: options.permissions,
|
||||
overwrite: true,
|
||||
overwrite: options.overwrite ?? false,
|
||||
});
|
||||
|
||||
this.logger.log(key.key);
|
||||
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
|
||||
} else {
|
||||
this.logger.log('No Key Found');
|
||||
process.exit(1);
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
@@ -23,15 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
PluginCliModule.register(),
|
||||
UnraidFileModifierModule,
|
||||
],
|
||||
providers: [
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
],
|
||||
exports: [ApiReportService, LogService, ApiKeyService, CliInternalClientService],
|
||||
providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService],
|
||||
exports: [ApiReportService, LogService, ApiKeyService],
|
||||
})
|
||||
export class CliServicesModule {}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN, INTERNAL_CLIENT_FACTORY_TOKEN } from '@unraid/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
|
||||
|
||||
describe('CliServicesModule', () => {
|
||||
@@ -26,29 +24,23 @@ describe('CliServicesModule', () => {
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provide CliInternalClientService', () => {
|
||||
const service = module.get(CliInternalClientService);
|
||||
it('should provide CanonicalInternalClient', () => {
|
||||
const service = module.get(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(CliInternalClientService);
|
||||
});
|
||||
|
||||
it('should provide AdminKeyService', () => {
|
||||
const service = module.get(AdminKeyService);
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(AdminKeyService);
|
||||
expect(service.getClient).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should provide InternalGraphQLClientFactory via token', () => {
|
||||
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
const factory = module.get(INTERNAL_CLIENT_FACTORY_TOKEN);
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory).toBeInstanceOf(InternalGraphQLClientFactory);
|
||||
});
|
||||
|
||||
describe('CliInternalClientService dependencies', () => {
|
||||
describe('CanonicalInternalClient dependencies', () => {
|
||||
it('should have all required dependencies available', () => {
|
||||
// This test ensures that CliInternalClientService can be instantiated
|
||||
// This test ensures that CanonicalInternalClient can be instantiated
|
||||
// with all its dependencies properly resolved
|
||||
const service = module.get(CliInternalClientService);
|
||||
const service = module.get(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
expect(service).toBeDefined();
|
||||
|
||||
// Verify the service has its dependencies injected
|
||||
@@ -59,16 +51,9 @@ describe('CliServicesModule', () => {
|
||||
|
||||
it('should resolve InternalGraphQLClientFactory dependency via token', () => {
|
||||
// Explicitly test that the factory is available in the module context via token
|
||||
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
const factory = module.get(INTERNAL_CLIENT_FACTORY_TOKEN);
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory.createClient).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve AdminKeyService dependency', () => {
|
||||
// Explicitly test that AdminKeyService is available in the module context
|
||||
const adminKeyService = module.get(AdminKeyService);
|
||||
expect(adminKeyService).toBeDefined();
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
@@ -12,7 +11,6 @@ import { ConfigCommand } from '@app/unraid-api/cli/config.command.js';
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js';
|
||||
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
|
||||
import {
|
||||
@@ -69,9 +67,7 @@ const DEFAULT_PROVIDERS = [
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { UPDATE_SANDBOX_MUTATION } from '@app/unraid-api/cli/queries/developer.mutation.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
@@ -52,12 +54,13 @@ unraid-dev-modal-test {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService
|
||||
) {}
|
||||
|
||||
async setSandboxMode(enable: boolean): Promise<void> {
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
|
||||
const result = await client.mutate({
|
||||
mutation: UPDATE_SANDBOX_MUTATION,
|
||||
|
||||
@@ -20,7 +20,7 @@ type Documents = {
|
||||
"\n mutation UpdateSandboxSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateSandboxSettingsDocument,
|
||||
"\n query GetPlugins {\n plugins {\n name\n version\n hasApiModule\n hasCliModule\n }\n }\n": typeof types.GetPluginsDocument,
|
||||
"\n query GetSSOUsers {\n settings {\n api {\n ssoSubIds\n }\n }\n }\n": typeof types.GetSsoUsersDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": typeof types.SystemReportDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": typeof types.SystemReportDocument,
|
||||
"\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": typeof types.ConnectStatusDocument,
|
||||
"\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": typeof types.ServicesDocument,
|
||||
"\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": typeof types.ValidateOidcSessionDocument,
|
||||
@@ -32,7 +32,7 @@ const documents: Documents = {
|
||||
"\n mutation UpdateSandboxSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateSandboxSettingsDocument,
|
||||
"\n query GetPlugins {\n plugins {\n name\n version\n hasApiModule\n hasCliModule\n }\n }\n": types.GetPluginsDocument,
|
||||
"\n query GetSSOUsers {\n settings {\n api {\n ssoSubIds\n }\n }\n }\n": types.GetSsoUsersDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": types.SystemReportDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": types.SystemReportDocument,
|
||||
"\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": types.ConnectStatusDocument,
|
||||
"\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": types.ServicesDocument,
|
||||
"\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": types.ValidateOidcSessionDocument,
|
||||
@@ -79,7 +79,7 @@ export function gql(source: "\n query GetSSOUsers {\n settings {\n
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function gql(source: "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"): (typeof documents)["\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"];
|
||||
export function gql(source: "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"): (typeof documents)["\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"];
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -120,7 +120,7 @@ export type ActivationCode = {
|
||||
};
|
||||
|
||||
export type AddPermissionInput = {
|
||||
actions: Array<Scalars['String']['input']>;
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -143,24 +143,36 @@ export type ApiKey = Node & {
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiKeyFormSettings = FormSchema & Node & {
|
||||
__typename?: 'ApiKeyFormSettings';
|
||||
/** The data schema for the API key form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** The UI schema for the API key form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the API key form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
/** API Key related mutations */
|
||||
export type ApiKeyMutations = {
|
||||
__typename?: 'ApiKeyMutations';
|
||||
/** Add a role to an API key */
|
||||
addRole: Scalars['Boolean']['output'];
|
||||
/** Create an API key */
|
||||
create: ApiKeyWithSecret;
|
||||
create: ApiKey;
|
||||
/** Delete one or more API keys */
|
||||
delete: Scalars['Boolean']['output'];
|
||||
/** Remove a role from an API key */
|
||||
removeRole: Scalars['Boolean']['output'];
|
||||
/** Update an API key */
|
||||
update: ApiKeyWithSecret;
|
||||
update: ApiKey;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,17 +211,6 @@ export type ApiKeyResponse = {
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ApiKeyWithSecret = Node & {
|
||||
__typename?: 'ApiKeyWithSecret';
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
/** Capacity in number of disks */
|
||||
@@ -370,19 +371,24 @@ export enum ArrayStateInputState {
|
||||
STOP = 'STOP'
|
||||
}
|
||||
|
||||
/** Available authentication action verbs */
|
||||
export enum AuthActionVerb {
|
||||
CREATE = 'CREATE',
|
||||
DELETE = 'DELETE',
|
||||
READ = 'READ',
|
||||
UPDATE = 'UPDATE'
|
||||
}
|
||||
|
||||
/** Available authentication possession types */
|
||||
export enum AuthPossession {
|
||||
ANY = 'ANY',
|
||||
OWN = 'OWN',
|
||||
OWN_ANY = 'OWN_ANY'
|
||||
/** Authentication actions with possession (e.g., create:any, read:own) */
|
||||
export enum AuthAction {
|
||||
/** Create any resource */
|
||||
CREATE_ANY = 'CREATE_ANY',
|
||||
/** Create own resource */
|
||||
CREATE_OWN = 'CREATE_OWN',
|
||||
/** Delete any resource */
|
||||
DELETE_ANY = 'DELETE_ANY',
|
||||
/** Delete own resource */
|
||||
DELETE_OWN = 'DELETE_OWN',
|
||||
/** Read any resource */
|
||||
READ_ANY = 'READ_ANY',
|
||||
/** Read own resource */
|
||||
READ_OWN = 'READ_OWN',
|
||||
/** Update any resource */
|
||||
UPDATE_ANY = 'UPDATE_ANY',
|
||||
/** Update own resource */
|
||||
UPDATE_OWN = 'UPDATE_OWN'
|
||||
}
|
||||
|
||||
/** Operators for authorization rule matching */
|
||||
@@ -399,16 +405,6 @@ export enum AuthorizationRuleMode {
|
||||
OR = 'OR'
|
||||
}
|
||||
|
||||
export type Baseboard = Node & {
|
||||
__typename?: 'Baseboard';
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Capacity = {
|
||||
__typename?: 'Capacity';
|
||||
/** Free capacity */
|
||||
@@ -419,15 +415,6 @@ export type Capacity = {
|
||||
used: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Case = Node & {
|
||||
__typename?: 'Case';
|
||||
base64?: Maybe<Scalars['String']['output']>;
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
icon?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Cloud = {
|
||||
__typename?: 'Cloud';
|
||||
allowedOrigins: Array<Scalars['String']['output']>;
|
||||
@@ -461,6 +448,20 @@ export enum ConfigErrorState {
|
||||
WITHDRAWN = 'WITHDRAWN'
|
||||
}
|
||||
|
||||
export type ConfigFile = {
|
||||
__typename?: 'ConfigFile';
|
||||
content: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
path: Scalars['String']['output'];
|
||||
/** Human-readable file size (e.g., "1.5 KB", "2.3 MB") */
|
||||
sizeReadable: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConfigFilesResponse = {
|
||||
__typename?: 'ConfigFilesResponse';
|
||||
files: Array<ConfigFile>;
|
||||
};
|
||||
|
||||
export type Connect = Node & {
|
||||
__typename?: 'Connect';
|
||||
/** The status of dynamic remote access */
|
||||
@@ -539,6 +540,42 @@ export enum ContainerState {
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type CoreVersions = {
|
||||
__typename?: 'CoreVersions';
|
||||
/** Unraid API version */
|
||||
api?: Maybe<Scalars['String']['output']>;
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** Unraid version */
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in user space. */
|
||||
percentUser: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuUtilization = Node & {
|
||||
__typename?: 'CpuUtilization';
|
||||
/** CPU load for each core */
|
||||
cpus: Array<CpuLoad>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Total CPU load in percent */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -569,14 +606,6 @@ export type DeleteRCloneRemoteInput = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Devices = Node & {
|
||||
__typename?: 'Devices';
|
||||
gpu: Array<Gpu>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
pci: Array<Pci>;
|
||||
usb: Array<Usb>;
|
||||
};
|
||||
|
||||
export type Disk = Node & {
|
||||
__typename?: 'Disk';
|
||||
/** The number of bytes per sector */
|
||||
@@ -653,31 +682,6 @@ export enum DiskSmartStatus {
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export type Display = Node & {
|
||||
__typename?: 'Display';
|
||||
banner?: Maybe<Scalars['String']['output']>;
|
||||
case?: Maybe<Case>;
|
||||
critical?: Maybe<Scalars['Int']['output']>;
|
||||
dashapps?: Maybe<Scalars['String']['output']>;
|
||||
date?: Maybe<Scalars['String']['output']>;
|
||||
hot?: Maybe<Scalars['Int']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
number?: Maybe<Scalars['String']['output']>;
|
||||
resize?: Maybe<Scalars['Boolean']['output']>;
|
||||
scale?: Maybe<Scalars['Boolean']['output']>;
|
||||
tabs?: Maybe<Scalars['Boolean']['output']>;
|
||||
text?: Maybe<Scalars['Boolean']['output']>;
|
||||
theme?: Maybe<ThemeName>;
|
||||
total?: Maybe<Scalars['Boolean']['output']>;
|
||||
unit?: Maybe<Temperature>;
|
||||
usage?: Maybe<Scalars['Boolean']['output']>;
|
||||
users?: Maybe<Scalars['String']['output']>;
|
||||
warning?: Maybe<Scalars['Int']['output']>;
|
||||
wwn?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containers: Array<DockerContainer>;
|
||||
@@ -792,80 +796,293 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Gpu = Node & {
|
||||
__typename?: 'Gpu';
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
typeid: Scalars['String']['output'];
|
||||
vendorname: Scalars['String']['output'];
|
||||
export type FormSchema = {
|
||||
/** The data schema for the form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
/** The UI schema for the form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Count of docker containers */
|
||||
apps: InfoApps;
|
||||
baseboard: Baseboard;
|
||||
/** Motherboard information */
|
||||
baseboard: InfoBaseboard;
|
||||
/** CPU information */
|
||||
cpu: InfoCpu;
|
||||
devices: Devices;
|
||||
display: Display;
|
||||
/** Device information */
|
||||
devices: InfoDevices;
|
||||
/** Display configuration */
|
||||
display: InfoDisplay;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Machine ID */
|
||||
machineId?: Maybe<Scalars['PrefixedID']['output']>;
|
||||
machineId?: Maybe<Scalars['ID']['output']>;
|
||||
/** Memory information */
|
||||
memory: InfoMemory;
|
||||
os: Os;
|
||||
system: System;
|
||||
/** Operating system information */
|
||||
os: InfoOs;
|
||||
/** System information */
|
||||
system: InfoSystem;
|
||||
/** Current server time */
|
||||
time: Scalars['DateTime']['output'];
|
||||
versions: Versions;
|
||||
/** Software versions */
|
||||
versions: InfoVersions;
|
||||
};
|
||||
|
||||
export type InfoApps = Node & {
|
||||
__typename?: 'InfoApps';
|
||||
export type InfoBaseboard = Node & {
|
||||
__typename?: 'InfoBaseboard';
|
||||
/** Motherboard asset tag */
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** How many docker containers are installed */
|
||||
installed: Scalars['Int']['output'];
|
||||
/** How many docker containers are running */
|
||||
started: Scalars['Int']['output'];
|
||||
/** Motherboard manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum memory capacity in bytes */
|
||||
memMax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Number of memory slots */
|
||||
memSlots?: Maybe<Scalars['Float']['output']>;
|
||||
/** Motherboard model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoCpu = Node & {
|
||||
__typename?: 'InfoCpu';
|
||||
brand: Scalars['String']['output'];
|
||||
cache: Scalars['JSON']['output'];
|
||||
cores: Scalars['Int']['output'];
|
||||
family: Scalars['String']['output'];
|
||||
flags: Array<Scalars['String']['output']>;
|
||||
/** CPU brand name */
|
||||
brand?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU cache information */
|
||||
cache?: Maybe<Scalars['JSON']['output']>;
|
||||
/** Number of CPU cores */
|
||||
cores?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU family */
|
||||
family?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU feature flags */
|
||||
flags?: Maybe<Array<Scalars['String']['output']>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model: Scalars['String']['output'];
|
||||
processors: Scalars['Int']['output'];
|
||||
revision: Scalars['String']['output'];
|
||||
socket: Scalars['String']['output'];
|
||||
speed: Scalars['Float']['output'];
|
||||
speedmax: Scalars['Float']['output'];
|
||||
speedmin: Scalars['Float']['output'];
|
||||
stepping: Scalars['Int']['output'];
|
||||
threads: Scalars['Int']['output'];
|
||||
vendor: Scalars['String']['output'];
|
||||
/** CPU manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of physical processors */
|
||||
processors?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU revision */
|
||||
revision?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU socket type */
|
||||
socket?: Maybe<Scalars['String']['output']>;
|
||||
/** Current CPU speed in GHz */
|
||||
speed?: Maybe<Scalars['Float']['output']>;
|
||||
/** Maximum CPU speed in GHz */
|
||||
speedmax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Minimum CPU speed in GHz */
|
||||
speedmin?: Maybe<Scalars['Float']['output']>;
|
||||
/** CPU stepping */
|
||||
stepping?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of CPU threads */
|
||||
threads?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU voltage */
|
||||
voltage?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoDevices = Node & {
|
||||
__typename?: 'InfoDevices';
|
||||
/** List of GPU devices */
|
||||
gpu?: Maybe<Array<InfoGpu>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of network interfaces */
|
||||
network?: Maybe<Array<InfoNetwork>>;
|
||||
/** List of PCI devices */
|
||||
pci?: Maybe<Array<InfoPci>>;
|
||||
/** List of USB devices */
|
||||
usb?: Maybe<Array<InfoUsb>>;
|
||||
};
|
||||
|
||||
export type InfoDisplay = Node & {
|
||||
__typename?: 'InfoDisplay';
|
||||
/** Case display configuration */
|
||||
case: InfoDisplayCase;
|
||||
/** Critical temperature threshold */
|
||||
critical: Scalars['Int']['output'];
|
||||
/** Hot temperature threshold */
|
||||
hot: Scalars['Int']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Locale setting */
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum temperature threshold */
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
/** Enable UI resize */
|
||||
resize: Scalars['Boolean']['output'];
|
||||
/** Enable UI scaling */
|
||||
scale: Scalars['Boolean']['output'];
|
||||
/** Show tabs in UI */
|
||||
tabs: Scalars['Boolean']['output'];
|
||||
/** Show text labels */
|
||||
text: Scalars['Boolean']['output'];
|
||||
/** UI theme name */
|
||||
theme: ThemeName;
|
||||
/** Show totals */
|
||||
total: Scalars['Boolean']['output'];
|
||||
/** Temperature unit (C or F) */
|
||||
unit: Temperature;
|
||||
/** Show usage statistics */
|
||||
usage: Scalars['Boolean']['output'];
|
||||
/** Warning temperature threshold */
|
||||
warning: Scalars['Int']['output'];
|
||||
/** Show WWN identifiers */
|
||||
wwn: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type InfoDisplayCase = Node & {
|
||||
__typename?: 'InfoDisplayCase';
|
||||
/** Base64 encoded case image */
|
||||
base64: Scalars['String']['output'];
|
||||
/** Error message if any */
|
||||
error: Scalars['String']['output'];
|
||||
/** Case icon identifier */
|
||||
icon: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Case image URL */
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoGpu = Node & {
|
||||
__typename?: 'InfoGpu';
|
||||
/** Whether GPU is blacklisted */
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** GPU type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** GPU type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoMemory = Node & {
|
||||
__typename?: 'InfoMemory';
|
||||
active: Scalars['BigInt']['output'];
|
||||
available: Scalars['BigInt']['output'];
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Physical memory layout */
|
||||
layout: Array<MemoryLayout>;
|
||||
max: Scalars['BigInt']['output'];
|
||||
swapfree: Scalars['BigInt']['output'];
|
||||
swaptotal: Scalars['BigInt']['output'];
|
||||
swapused: Scalars['BigInt']['output'];
|
||||
total: Scalars['BigInt']['output'];
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
export type InfoNetwork = Node & {
|
||||
__typename?: 'InfoNetwork';
|
||||
/** DHCP enabled flag */
|
||||
dhcp?: Maybe<Scalars['Boolean']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Network interface name */
|
||||
iface: Scalars['String']['output'];
|
||||
/** MAC address */
|
||||
mac?: Maybe<Scalars['String']['output']>;
|
||||
/** Network interface model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Network speed */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Network vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual interface flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoOs = Node & {
|
||||
__typename?: 'InfoOs';
|
||||
/** OS architecture */
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
/** OS build identifier */
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
/** OS codename */
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
/** Linux distribution name */
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
/** Fully qualified domain name */
|
||||
fqdn?: Maybe<Scalars['String']['output']>;
|
||||
/** Hostname */
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** OS logo name */
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
/** Operating system platform */
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
/** OS release version */
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
/** OS serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Service pack version */
|
||||
servicepack?: Maybe<Scalars['String']['output']>;
|
||||
/** OS started via UEFI */
|
||||
uefi?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Boot time ISO string */
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoPci = Node & {
|
||||
__typename?: 'InfoPci';
|
||||
/** Blacklisted status */
|
||||
blacklisted: Scalars['String']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** Product name */
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
/** Device type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** Type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor ID */
|
||||
vendorid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoSystem = Node & {
|
||||
__typename?: 'InfoSystem';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** System manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** System model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** System serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** System SKU */
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
/** System UUID */
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
/** System version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual machine flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoUsb = Node & {
|
||||
__typename?: 'InfoUsb';
|
||||
/** USB bus number */
|
||||
bus?: Maybe<Scalars['String']['output']>;
|
||||
/** USB device number */
|
||||
device?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** USB device name */
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoVersions = Node & {
|
||||
__typename?: 'InfoVersions';
|
||||
/** Core system versions */
|
||||
core: CoreVersions;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages?: Maybe<PackageVersions>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -911,20 +1128,68 @@ export type LogFileContent = {
|
||||
|
||||
export type MemoryLayout = Node & {
|
||||
__typename?: 'MemoryLayout';
|
||||
/** Memory bank location (e.g., BANK 0) */
|
||||
bank?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory clock speed in MHz */
|
||||
clockSpeed?: Maybe<Scalars['Int']['output']>;
|
||||
/** Form factor (e.g., DIMM, SODIMM) */
|
||||
formFactor?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Memory manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Part number of the memory module */
|
||||
partNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Serial number of the memory module */
|
||||
serialNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory module size in bytes */
|
||||
size: Scalars['BigInt']['output'];
|
||||
/** Memory type (e.g., DDR4, DDR5) */
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
/** Configured voltage in millivolts */
|
||||
voltageConfigured?: Maybe<Scalars['Int']['output']>;
|
||||
/** Maximum voltage in millivolts */
|
||||
voltageMax?: Maybe<Scalars['Int']['output']>;
|
||||
/** Minimum voltage in millivolts */
|
||||
voltageMin?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type MemoryUtilization = Node & {
|
||||
__typename?: 'MemoryUtilization';
|
||||
/** Active memory in bytes */
|
||||
active: Scalars['BigInt']['output'];
|
||||
/** Available memory in bytes */
|
||||
available: Scalars['BigInt']['output'];
|
||||
/** Buffer/cache memory in bytes */
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
/** Free memory in bytes */
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Swap usage percentage */
|
||||
percentSwapTotal: Scalars['Float']['output'];
|
||||
/** Memory usage percentage */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** Free swap memory in bytes */
|
||||
swapFree: Scalars['BigInt']['output'];
|
||||
/** Total swap memory in bytes */
|
||||
swapTotal: Scalars['BigInt']['output'];
|
||||
/** Used swap memory in bytes */
|
||||
swapUsed: Scalars['BigInt']['output'];
|
||||
/** Total system memory in bytes */
|
||||
total: Scalars['BigInt']['output'];
|
||||
/** Used memory in bytes */
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
/** System metrics including CPU and memory utilization */
|
||||
export type Metrics = Node & {
|
||||
__typename?: 'Metrics';
|
||||
/** Current CPU utilization metrics */
|
||||
cpu?: Maybe<CpuUtilization>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Current memory utilization metrics */
|
||||
memory?: Maybe<MemoryUtilization>;
|
||||
};
|
||||
|
||||
/** The status of the minigraph */
|
||||
export enum MinigraphStatus {
|
||||
CONNECTED = 'CONNECTED',
|
||||
@@ -1181,6 +1446,14 @@ export type OidcAuthorizationRule = {
|
||||
value: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type OidcConfiguration = {
|
||||
__typename?: 'OidcConfiguration';
|
||||
/** Default allowed redirect origins that apply to all OIDC providers (e.g., Tailscale domains) */
|
||||
defaultAllowedOrigins?: Maybe<Array<Scalars['String']['output']>>;
|
||||
/** List of configured OIDC providers */
|
||||
providers: Array<OidcProvider>;
|
||||
};
|
||||
|
||||
export type OidcProvider = {
|
||||
__typename?: 'OidcProvider';
|
||||
/** OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
@@ -1204,7 +1477,7 @@ export type OidcProvider = {
|
||||
/** The unique identifier for the OIDC provider */
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** OIDC issuer URL (e.g., https://accounts.google.com). Required for auto-discovery via /.well-known/openid-configuration */
|
||||
issuer: Scalars['String']['output'];
|
||||
issuer?: Maybe<Scalars['String']['output']>;
|
||||
/** JSON Web Key Set URI for token validation. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
jwksUri?: Maybe<Scalars['String']['output']>;
|
||||
/** Display name of the OIDC provider */
|
||||
@@ -1237,23 +1510,6 @@ export type OrganizerResource = {
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Os = Node & {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
codepage?: Maybe<Scalars['String']['output']>;
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
__typename?: 'Owner';
|
||||
avatar: Scalars['String']['output'];
|
||||
@@ -1261,6 +1517,26 @@ export type Owner = {
|
||||
username: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PackageVersions = {
|
||||
__typename?: 'PackageVersions';
|
||||
/** Docker version */
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
/** Git version */
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
/** nginx version */
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
/** Node.js version */
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
/** npm version */
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
/** OpenSSL version */
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
/** PHP version */
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
/** pm2 version */
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ParityCheck = {
|
||||
__typename?: 'ParityCheck';
|
||||
/** Whether corrections are being written to parity */
|
||||
@@ -1280,7 +1556,7 @@ export type ParityCheck = {
|
||||
/** Speed of the parity check, in MB/s */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Status of the parity check */
|
||||
status?: Maybe<Scalars['String']['output']>;
|
||||
status: ParityCheckStatus;
|
||||
};
|
||||
|
||||
/** Parity check related mutations, WIP, response types and functionaliy will change */
|
||||
@@ -1302,22 +1578,19 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type Pci = Node & {
|
||||
__typename?: 'Pci';
|
||||
blacklisted?: Maybe<Scalars['String']['output']>;
|
||||
class?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid?: Maybe<Scalars['String']['output']>;
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
typeid?: Maybe<Scalars['String']['output']>;
|
||||
vendorid?: Maybe<Scalars['String']['output']>;
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
export enum ParityCheckStatus {
|
||||
CANCELLED = 'CANCELLED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
NEVER_RUN = 'NEVER_RUN',
|
||||
PAUSED = 'PAUSED',
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
/** Actions allowed on this resource */
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -1372,6 +1645,7 @@ export type PublicPartnerInfo = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
allConfigFiles: ConfigFilesResponse;
|
||||
apiKey?: Maybe<ApiKey>;
|
||||
/** All possible permissions for API keys */
|
||||
apiKeyPossiblePermissions: Array<Permission>;
|
||||
@@ -1381,22 +1655,31 @@ export type Query = {
|
||||
array: UnraidArray;
|
||||
cloud: Cloud;
|
||||
config: Config;
|
||||
configFile?: Maybe<ConfigFile>;
|
||||
connect: Connect;
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
disks: Array<Disk>;
|
||||
display: Display;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
/** Get JSON Schema for API key creation form */
|
||||
getApiKeyCreationFormSchema: ApiKeyFormSettings;
|
||||
/** Get all available authentication actions with possession */
|
||||
getAvailableAuthActions: Array<AuthAction>;
|
||||
/** Get the actual permissions that would be granted by a set of roles */
|
||||
getPermissionsForRoles: Array<Permission>;
|
||||
info: Info;
|
||||
isInitialSetup: Scalars['Boolean']['output'];
|
||||
isSSOEnabled: Scalars['Boolean']['output'];
|
||||
logFile: LogFileContent;
|
||||
logFiles: Array<LogFile>;
|
||||
me: UserAccount;
|
||||
metrics: Metrics;
|
||||
network: Network;
|
||||
/** Get all notifications */
|
||||
notifications: Notifications;
|
||||
/** Get the full OIDC configuration (admin only) */
|
||||
oidcConfiguration: OidcConfiguration;
|
||||
/** Get a specific OIDC provider by ID */
|
||||
oidcProvider?: Maybe<OidcProvider>;
|
||||
/** Get all configured OIDC providers (admin only) */
|
||||
@@ -1406,6 +1689,8 @@ export type Query = {
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Preview the effective permissions for a combination of roles and explicit permissions */
|
||||
previewEffectivePermissions: Array<Permission>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
@@ -1434,11 +1719,21 @@ export type QueryApiKeyArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryConfigFileArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryDiskArgs = {
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetPermissionsForRolesArgs = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryLogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
@@ -1451,6 +1746,12 @@ export type QueryOidcProviderArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryPreviewEffectivePermissionsArgs = {
|
||||
permissions?: InputMaybe<Array<AddPermissionInput>>;
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryUpsDeviceByIdArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1643,10 +1944,14 @@ export enum Resource {
|
||||
|
||||
/** Available roles for API keys and users */
|
||||
export enum Role {
|
||||
/** Full administrative access to all resources */
|
||||
ADMIN = 'ADMIN',
|
||||
/** Internal Role for Unraid Connect */
|
||||
CONNECT = 'CONNECT',
|
||||
/** Basic read access to user profile only */
|
||||
GUEST = 'GUEST',
|
||||
USER = 'USER'
|
||||
/** Read-only access to all resources */
|
||||
VIEWER = 'VIEWER'
|
||||
}
|
||||
|
||||
export type Server = Node & {
|
||||
@@ -1659,6 +1964,7 @@ export type Server = Node & {
|
||||
name: Scalars['String']['output'];
|
||||
owner: ProfileModel;
|
||||
remoteurl: Scalars['String']['output'];
|
||||
/** Whether this server is online or offline */
|
||||
status: ServerStatus;
|
||||
wanip: Scalars['String']['output'];
|
||||
};
|
||||
@@ -1743,14 +2049,14 @@ export type SsoSettings = Node & {
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
arraySubscription: UnraidArray;
|
||||
displaySubscription: Display;
|
||||
infoSubscription: Info;
|
||||
logFile: LogFileContent;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
ownerSubscription: Owner;
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
systemMetricsCpu: CpuUtilization;
|
||||
systemMetricsMemory: MemoryUtilization;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
|
||||
@@ -1759,21 +2065,10 @@ export type SubscriptionLogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type System = Node & {
|
||||
__typename?: 'System';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Temperature unit (Celsius or Fahrenheit) */
|
||||
/** Temperature unit */
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F'
|
||||
CELSIUS = 'CELSIUS',
|
||||
FAHRENHEIT = 'FAHRENHEIT'
|
||||
}
|
||||
|
||||
export type Theme = {
|
||||
@@ -1934,7 +2229,7 @@ export enum UrlType {
|
||||
WIREGUARD = 'WIREGUARD'
|
||||
}
|
||||
|
||||
export type UnifiedSettings = Node & {
|
||||
export type UnifiedSettings = FormSchema & Node & {
|
||||
__typename?: 'UnifiedSettings';
|
||||
/** The data schema for the settings */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
@@ -1958,6 +2253,8 @@ export type UnraidArray = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Parity disks in the current array */
|
||||
parities: Array<ArrayDisk>;
|
||||
/** Current parity check status */
|
||||
parityCheckStatus: ParityCheck;
|
||||
/** Current array state */
|
||||
state: ArrayState;
|
||||
};
|
||||
@@ -1985,12 +2282,6 @@ export type Uptime = {
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Usb = Node & {
|
||||
__typename?: 'Usb';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserAccount = Node & {
|
||||
__typename?: 'UserAccount';
|
||||
/** A description of the user */
|
||||
@@ -2168,37 +2459,6 @@ export type Vars = Node & {
|
||||
workgroup?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Versions = Node & {
|
||||
__typename?: 'Versions';
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
systemOpensslLib?: Maybe<Scalars['String']['output']>;
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type VmDomain = Node & {
|
||||
__typename?: 'VmDomain';
|
||||
/** The unique identifier for the vm (uuid) */
|
||||
@@ -2349,7 +2609,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?:
|
||||
export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: any | null, system: { __typename?: 'System', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'Versions', unraid?: string | null, kernel?: string | null, openssl?: string | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages?: { __typename?: 'PackageVersions', openssl?: string | null } | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
|
||||
export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -2375,7 +2635,7 @@ export const UpdateSsoUsersDocument = {"kind":"Document","definitions":[{"kind":
|
||||
export const UpdateSandboxSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSandboxSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateSandboxSettingsMutation, UpdateSandboxSettingsMutationVariables>;
|
||||
export const GetPluginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"hasApiModule"}},{"kind":"Field","name":{"kind":"Name","value":"hasCliModule"}}]}}]}}]} as unknown as DocumentNode<GetPluginsQuery, GetPluginsQueryVariables>;
|
||||
export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSSOUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"api"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ssoSubIds"}}]}}]}}]}}]} as unknown as DocumentNode<GetSsoUsersQuery, GetSsoUsersQueryVariables>;
|
||||
export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}},{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<SystemReportQuery, SystemReportQueryVariables>;
|
||||
export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}}]}},{"kind":"Field","name":{"kind":"Name","value":"packages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<SystemReportQuery, SystemReportQueryVariables>;
|
||||
export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode<ConnectStatusQuery, ConnectStatusQueryVariables>;
|
||||
export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode<ServicesQuery, ServicesQueryVariables>;
|
||||
export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode<ValidateOidcSessionQuery, ValidateOidcSessionQueryVariables>;
|
||||
@@ -1,203 +0,0 @@
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import type { InternalGraphQLClientFactory } from '@unraid/shared';
|
||||
import { ApolloClient } from '@apollo/client/core/index.js';
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
|
||||
describe('CliInternalClientService', () => {
|
||||
let service: CliInternalClientService;
|
||||
let clientFactory: InternalGraphQLClientFactory;
|
||||
let adminKeyService: AdminKeyService;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockApolloClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot()],
|
||||
providers: [
|
||||
CliInternalClientService,
|
||||
{
|
||||
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
|
||||
useValue: {
|
||||
createClient: vi.fn().mockResolvedValue(mockApolloClient),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AdminKeyService,
|
||||
useValue: {
|
||||
getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CliInternalClientService>(CliInternalClientService);
|
||||
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
adminKeyService = module.get<AdminKeyService>(AdminKeyService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module?.close();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('dependency injection', () => {
|
||||
it('should have InternalGraphQLClientFactory injected', () => {
|
||||
expect(clientFactory).toBeDefined();
|
||||
expect(clientFactory.createClient).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have AdminKeyService injected', () => {
|
||||
expect(adminKeyService).toBeDefined();
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('should create a client with getApiKey function', async () => {
|
||||
const client = await service.getClient();
|
||||
|
||||
// The API key is now fetched lazily, not immediately
|
||||
expect(clientFactory.createClient).toHaveBeenCalledWith({
|
||||
getApiKey: expect.any(Function),
|
||||
enableSubscriptions: false,
|
||||
});
|
||||
|
||||
// Verify the getApiKey function works correctly when called
|
||||
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
|
||||
const apiKey = await callArgs.getApiKey();
|
||||
expect(apiKey).toBe('test-admin-key');
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled();
|
||||
|
||||
expect(client).toBe(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should return cached client on subsequent calls', async () => {
|
||||
const client1 = await service.getClient();
|
||||
const client2 = await service.getClient();
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors when getting admin key', async () => {
|
||||
const error = new Error('Failed to get admin key');
|
||||
vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error);
|
||||
|
||||
// The client creation will succeed, but the API key error happens later
|
||||
const client = await service.getClient();
|
||||
expect(client).toBe(mockApolloClient);
|
||||
|
||||
// Now test that the getApiKey function throws the expected error
|
||||
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
|
||||
await expect(callArgs.getApiKey()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearClient', () => {
|
||||
it('should stop and clear the client', async () => {
|
||||
// First create a client
|
||||
await service.getClient();
|
||||
|
||||
// Clear the client
|
||||
service.clearClient();
|
||||
|
||||
expect(mockApolloClient.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle clearing when no client exists', () => {
|
||||
// Should not throw when clearing a non-existent client
|
||||
expect(() => service.clearClient()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create a new client after clearing', async () => {
|
||||
// Create initial client
|
||||
await service.getClient();
|
||||
|
||||
// Clear it
|
||||
service.clearClient();
|
||||
|
||||
// Create new client
|
||||
await service.getClient();
|
||||
|
||||
// Should have created client twice
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('race condition protection', () => {
|
||||
it('should prevent stale client resurrection when clearClient() is called during creation', async () => {
|
||||
let resolveClientCreation!: (client: any) => void;
|
||||
|
||||
// Mock createClient to return a controllable promise
|
||||
const clientCreationPromise = new Promise<any>((resolve) => {
|
||||
resolveClientCreation = resolve;
|
||||
});
|
||||
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
|
||||
|
||||
// Start client creation (but don't await yet)
|
||||
const getClientPromise = service.getClient();
|
||||
|
||||
// Clear the client while creation is in progress
|
||||
service.clearClient();
|
||||
|
||||
// Now complete the client creation
|
||||
resolveClientCreation(mockApolloClient);
|
||||
|
||||
// Wait for getClient to complete
|
||||
const client = await getClientPromise;
|
||||
|
||||
// The client should be returned from getClient
|
||||
expect(client).toBe(mockApolloClient);
|
||||
|
||||
// But subsequent getClient calls should create a new client
|
||||
// because the race condition protection prevented assignment
|
||||
await service.getClient();
|
||||
|
||||
// Should have created a second client, proving the first wasn't assigned
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle concurrent getClient calls during race condition', async () => {
|
||||
let resolveClientCreation!: (client: any) => void;
|
||||
|
||||
// Mock createClient to return a controllable promise
|
||||
const clientCreationPromise = new Promise<any>((resolve) => {
|
||||
resolveClientCreation = resolve;
|
||||
});
|
||||
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
|
||||
|
||||
// Start multiple concurrent client creation calls
|
||||
const getClientPromise1 = service.getClient();
|
||||
const getClientPromise2 = service.getClient(); // Should wait for first one
|
||||
|
||||
// Clear the client while creation is in progress
|
||||
service.clearClient();
|
||||
|
||||
// Complete the client creation
|
||||
resolveClientCreation(mockApolloClient);
|
||||
|
||||
// Both calls should resolve with the same client
|
||||
const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]);
|
||||
expect(client1).toBe(mockApolloClient);
|
||||
expect(client2).toBe(mockApolloClient);
|
||||
|
||||
// But the client should not be cached due to race condition protection
|
||||
await service.getClient();
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { InternalGraphQLClientFactory } from '@unraid/shared';
|
||||
import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js';
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
|
||||
/**
|
||||
* Internal GraphQL client for CLI commands.
|
||||
*
|
||||
* This service creates an Apollo client that queries the local API server
|
||||
* with admin privileges for CLI operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CliInternalClientService {
|
||||
private readonly logger = new Logger(CliInternalClientService.name);
|
||||
private client: ApolloClient<NormalizedCacheObject> | null = null;
|
||||
private creatingClient: Promise<ApolloClient<NormalizedCacheObject>> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(INTERNAL_CLIENT_SERVICE_TOKEN)
|
||||
private readonly clientFactory: InternalGraphQLClientFactory,
|
||||
private readonly adminKeyService: AdminKeyService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the admin API key using the AdminKeyService.
|
||||
* This ensures the key exists and is available for CLI operations.
|
||||
*/
|
||||
private async getLocalApiKey(): Promise<string> {
|
||||
try {
|
||||
return await this.adminKeyService.getOrCreateLocalAdminKey();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get admin API key:', error);
|
||||
throw new Error(
|
||||
'Unable to get admin API key for internal client. Ensure the API server is running.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default CLI client with admin API key.
|
||||
* This is for CLI commands that need admin access.
|
||||
*/
|
||||
public async getClient(): Promise<ApolloClient<NormalizedCacheObject>> {
|
||||
// If client already exists, return it
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
// If another call is already creating the client, wait for it
|
||||
if (this.creatingClient) {
|
||||
return await this.creatingClient;
|
||||
}
|
||||
|
||||
// Start creating the client with race condition protection
|
||||
let creationPromise!: Promise<ApolloClient<NormalizedCacheObject>>;
|
||||
// eslint-disable-next-line prefer-const
|
||||
creationPromise = (async () => {
|
||||
try {
|
||||
const client = await this.clientFactory.createClient({
|
||||
getApiKey: () => this.getLocalApiKey(),
|
||||
enableSubscriptions: false, // CLI doesn't need subscriptions
|
||||
});
|
||||
|
||||
// awaiting *before* checking this.creatingClient is important!
|
||||
// by yielding to the event loop, it ensures
|
||||
// `this.creatingClient = creationPromise;` is executed before the next check.
|
||||
|
||||
// This prevents race conditions where the client is assigned to the wrong instance.
|
||||
// Only assign client if this creation is still current
|
||||
if (this.creatingClient === creationPromise) {
|
||||
this.client = client;
|
||||
this.logger.debug('Created CLI internal GraphQL client with admin privileges');
|
||||
}
|
||||
|
||||
return client;
|
||||
} finally {
|
||||
// Only clear if this creation is still current
|
||||
if (this.creatingClient === creationPromise) {
|
||||
this.creatingClient = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this.creatingClient = creationPromise;
|
||||
return await creationPromise;
|
||||
}
|
||||
|
||||
public clearClient() {
|
||||
// Stop the Apollo client to terminate any active processes
|
||||
this.client?.stop();
|
||||
this.client = null;
|
||||
this.creatingClient = null;
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,13 @@ export const SYSTEM_REPORT_QUERY = gql(`
|
||||
uuid
|
||||
}
|
||||
versions {
|
||||
unraid
|
||||
kernel
|
||||
openssl
|
||||
core {
|
||||
unraid
|
||||
kernel
|
||||
}
|
||||
packages {
|
||||
openssl
|
||||
}
|
||||
}
|
||||
}
|
||||
config {
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { ECOSYSTEM_PATH } from '@app/environment.js';
|
||||
import type { LogLevel } from '@app/core/log.js';
|
||||
import { levels } from '@app/core/log.js';
|
||||
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
|
||||
export interface LogLevelOptions {
|
||||
logLevel?: LogLevel;
|
||||
}
|
||||
|
||||
export function parseLogLevelOption(val: string, allowedLevels: string[] = [...levels]): LogLevel {
|
||||
const normalized = val.toLowerCase() as LogLevel;
|
||||
if (allowedLevels.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Invalid --log-level "${val}". Allowed: ${allowedLevels.join(', ')}`);
|
||||
}
|
||||
|
||||
@Command({ name: 'restart', description: 'Restart the Unraid API' })
|
||||
export class RestartCommand extends CommandRunner {
|
||||
constructor(
|
||||
@@ -13,11 +27,12 @@ export class RestartCommand extends CommandRunner {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
async run(_?: string[], options: LogLevelOptions = {}): Promise<void> {
|
||||
try {
|
||||
this.logger.info('Restarting the Unraid API...');
|
||||
const env = { LOG_LEVEL: options.logLevel };
|
||||
const { stderr, stdout } = await this.pm2.run(
|
||||
{ tag: 'PM2 Restart', raw: true },
|
||||
{ tag: 'PM2 Restart', raw: true, extendEnv: true, env },
|
||||
'restart',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
@@ -40,4 +55,13 @@ export class RestartCommand extends CommandRunner {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: `--log-level <${levels.join('|')}>`,
|
||||
description: 'log level to use',
|
||||
defaultValue: LOG_LEVEL.toLowerCase(),
|
||||
})
|
||||
parseLogLevel(val: string): LogLevel {
|
||||
return parseLogLevelOption(val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { CommandRunner, SubCommand } from 'nest-commander';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { VALIDATE_OIDC_SESSION_QUERY } from '@app/unraid-api/cli/queries/validate-oidc-session.query.js';
|
||||
|
||||
@@ -13,7 +16,8 @@ import { VALIDATE_OIDC_SESSION_QUERY } from '@app/unraid-api/cli/queries/validat
|
||||
export class ValidateTokenCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export class ValidateTokenCommand extends CommandRunner {
|
||||
|
||||
private async validateOidcToken(token: string): Promise<void> {
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
const { data, errors } = await client.query({
|
||||
query: VALIDATE_OIDC_SESSION_QUERY,
|
||||
variables: { token },
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import type { LogLevel } from '@app/core/log.js';
|
||||
import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { levels } from '@app/core/log.js';
|
||||
import { ECOSYSTEM_PATH } from '@app/environment.js';
|
||||
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
|
||||
interface StartCommandOptions {
|
||||
'log-level'?: string;
|
||||
}
|
||||
import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js';
|
||||
|
||||
@Command({ name: 'start', description: 'Start the Unraid API' })
|
||||
export class StartCommand extends CommandRunner {
|
||||
@@ -27,17 +25,12 @@ export class StartCommand extends CommandRunner {
|
||||
await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH);
|
||||
}
|
||||
|
||||
async run(_: string[], options: StartCommandOptions): Promise<void> {
|
||||
async run(_: string[], options: LogLevelOptions): Promise<void> {
|
||||
this.logger.info('Starting the Unraid API');
|
||||
await this.cleanupPM2State();
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
if (options['log-level']) {
|
||||
env.LOG_LEVEL = options['log-level'];
|
||||
}
|
||||
|
||||
const env = { LOG_LEVEL: options.logLevel };
|
||||
const { stderr, stdout } = await this.pm2.run(
|
||||
{ tag: 'PM2 Start', env, raw: true },
|
||||
{ tag: 'PM2 Start', raw: true, extendEnv: true, env },
|
||||
'start',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
@@ -54,9 +47,9 @@ export class StartCommand extends CommandRunner {
|
||||
@Option({
|
||||
flags: `--log-level <${levels.join('|')}>`,
|
||||
description: 'log level to use',
|
||||
defaultValue: 'info',
|
||||
defaultValue: LOG_LEVEL.toLowerCase(),
|
||||
})
|
||||
parseLogLevel(val: string): LogLevel {
|
||||
return levels.includes(val as LogLevel) ? (val as LogLevel) : 'info';
|
||||
return parseLogLevelOption(val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
|
||||
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
imports: [],
|
||||
providers: [WriteFlashFileService, LogRotateService],
|
||||
})
|
||||
export class CronModule {}
|
||||
|
||||
3
api/src/unraid-api/graph/auth/auth-action.enum.ts
Normal file
3
api/src/unraid-api/graph/auth/auth-action.enum.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// All enum registrations have been moved to @unraid/shared/graphql.model.js
|
||||
// Just re-export AuthAction for convenience
|
||||
export { AuthAction } from '@unraid/shared/graphql.model.js';
|
||||
@@ -1,52 +1,3 @@
|
||||
import { DirectiveLocation, GraphQLDirective, GraphQLEnumType, GraphQLString } from 'graphql';
|
||||
import { AuthActionVerb, AuthPossession } from 'nest-authz';
|
||||
|
||||
// Create GraphQL enum types for auth action verbs and possessions
|
||||
export const AuthActionVerbEnum = new GraphQLEnumType({
|
||||
name: 'AuthActionVerb',
|
||||
description: 'Available authentication action verbs',
|
||||
values: Object.entries(AuthActionVerb)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
export const AuthPossessionEnum = new GraphQLEnumType({
|
||||
name: 'AuthPossession',
|
||||
description: 'Available authentication possession types',
|
||||
values: Object.entries(AuthPossession)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
// Create the auth directive
|
||||
export const AuthDirective = new GraphQLDirective({
|
||||
name: 'auth',
|
||||
description: 'Directive to control access to fields based on authentication',
|
||||
locations: [DirectiveLocation.FIELD_DEFINITION],
|
||||
args: {
|
||||
action: {
|
||||
type: AuthActionVerbEnum,
|
||||
description: 'The action verb required for access',
|
||||
},
|
||||
resource: {
|
||||
type: GraphQLString,
|
||||
description: 'The resource required for access',
|
||||
},
|
||||
possession: {
|
||||
type: AuthPossessionEnum,
|
||||
description: 'The possession type required for access',
|
||||
},
|
||||
},
|
||||
});
|
||||
// Resource and Role enums are already registered in @unraid/shared/graphql.model.js
|
||||
// Just re-export them here for convenience
|
||||
export { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@@ -12,6 +12,10 @@ import { NoUnusedVariablesRule } from 'graphql';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
|
||||
// Import enum registrations to ensure they're registered with GraphQL
|
||||
import '@app/unraid-api/graph/auth/auth-action.enum.js';
|
||||
|
||||
import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js';
|
||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||
import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyFormSettings } from '@app/unraid-api/graph/resolvers/settings/settings.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyFormResolver {
|
||||
constructor(private apiKeyFormService: ApiKeyFormService) {}
|
||||
|
||||
@Query(() => ApiKeyFormSettings, {
|
||||
description: 'Get JSON Schema for API key creation form',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
})
|
||||
getApiKeyCreationFormSchema(): ApiKeyFormSettings {
|
||||
return this.apiKeyFormService.getApiKeyCreationFormSchema();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiKeyFormData,
|
||||
ApiKeyFormService,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
|
||||
describe('ApiKeyFormService', () => {
|
||||
let service: ApiKeyFormService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ApiKeyFormService();
|
||||
});
|
||||
|
||||
describe('convertFormDataToPermissions', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should merge roles and custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.ADMIN],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.ADMIN]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle only roles when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple roles', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER, Role.ADMIN]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle only custom permissions when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.ARRAY, Resource.DISK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.ARRAY,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DISK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty form data', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom permissions handling', () => {
|
||||
it('should merge custom permissions with same resource', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: expect.arrayContaining([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate actions when merging', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
const networkPermission = result.permissions.find(
|
||||
(p) => p.resource === Resource.NETWORK
|
||||
);
|
||||
expect(networkPermission?.actions).toHaveLength(3);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle resources as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: Resource.DOCKER as any,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle actions as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: AuthAction.READ_ANY as any,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty arrays gracefully', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [],
|
||||
customPermissions: [],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle both roles and custom permissions together', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.VIEWER],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER, Resource.VMS],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.VIEWER]);
|
||||
expect(result.permissions).toHaveLength(3);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { JsonSchema, LabelElement, UISchemaElement } from '@jsonforms/core';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
|
||||
import { normalizeAction } from '@unraid/shared/util/permissions.js';
|
||||
import { capitalCase } from 'change-case';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import {
|
||||
createLabeledControl,
|
||||
createSimpleLabeledControl,
|
||||
} from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
|
||||
// Helper to get GraphQL enum names for JSON Schema
|
||||
// GraphQL expects the enum names (keys) not the values
|
||||
function getAuthActionEnumNames(): string[] {
|
||||
// Get only the "_ANY" actions (not "_OWN")
|
||||
// e.g., CREATE_ANY, READ_ANY, UPDATE_ANY, DELETE_ANY
|
||||
return Object.keys(AuthAction).filter((key) => key === key.toUpperCase() && key.endsWith('_ANY'));
|
||||
}
|
||||
|
||||
// Helper to create labels for AuthAction enum dynamically
|
||||
function getAuthActionLabels(): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const enumName of getAuthActionEnumNames()) {
|
||||
// Convert CREATE_ANY -> Create (All)
|
||||
// Convert READ_OWN -> Read (Own)
|
||||
const [verb, possession] = enumName.split('_');
|
||||
const verbLabel = capitalCase(verb.toLowerCase());
|
||||
const possessionLabel = possession === 'ANY' ? 'All' : 'Own';
|
||||
labels[enumName] = `${verbLabel} (${possessionLabel})`;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
export interface ApiKeyFormData {
|
||||
name: string;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissionPresets?: string; // Single preset selection from dropdown
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[]; // Form uses array for multi-select
|
||||
actions: string[];
|
||||
}>;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyFormService {
|
||||
/**
|
||||
* Generate form schema for API key creation
|
||||
*/
|
||||
getApiKeyCreationFormSchema(): {
|
||||
id: string;
|
||||
dataSchema: Record<string, any>;
|
||||
uiSchema: Record<string, any>;
|
||||
values: Record<string, any>;
|
||||
} {
|
||||
const slice = this.createApiKeyCreationSlice();
|
||||
const merged = mergeSettingSlices([slice]);
|
||||
|
||||
return {
|
||||
id: 'api-key-creation-form',
|
||||
dataSchema: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: merged.properties,
|
||||
},
|
||||
uiSchema: {
|
||||
type: 'VerticalLayout',
|
||||
elements: merged.elements,
|
||||
},
|
||||
values: {},
|
||||
};
|
||||
}
|
||||
|
||||
private createApiKeyCreationSlice(): SettingSlice {
|
||||
const slice: SettingSlice = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
title: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
maxLength: 500,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
title: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableRoles(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
},
|
||||
permissionPresets: {
|
||||
type: 'string',
|
||||
title: 'Add Permission Preset',
|
||||
description: 'Quick add common permission sets',
|
||||
enum: [
|
||||
'none',
|
||||
'docker_manager',
|
||||
'vm_manager',
|
||||
'monitoring',
|
||||
'backup_manager',
|
||||
'network_admin',
|
||||
],
|
||||
default: 'none',
|
||||
},
|
||||
customPermissions: {
|
||||
type: 'array',
|
||||
title: 'Permissions',
|
||||
description: 'Configure specific permissions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resources: {
|
||||
type: 'array',
|
||||
title: 'Resources',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableResources(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: [this.getAvailableResources()[0]], // Set a default value as array
|
||||
},
|
||||
actions: {
|
||||
type: 'array',
|
||||
title: 'Actions',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: getAuthActionEnumNames(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: ['READ_ANY'], // Set a default action
|
||||
},
|
||||
},
|
||||
required: ['resources', 'actions'],
|
||||
},
|
||||
},
|
||||
// Commenting out expiration date until date picker is implemented
|
||||
// expiresAt: {
|
||||
// type: 'string',
|
||||
// format: 'date-time',
|
||||
// title: 'Expiration Date',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// },
|
||||
},
|
||||
elements: [
|
||||
createLabeledControl({
|
||||
scope: '#/properties/name',
|
||||
label: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
inputType: 'text',
|
||||
},
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/description',
|
||||
label: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multi: true,
|
||||
rows: 3,
|
||||
},
|
||||
}),
|
||||
// Permissions section header
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions Configuration',
|
||||
options: {
|
||||
format: 'title',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Select any combination of roles, permission groups, and custom permissions to define what this API key can access.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Roles selection
|
||||
createLabeledControl({
|
||||
scope: '#/properties/roles',
|
||||
label: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableRoles().reduce(
|
||||
(acc, role) => ({
|
||||
...acc,
|
||||
[role]: capitalCase(role),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
descriptions: this.getRoleDescriptions(),
|
||||
},
|
||||
}),
|
||||
// Separator for permissions
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions',
|
||||
options: {
|
||||
format: 'subtitle',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Use the preset dropdown for common permission sets, or manually add custom permissions. You can select multiple resources that share the same actions.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Permission preset dropdown
|
||||
createLabeledControl({
|
||||
scope: '#/properties/permissionPresets',
|
||||
label: 'Quick Add Presets',
|
||||
description: 'Select a preset to quickly add common permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
labels: {
|
||||
none: '-- Select a preset --',
|
||||
docker_manager: 'Docker Manager (Full Docker Control)',
|
||||
vm_manager: 'VM Manager (Full VM Control)',
|
||||
monitoring: 'Monitoring (Read-only System Info)',
|
||||
backup_manager: 'Backup Manager (Flash & Share Control)',
|
||||
network_admin: 'Network Admin (Network & Services Control)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Custom permissions array - following OIDC pattern exactly
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/customPermissions',
|
||||
options: {
|
||||
elementLabelFormat: 'Permission Entry',
|
||||
itemTypeName: 'Permission',
|
||||
detail: {
|
||||
type: 'VerticalLayout',
|
||||
elements: [
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/resources',
|
||||
label: 'Resources:',
|
||||
description: 'Select the resources to grant permissions for',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableResources().reduce(
|
||||
(acc, resource) => ({
|
||||
...acc,
|
||||
[resource]: capitalCase(
|
||||
resource.toLowerCase().replace(/_/g, ' ')
|
||||
),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
},
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/actions',
|
||||
label: 'Actions:',
|
||||
description: 'Select the actions allowed on this resource',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: getAuthActionLabels(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} as UISchemaElement,
|
||||
// Note: Datetime inputs are not currently supported in the renderer
|
||||
// Would need to implement a date picker component
|
||||
// For now, commenting out the expiration date field
|
||||
// createLabeledControl({
|
||||
// scope: '#/properties/expiresAt',
|
||||
// label: 'Expiration Date:',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// controlOptions: {
|
||||
// inputType: 'datetime-local',
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
};
|
||||
|
||||
return slice;
|
||||
}
|
||||
|
||||
private getAvailableRoles(): Role[] {
|
||||
return [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST];
|
||||
}
|
||||
|
||||
private getRoleDescriptions(): Record<Role, string> {
|
||||
return {
|
||||
[Role.ADMIN]: 'Full administrative access to all resources',
|
||||
[Role.VIEWER]: 'Read-only access to all resources',
|
||||
[Role.CONNECT]: 'Internal Role for Unraid Connect',
|
||||
[Role.GUEST]: 'Basic read access to user profile only',
|
||||
};
|
||||
}
|
||||
|
||||
private getAvailableResources(): Resource[] {
|
||||
return Object.values(Resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert form data back to permissions for API key creation
|
||||
* The form provides: name, description, roles, and customPermissions
|
||||
* Note: permissionPresets is only a UI helper that adds to customPermissions
|
||||
*/
|
||||
convertFormDataToPermissions(formData: ApiKeyFormData): {
|
||||
roles: Role[];
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
|
||||
} {
|
||||
const roles: Role[] = [];
|
||||
const permissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// 1. Add roles if provided
|
||||
if (formData.roles && formData.roles.length > 0) {
|
||||
roles.push(...formData.roles);
|
||||
}
|
||||
|
||||
// 2. Add custom permissions if provided
|
||||
// This includes permissions added via the preset dropdown
|
||||
if (formData.customPermissions && formData.customPermissions.length > 0) {
|
||||
for (const perm of formData.customPermissions) {
|
||||
// Handle resources as an array (form uses multi-select)
|
||||
const resources = Array.isArray(perm.resources)
|
||||
? perm.resources
|
||||
: [perm.resources as Resource];
|
||||
|
||||
// Handle actions as an array and normalize them
|
||||
const rawActions = Array.isArray(perm.actions) ? perm.actions : [perm.actions];
|
||||
const normalizedActions: AuthAction[] = [];
|
||||
|
||||
for (const rawAction of rawActions) {
|
||||
const normalized = normalizeAction(rawAction);
|
||||
if (normalized) {
|
||||
normalizedActions.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
if (!permissions.has(resource)) {
|
||||
permissions.set(resource, new Set());
|
||||
}
|
||||
normalizedActions.forEach((action) => permissions.get(resource)!.add(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions: Array.from(permissions.entries()).map(([resource, actions]) => ({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import {
|
||||
expandWildcardAction,
|
||||
mergePermissionsIntoMap,
|
||||
parseActionToAuthAction,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyPermissionsResolver {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description: 'Get the actual permissions that would be granted by a set of roles',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async getPermissionsForRoles(
|
||||
@Args('roles', { type: () => [Role] }) roles: Role[]
|
||||
): Promise<Permission[]> {
|
||||
// Get the implicit permissions for each role from Casbin
|
||||
const allPermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
for (const role of roles) {
|
||||
// Query Casbin for what permissions this role actually has
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(allPermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const permissions: Permission[] = [];
|
||||
for (const [resource, actions] of allPermissions) {
|
||||
permissions.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description:
|
||||
'Preview the effective permissions for a combination of roles and explicit permissions',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async previewEffectivePermissions(
|
||||
@Args('roles', { type: () => [Role], nullable: true }) roles?: Role[],
|
||||
@Args('permissions', { type: () => [AddPermissionInput], nullable: true })
|
||||
permissions?: AddPermissionInput[]
|
||||
): Promise<Permission[]> {
|
||||
const effectivePermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// Add permissions from roles
|
||||
for (const role of roles ?? []) {
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(effectivePermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Add explicit permissions
|
||||
if (permissions && permissions.length > 0) {
|
||||
for (const perm of permissions) {
|
||||
if (!effectivePermissions.has(perm.resource)) {
|
||||
effectivePermissions.set(perm.resource, new Set());
|
||||
}
|
||||
const resourceActions = effectivePermissions.get(perm.resource)!;
|
||||
|
||||
perm.actions.forEach((action) => {
|
||||
const actionStr = String(action);
|
||||
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
expandWildcardAction().forEach((expandedAction) => {
|
||||
resourceActions.add(expandedAction);
|
||||
});
|
||||
} else {
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
if (parsedAction) {
|
||||
resourceActions.add(parsedAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const result: Permission[] = [];
|
||||
for (const [resource, actions] of effectivePermissions) {
|
||||
result.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Query(() => [AuthAction], {
|
||||
description: 'Get all available authentication actions with possession',
|
||||
})
|
||||
getAvailableAuthActions(): AuthAction[] {
|
||||
return Object.values(AuthAction);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -22,15 +22,21 @@ export class Permission {
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction], {
|
||||
description: 'Actions allowed on this resource',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class ApiKey extends Node {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -58,24 +64,17 @@ export class ApiKey extends Node {
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ApiKeyWithSecret extends ApiKey {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AddPermissionInput {
|
||||
@Field(() => Resource)
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction])
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -3,12 +3,23 @@ import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKeyFormResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.resolver.js';
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyPermissionsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.js';
|
||||
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver],
|
||||
exports: [ApiKeyResolver, ApiKeyService],
|
||||
providers: [
|
||||
ApiKeyResolver,
|
||||
ApiKeyService,
|
||||
AuthService,
|
||||
ApiKeyMutationsResolver,
|
||||
ApiKeyPermissionsResolver,
|
||||
ApiKeyFormService,
|
||||
ApiKeyFormResolver,
|
||||
],
|
||||
exports: [ApiKeyResolver, ApiKeyService, ApiKeyFormService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
@@ -23,16 +22,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -48,7 +38,8 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
const localSessionService = { validateLocalSession: vi.fn() } as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
resolver = new ApiKeyMutationsResolver(authService, apiKeyService);
|
||||
});
|
||||
|
||||
@@ -61,12 +52,12 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();
|
||||
|
||||
const result = await resolver.create(input);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -95,7 +86,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
|
||||
await expect(resolver.create(input)).rejects.toThrow('Sync failed');
|
||||
});
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddRoleForApiKeyInput,
|
||||
ApiKeyWithSecret,
|
||||
ApiKey,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
UpdateApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
|
||||
@Resolver(() => ApiKeyMutations)
|
||||
export class ApiKeyMutationsResolver {
|
||||
@@ -28,12 +23,11 @@ export class ApiKeyMutationsResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.create({
|
||||
name: input.name,
|
||||
description: input.description ?? undefined,
|
||||
@@ -46,9 +40,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Add a role to an API key' })
|
||||
async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise<boolean> {
|
||||
@@ -56,9 +49,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Remove a role from an API key' })
|
||||
async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise<boolean> {
|
||||
@@ -66,9 +58,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.DELETE,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Delete one or more API keys' })
|
||||
async delete(@Args('input') input: DeleteApiKeyInput): Promise<boolean> {
|
||||
@@ -77,12 +68,11 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.update(input);
|
||||
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
|
||||
return apiKey;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
@@ -18,16 +18,7 @@ describe('ApiKeyResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -43,8 +34,9 @@ describe('ApiKeyResolver', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
resolver = new ApiKeyResolver(authService, apiKeyService);
|
||||
const localSessionService = { validateLocalSession: vi.fn() } as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
resolver = new ApiKeyResolver(apiKeyService);
|
||||
});
|
||||
|
||||
describe('apiKeys', () => {
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private apiKeyService: ApiKeyService
|
||||
) {}
|
||||
constructor(private apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeys(): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findAll();
|
||||
@@ -31,9 +22,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => ApiKey, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKey(
|
||||
@Args('id', { type: () => PrefixedID })
|
||||
@@ -44,9 +34,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Role], { description: 'All possible roles for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossibleRoles(): Promise<Role[]> {
|
||||
return Object.values(Role);
|
||||
@@ -54,14 +43,13 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Permission], { description: 'All possible permissions for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossiblePermissions(): Promise<Permission[]> {
|
||||
// Build all combinations of Resource and AuthActionVerb
|
||||
// Build all combinations of Resource and AuthAction
|
||||
const resources = Object.values(Resource);
|
||||
const actions = Object.values(AuthActionVerb);
|
||||
const actions = Object.values(AuthAction);
|
||||
return resources.map((resource) => ({
|
||||
resource,
|
||||
actions,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
import { GraphQLBigInt } from 'graphql-scalars';
|
||||
|
||||
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
@ObjectType()
|
||||
export class Capacity {
|
||||
@Field(() => String, { description: 'Free capacity' })
|
||||
@@ -142,6 +144,9 @@ export class UnraidArray extends Node {
|
||||
@Field(() => [ArrayDisk], { description: 'Parity disks in the current array' })
|
||||
parities!: ArrayDisk[];
|
||||
|
||||
@Field(() => ParityCheck, { description: 'Current parity check status' })
|
||||
parityCheckStatus!: ParityCheck;
|
||||
|
||||
@Field(() => [ArrayDisk], { description: 'Data disks in the current array' })
|
||||
disks!: ArrayDisk[];
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import {
|
||||
ArrayDisk,
|
||||
@@ -27,9 +23,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Set array state' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async setState(@Args('input') input: ArrayStateInput): Promise<UnraidArray> {
|
||||
return this.arrayService.updateArrayState(input);
|
||||
@@ -37,9 +32,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Add new disk to array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async addDiskToArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.addDiskToArray(input);
|
||||
@@ -50,9 +44,8 @@ export class ArrayMutationsResolver {
|
||||
"Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.",
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async removeDiskFromArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.removeDiskFromArray(input);
|
||||
@@ -60,9 +53,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Mount a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async mountArrayDisk(@Args('id', { type: () => PrefixedID }) id: string): Promise<ArrayDisk> {
|
||||
const array = await this.arrayService.mountArrayDisk(id);
|
||||
@@ -80,9 +72,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Unmount a disk from the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async unmountArrayDisk(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
@@ -102,9 +93,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => Boolean, { description: 'Clear statistics for a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async clearArrayDiskStatistics(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
@@ -17,9 +13,8 @@ export class ArrayResolver {
|
||||
|
||||
@Query(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async array() {
|
||||
return this.arrayService.getArrayData();
|
||||
@@ -27,9 +22,8 @@ export class ArrayResolver {
|
||||
|
||||
@Subscription(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async arraySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.ARRAY);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArrayRunningError } from '@app/core/errors/array-running-error.js';
|
||||
import { getArrayData as getArrayDataUtil } from '@app/core/modules/array/get-array-data.js';
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { emcmd } from '@app/core/utils/clients/emcmd.js';
|
||||
import {
|
||||
ArrayDiskInput,
|
||||
@@ -82,6 +83,13 @@ describe('ArrayService', () => {
|
||||
parities: [],
|
||||
disks: [],
|
||||
caches: [],
|
||||
parityCheckStatus: {
|
||||
status: ParityCheckStatus.NEVER_RUN,
|
||||
progress: 0,
|
||||
date: undefined,
|
||||
duration: 0,
|
||||
speed: '0',
|
||||
},
|
||||
};
|
||||
mockGetArrayDataUtil.mockResolvedValue(mockArrayData);
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Field, GraphQLISODateTime, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, GraphQLISODateTime, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
|
||||
registerEnumType(ParityCheckStatus, {
|
||||
name: 'ParityCheckStatus',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ParityCheck {
|
||||
@@ -11,8 +17,8 @@ export class ParityCheck {
|
||||
@Field(() => String, { nullable: true, description: 'Speed of the parity check, in MB/s' })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Status of the parity check' })
|
||||
status?: string;
|
||||
@Field(() => ParityCheckStatus, { description: 'Status of the parity check' })
|
||||
status!: ParityCheckStatus;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of errors during the parity check' })
|
||||
errors?: number;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
|
||||
@@ -19,9 +15,8 @@ export class ParityCheckMutationsResolver {
|
||||
constructor(private readonly parityService: ParityService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Start a parity check' })
|
||||
async start(@Args('correct') correct: boolean): Promise<object> {
|
||||
@@ -32,9 +27,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Pause a parity check' })
|
||||
async pause(): Promise<object> {
|
||||
@@ -45,9 +39,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Resume a parity check' })
|
||||
async resume(): Promise<object> {
|
||||
@@ -58,9 +51,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Cancel a parity check' })
|
||||
async cancel(): Promise<object> {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
@@ -23,9 +19,8 @@ export class ParityResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => [ParityCheck])
|
||||
async parityHistory(): Promise<ParityCheck[]> {
|
||||
@@ -33,9 +28,8 @@ export class ParityResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Subscription(() => ParityCheck)
|
||||
parityHistorySubscription() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
import { toNumberAlways } from '@unraid/shared/util/data.js';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { emcmd } from '@app/core/utils/index.js';
|
||||
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
@@ -22,16 +24,30 @@ export class ParityService {
|
||||
const lines = history.toString().trim().split('\n').reverse();
|
||||
return lines.map<ParityCheck>((line) => {
|
||||
const [date, duration, speed, status, errors = '0'] = line.split('|');
|
||||
const parsedDate = new Date(date);
|
||||
const safeDate = Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate;
|
||||
const durationNumber = Number(duration);
|
||||
const safeDuration = Number.isNaN(durationNumber) ? undefined : durationNumber;
|
||||
return {
|
||||
date: new Date(date),
|
||||
duration: Number.parseInt(duration, 10),
|
||||
date: safeDate,
|
||||
duration: safeDuration,
|
||||
speed: speed ?? 'Unavailable',
|
||||
status: status === '-4' ? 'Cancelled' : 'OK',
|
||||
// use http 422 (unprocessable entity) as fallback to differentiate from unix error codes
|
||||
// when status is not a number.
|
||||
status: this.statusCodeToStatusEnum(toNumberAlways(status, 422)),
|
||||
errors: Number.parseInt(errors, 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
statusCodeToStatusEnum(statusCode: number): ParityCheckStatus {
|
||||
return statusCode === -4
|
||||
? ParityCheckStatus.CANCELLED
|
||||
: toNumberAlways(statusCode, 0) === 0
|
||||
? ParityCheckStatus.COMPLETED
|
||||
: ParityCheckStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the parity check state
|
||||
* @param wantedState - The desired state for the parity check ('pause', 'resume', 'cancel', 'start')
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
@@ -14,9 +10,8 @@ import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
export class ConfigResolver {
|
||||
@Query(() => Config)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async config(): Promise<Config> {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator
|
||||
|
||||
@@ -23,9 +19,8 @@ export class CustomizationResolver {
|
||||
// Authenticated query
|
||||
@Query(() => Customization, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async customization(): Promise<Customization | null> {
|
||||
// We return an empty object because the fields are resolved by @ResolveField
|
||||
@@ -52,9 +47,8 @@ export class CustomizationResolver {
|
||||
|
||||
@ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async activationCode(): Promise<ActivationCode | null> {
|
||||
return this.customizationService.getActivationData();
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js';
|
||||
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
|
||||
@@ -17,9 +13,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => [Disk])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disks() {
|
||||
return this.disksService.getDisks();
|
||||
@@ -27,9 +22,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => Disk)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disk(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.disksService.getDisk(id);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
// Mock the pubsub module
|
||||
vi.mock('@app/core/pubsub.js', () => ({
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { Display } from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
@Resolver(() => Display)
|
||||
export class DisplayResolver {
|
||||
constructor(private readonly displayService: DisplayService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Display)
|
||||
public async display(): Promise<Display> {
|
||||
@@ -27,9 +22,8 @@ export class DisplayResolver {
|
||||
|
||||
@Subscription(() => Display)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async displaySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.DISPLAY);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
@@ -21,9 +17,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Start a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async start(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.start(id);
|
||||
@@ -31,9 +26,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Stop a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.stop(id);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import {
|
||||
@@ -25,9 +21,8 @@ export class DockerResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Docker)
|
||||
public docker() {
|
||||
@@ -37,9 +32,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerContainer])
|
||||
public async containers(
|
||||
@@ -49,9 +43,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerNetwork])
|
||||
public async networks(
|
||||
@@ -61,9 +54,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ResolvedOrganizerV1)
|
||||
public async organizer() {
|
||||
@@ -71,9 +63,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async createDockerFolder(
|
||||
@@ -90,9 +81,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async setDockerFolderChildren(
|
||||
@@ -107,9 +97,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) {
|
||||
@@ -120,9 +109,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async moveDockerEntriesToFolder(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user