mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
44 Commits
4.13.0-bui
...
v4.18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b7798b82f4 | ||
|
|
426283011a | ||
|
|
effdbcf0f5 | ||
|
|
541b0edd35 | ||
|
|
ce63d5dca2 | ||
|
|
bcaacca061 | ||
|
|
0afc4e8e9a | ||
|
|
1a01696dc7 | ||
|
|
1bc5251310 | ||
|
|
3a10871918 | ||
|
|
58b5544bea | ||
|
|
a4ff3c4092 |
2
.github/workflows/build-plugin.yml
vendored
2
.github/workflows/build-plugin.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
29
.github/workflows/create-docusaurus-pr.yml
vendored
29
.github/workflows/create-docusaurus-pr.yml
vendored
@@ -20,26 +20,49 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
path: source-repo
|
||||
|
||||
- name: Checkout docs repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: unraid/docs
|
||||
path: docs-repo
|
||||
token: ${{ secrets.DOCS_PAT_UNRAID_BOT }}
|
||||
|
||||
- name: Copy updated docs
|
||||
- name: Copy and process docs
|
||||
run: |
|
||||
if [ ! -d "source-repo/api/docs" ]; then
|
||||
echo "Source directory does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove old API docs but preserve other folders
|
||||
rm -rf docs-repo/docs/API/
|
||||
mkdir -p docs-repo/docs/API
|
||||
|
||||
# Copy all markdown files and maintain directory structure
|
||||
cp -r source-repo/api/docs/public/. docs-repo/docs/API/
|
||||
|
||||
# Copy images to Docusaurus static directory
|
||||
mkdir -p docs-repo/static/img/api
|
||||
|
||||
# Copy images from public/images if they exist
|
||||
if [ -d "source-repo/api/docs/public/images" ]; then
|
||||
cp -r source-repo/api/docs/public/images/. docs-repo/static/img/api/
|
||||
fi
|
||||
|
||||
# Also copy any images from the parent docs/images directory
|
||||
if [ -d "source-repo/api/docs/images" ]; then
|
||||
cp -r source-repo/api/docs/images/. docs-repo/static/img/api/
|
||||
fi
|
||||
|
||||
# Update image paths in markdown files
|
||||
# Replace relative image paths with absolute paths pointing to /img/api/
|
||||
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](\./images/\([^)]*\))||g' {} \;
|
||||
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](images/\([^)]*\))||g' {} \;
|
||||
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](../images/\([^)]*\))||g' {} \;
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
|
||||
2
.github/workflows/deploy-storybook.yml
vendored
2
.github/workflows/deploy-storybook.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
name: Deploy Storybook
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
10
.github/workflows/main.yml
vendored
10
.github/workflows/main.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
# Only run release-please on pushes to main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
working-directory: api
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Create env file
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test-libvirt.yml
vendored
6
.github/workflows/test-libvirt.yml
vendored
@@ -22,13 +22,13 @@ jobs:
|
||||
working-directory: ./libvirt
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- 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.0"}
|
||||
{".":"4.18.0"}
|
||||
|
||||
@@ -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,9 +414,9 @@
|
||||
}
|
||||
|
||||
[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(143, 85%, 96%);
|
||||
--success-border: hsl(145, 92%, 91%);
|
||||
@@ -436,21 +436,21 @@
|
||||
}
|
||||
|
||||
[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(150, 100%, 6%);
|
||||
--success-border: hsl(147, 100%, 12%);
|
||||
@@ -541,7 +541,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,87 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* insecure routes not working for SSO ([#1587](https://github.com/unraid/api/issues/1587)) ([a4ff3c4](https://github.com/unraid/api/commit/a4ff3c40926915f6989ed4af679b30cf295ea15d))
|
||||
|
||||
## [4.13.0](https://github.com/unraid/api/compare/v4.12.0...v4.13.0) (2025-08-15)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.17.0",
|
||||
"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);
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,17 @@
|
||||
---
|
||||
title: CLI Reference
|
||||
description: Complete reference for all Unraid API CLI commands
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# CLI Commands
|
||||
|
||||
:::info[Command Structure]
|
||||
All commands follow the pattern: `unraid-api <command> [options]`
|
||||
:::
|
||||
|
||||
## 🚀 Service Management
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
@@ -9,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
|
||||
|
||||
@@ -24,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
|
||||
@@ -39,7 +68,7 @@ View the API logs.
|
||||
|
||||
- `-l, --lines`: Optional. Number of lines to tail (default: 100)
|
||||
|
||||
## Configuration Commands
|
||||
## ⚙️ Configuration Commands
|
||||
|
||||
### Config
|
||||
|
||||
@@ -61,6 +90,10 @@ Switch between production and staging environments.
|
||||
|
||||
### Developer Mode
|
||||
|
||||
:::tip Web GUI Management
|
||||
You can also manage developer options through the web interface at **Settings** → **Management Access** → **Developer Options**
|
||||
:::
|
||||
|
||||
```bash
|
||||
unraid-api developer # Interactive prompt for tools
|
||||
unraid-api developer --sandbox true # Enable GraphQL sandbox
|
||||
@@ -76,13 +109,17 @@ Configure developer features for the API:
|
||||
|
||||
## API Key Management
|
||||
|
||||
:::tip Web GUI Management
|
||||
You can also manage API keys through the web interface at **Settings** → **Management Access** → **API Keys**
|
||||
:::
|
||||
|
||||
### API Key Commands
|
||||
|
||||
```bash
|
||||
unraid-api apikey [options]
|
||||
```
|
||||
|
||||
Create and manage API keys.
|
||||
Create and manage API keys via CLI.
|
||||
|
||||
Options:
|
||||
|
||||
@@ -94,6 +131,10 @@ Options:
|
||||
|
||||
## SSO (Single Sign-On) Management
|
||||
|
||||
:::info OIDC Configuration
|
||||
For OIDC/SSO provider configuration, see the web interface at **Settings** → **Management Access** → **API** → **OIDC** or refer to the [OIDC Provider Setup](./oidc-provider-setup.md) guide.
|
||||
:::
|
||||
|
||||
### SSO Base Command
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,39 +1,75 @@
|
||||
---
|
||||
title: Using the Unraid API
|
||||
description: Learn how to interact with your Unraid server through the GraphQL API
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Using the Unraid API
|
||||
|
||||
:::tip[Quick Start]
|
||||
The Unraid API provides a powerful GraphQL interface for managing your server. This guide covers authentication, common queries, and best practices.
|
||||
:::
|
||||
|
||||
The Unraid API provides a GraphQL interface that allows you to interact with your Unraid server. This guide will help you get started with exploring and using the API.
|
||||
|
||||
## Enabling the GraphQL Sandbox
|
||||
## 🎮 Enabling the GraphQL Sandbox
|
||||
|
||||
1. Enable developer mode using the CLI:
|
||||
### Web GUI Method (Recommended)
|
||||
|
||||
```bash
|
||||
unraid-api developer --sandbox true
|
||||
```
|
||||
|
||||
Or use the interactive mode:
|
||||
|
||||
```bash
|
||||
unraid-api developer
|
||||
```
|
||||
|
||||
2. Once enabled, you can access the Apollo Sandbox interface
|
||||
:::info[Preferred Method]
|
||||
Using the Web GUI is the easiest way to enable the GraphQL sandbox.
|
||||
:::
|
||||
|
||||
1. Navigate to **Settings** → **Management Access** → **Developer Options**
|
||||
2. Enable the **GraphQL Sandbox** toggle
|
||||
3. Access the GraphQL playground by navigating to:
|
||||
|
||||
```txt
|
||||
http://YOUR_SERVER_IP/graphql
|
||||
```
|
||||
|
||||
## Authentication
|
||||
### CLI Method
|
||||
|
||||
Most queries and mutations require authentication. You can authenticate using either:
|
||||
Alternatively, you can enable developer mode using the CLI:
|
||||
|
||||
1. API Keys
|
||||
2. Cookies (default method when signed into the WebGUI)
|
||||
```bash
|
||||
unraid-api developer --sandbox true
|
||||
```
|
||||
|
||||
### Creating an API Key
|
||||
Or use the interactive mode:
|
||||
|
||||
Use the CLI to create an API key:
|
||||
```bash
|
||||
unraid-api developer
|
||||
```
|
||||
|
||||
## 🔑 Authentication
|
||||
|
||||
:::warning[Required for Most Operations]
|
||||
Most queries and mutations require authentication. Always include appropriate credentials in your requests.
|
||||
:::
|
||||
|
||||
You can authenticate using:
|
||||
|
||||
1. **API Keys** - For programmatic access
|
||||
2. **Cookies** - Automatic when signed into the WebGUI
|
||||
3. **SSO/OIDC** - When configured with external providers
|
||||
|
||||
### Managing API Keys
|
||||
|
||||
<tabs>
|
||||
<tabItem value="gui" label="Web GUI (Recommended)" default>
|
||||
|
||||
Navigate to **Settings** → **Management Access** → **API Keys** in your Unraid web interface to:
|
||||
|
||||
- View existing API keys
|
||||
- Create new API keys
|
||||
- Manage permissions and roles
|
||||
- Revoke or regenerate keys
|
||||
|
||||
</tabItem>
|
||||
<tabItem value="cli" label="CLI Method">
|
||||
|
||||
You can also use the CLI to create an API key:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create
|
||||
@@ -46,6 +82,11 @@ Follow the prompts to set:
|
||||
- Roles
|
||||
- Permissions
|
||||
|
||||
</tabItem>
|
||||
</tabs>
|
||||
|
||||
### Using API Keys
|
||||
|
||||
The generated API key should be included in your GraphQL requests as a header:
|
||||
|
||||
```json
|
||||
@@ -54,7 +95,7 @@ The generated API key should be included in your GraphQL requests as a header:
|
||||
}
|
||||
```
|
||||
|
||||
## Available Schemas
|
||||
## 📊 Available Schemas
|
||||
|
||||
The API provides access to various aspects of your Unraid server:
|
||||
|
||||
@@ -83,9 +124,9 @@ The API provides access to various aspects of your Unraid server:
|
||||
- Handle SSO configuration
|
||||
- Manage allowed origins
|
||||
|
||||
### Example Queries
|
||||
### 💻 Example Queries
|
||||
|
||||
1. Check System Status:
|
||||
#### Check System Status
|
||||
|
||||
```graphql
|
||||
query {
|
||||
@@ -106,7 +147,7 @@ query {
|
||||
}
|
||||
```
|
||||
|
||||
2. Monitor Array Status:
|
||||
#### Monitor Array Status
|
||||
|
||||
```graphql
|
||||
query {
|
||||
@@ -129,7 +170,7 @@ query {
|
||||
}
|
||||
```
|
||||
|
||||
3. List Docker Containers:
|
||||
#### List Docker Containers
|
||||
|
||||
```graphql
|
||||
query {
|
||||
@@ -143,7 +184,7 @@ query {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Types
|
||||
## 🏗️ Schema Types
|
||||
|
||||
The API includes several core types:
|
||||
|
||||
@@ -170,19 +211,23 @@ Available roles:
|
||||
- `connect`: Remote access features
|
||||
- `guest`: Limited read access
|
||||
|
||||
## Best Practices
|
||||
## ✨ Best Practices
|
||||
|
||||
:::tip[Pro Tips]
|
||||
1. Use the Apollo Sandbox to explore the schema and test queries
|
||||
2. Start with small queries and gradually add fields as needed
|
||||
3. Monitor your query complexity to maintain performance
|
||||
4. Use appropriate roles and permissions for your API keys
|
||||
5. Keep your API keys secure and rotate them periodically
|
||||
:::
|
||||
|
||||
## Rate Limiting
|
||||
## ⏱️ Rate Limiting
|
||||
|
||||
:::caution[Rate Limits]
|
||||
The API implements rate limiting to prevent abuse. Ensure your applications handle rate limit responses appropriately.
|
||||
:::
|
||||
|
||||
## Error Handling
|
||||
## 🚨 Error Handling
|
||||
|
||||
The API returns standard GraphQL errors in the following format:
|
||||
|
||||
@@ -198,11 +243,13 @@ The API returns standard GraphQL errors in the following format:
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
## 📚 Additional Resources
|
||||
|
||||
:::info[Learn More]
|
||||
- Use the Apollo Sandbox's schema explorer to browse all available types and fields
|
||||
- Check the documentation tab in Apollo Sandbox for detailed field descriptions
|
||||
- Monitor the API's health using `unraid-api status`
|
||||
- Generate reports using `unraid-api report` for troubleshooting
|
||||
|
||||
For more information about specific commands and configuration options, refer to the CLI documentation or run `unraid-api --help`.
|
||||
For more information about specific commands and configuration options, refer to the [CLI documentation](/cli) or run `unraid-api --help`.
|
||||
:::
|
||||
|
||||
@@ -1,38 +1,94 @@
|
||||
# Unraid API
|
||||
---
|
||||
title: Welcome to Unraid API
|
||||
description: The official GraphQL API for Unraid Server management and automation
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Welcome to Unraid API
|
||||
|
||||
:::tip[What's New]
|
||||
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.
|
||||
|
||||
## Current Availability
|
||||
## 📦 Availability
|
||||
|
||||
The API is available through the Unraid Connect Plugin:
|
||||
### ✨ Native Integration (Unraid OS v7.2+)
|
||||
|
||||
1. Install Unraid Connect Plugin from Apps
|
||||
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 (Pre-7.2 and Advanced Users)
|
||||
|
||||
For Unraid versions prior to v7.2 or to access newer API features:
|
||||
|
||||
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#accessing-the-graphql-sandbox)
|
||||
3. Access API functionality through the [GraphQL Sandbox](./how-to-use-the-api.md)
|
||||
|
||||
## Future Availability
|
||||
:::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
|
||||
:::
|
||||
|
||||
The API will be integrated directly into the Unraid operating system in an upcoming OS release. This integration will:
|
||||
## 📚 Documentation Sections
|
||||
|
||||
- Make the API a core part of the Unraid system
|
||||
- Remove the need for separate plugin installation
|
||||
- Enable deeper system integration capabilities
|
||||
<cards>
|
||||
<card title="CLI Commands" icon="terminal" href="./cli">
|
||||
Complete reference for all CLI commands
|
||||
</card>
|
||||
<card title="Using the API" icon="code" href="./how-to-use-the-api">
|
||||
Learn how to interact with the GraphQL API
|
||||
</card>
|
||||
<card title="OIDC Setup" icon="shield" href="./oidc-provider-setup">
|
||||
Configure SSO authentication providers
|
||||
</card>
|
||||
<card title="Upcoming Features" icon="rocket" href="./upcoming-features">
|
||||
See what's coming next
|
||||
</card>
|
||||
</cards>
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
- [CLI Commands](./cli.md) - Reference for all available command-line interface commands
|
||||
- [Using the Unraid API](./how-to-use-the-api.md) - Comprehensive guide on using the GraphQL API
|
||||
- [OIDC Provider Setup](./oidc-provider-setup.md) - OIDC SSO provider configuration examples
|
||||
- [Upcoming Features](./upcoming-features.md) - Roadmap of planned features and improvements
|
||||
|
||||
## Key Features
|
||||
## 🌟 Key Features
|
||||
|
||||
:::info[Core Capabilities]
|
||||
The API provides:
|
||||
|
||||
- GraphQL Interface: Modern, flexible API with strong typing
|
||||
- Authentication: Secure access via API keys or session cookies
|
||||
- Comprehensive Coverage: Access to system information, array management, and Docker operations
|
||||
- Developer Tools: Built-in GraphQL sandbox for testing
|
||||
- Role-Based Access: Granular permission control
|
||||
- **GraphQL Interface**: Modern, flexible API with strong typing
|
||||
- **Authentication**: Multiple methods including API keys, session cookies, and SSO/OIDC
|
||||
- **Comprehensive Coverage**: Access to system information, array management, and Docker operations
|
||||
- **Developer Tools**: Built-in GraphQL sandbox configurable via web interface or CLI
|
||||
- **Role-Based Access**: Granular permission control
|
||||
- **Web Management**: Manage API keys and settings through the web interface
|
||||
:::
|
||||
|
||||
For detailed usage instructions, see [CLI Commands](./cli.md).
|
||||
## 🚀 Get Started
|
||||
|
||||
<tabs>
|
||||
<tabItem value="v72" label="Unraid OS v7.2+" default>
|
||||
|
||||
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="Pre-7.2 Versions">
|
||||
|
||||
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>
|
||||
|
||||
For detailed usage instructions, see the [CLI Commands](./cli) reference.
|
||||
|
||||
@@ -6,18 +6,27 @@ sidebar_position: 3
|
||||
|
||||
# OIDC Provider Setup
|
||||
|
||||
:::info[What is OIDC?]
|
||||
OpenID Connect (OIDC) is an authentication protocol that allows users to sign in using their existing accounts from providers like Google, Microsoft, or your corporate identity provider. It enables Single Sign-On (SSO) for seamless and secure authentication.
|
||||
:::
|
||||
|
||||
This guide walks you through configuring OIDC (OpenID Connect) providers for SSO authentication in the Unraid API using the web interface.
|
||||
|
||||
## Accessing OIDC Settings
|
||||
## 🚀 Quick Start
|
||||
|
||||
<details open>
|
||||
<summary><strong>Getting to OIDC Settings</strong></summary>
|
||||
|
||||
1. Navigate to your Unraid server's web interface
|
||||
2. The OIDC Providers section is available on the main configuration page
|
||||
2. Go to **Settings** → **Management Access** → **API** → **OIDC**
|
||||
3. You'll see tabs for different providers - click the **+** button to add a new provider
|
||||
|
||||
</details>
|
||||
|
||||
### OIDC Providers Interface Overview
|
||||
|
||||

|
||||
_Screenshot: Login page showing traditional login form with SSO options - "Login With Unraid.net" and "Sign in with Google" buttons_
|
||||
*Login page showing traditional login form with SSO options - "Login With Unraid.net" and "Sign in with Google" buttons*
|
||||
|
||||
The interface includes:
|
||||
|
||||
@@ -67,7 +76,7 @@ Advanced mode provides granular control using claim-based rules. You can:
|
||||
## Authorization Rules
|
||||
|
||||

|
||||
_Screenshot: Advanced authorization rules showing JWT claim configuration with email endsWith operator for domain-based access control_
|
||||
*Advanced authorization rules showing JWT claim configuration with email endsWith operator for domain-based access control*
|
||||
|
||||
### Simple Mode Examples
|
||||
|
||||
@@ -191,13 +200,17 @@ When "advanced" mode is selected, you'll see:
|
||||
|
||||
### Required Redirect URI
|
||||
|
||||
All providers must be configured with this redirect URI:
|
||||
:::caution[Important Configuration]
|
||||
All providers must be configured with this exact redirect URI format:
|
||||
:::
|
||||
|
||||
```
|
||||
http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback
|
||||
```bash
|
||||
http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Replace `YOUR_UNRAID_IP` with your actual server IP address.
|
||||
:::tip
|
||||
Replace `YOUR_UNRAID_IP` with your actual server IP address (e.g., `192.168.1.100` or `tower.local`).
|
||||
:::
|
||||
|
||||
### Issuer URL Format
|
||||
|
||||
@@ -214,10 +227,10 @@ The **Issuer URL** field accepts both formats, but **base URL is strongly recomm
|
||||
- Keycloak: `https://keycloak.example.com/realms/YOUR_REALM`
|
||||
- Authelia: `https://auth.yourdomain.com`
|
||||
|
||||
## Testing Your Configuration
|
||||
## ✅ Testing Your Configuration
|
||||
|
||||

|
||||
_Screenshot: Unraid login page displaying both traditional username/password authentication and SSO options with customized provider buttons_
|
||||
*Unraid login page displaying both traditional username/password authentication and SSO options with customized provider buttons*
|
||||
|
||||
1. Save your provider configuration
|
||||
2. Log out (if logged in)
|
||||
@@ -225,7 +238,7 @@ _Screenshot: Unraid login page displaying both traditional username/password aut
|
||||
4. Your configured provider button should appear
|
||||
5. Click to test the login flow
|
||||
|
||||
## Troubleshooting
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
@@ -246,8 +259,8 @@ _Screenshot: Unraid login page displaying both traditional username/password aut
|
||||
#### "Invalid redirect URI"
|
||||
|
||||
- Ensure the redirect URI in your provider matches exactly
|
||||
- Include the port number (:3001)
|
||||
- Use HTTP for local, HTTPS for production
|
||||
- Include the correct port if using a non-standard configuration
|
||||
- Verify the redirect URI protocol matches your server's configuration (HTTP or HTTPS)
|
||||
|
||||
#### Cannot see login button
|
||||
|
||||
@@ -270,40 +283,43 @@ LOG_LEVEL=debug unraid-api start --debug
|
||||
- Authorization rule evaluation
|
||||
- Token validation errors
|
||||
|
||||
## Security Best Practices
|
||||
## 🔐 Security Best Practices
|
||||
|
||||
1. **Always use HTTPS in production** - OAuth requires secure connections
|
||||
2. **Use Simple Mode for authorization** - Prevents overly accepting configurations and reduces misconfiguration risks
|
||||
3. **Be specific with authorization** - Don't use overly broad rules
|
||||
4. **Rotate secrets regularly** - Update client secrets periodically
|
||||
5. **Test thoroughly** - Verify only intended users can access
|
||||
1. **Use Simple Mode for authorization** - Prevents overly accepting configurations and reduces misconfiguration risks
|
||||
2. **Be specific with authorization** - Don't use overly broad rules
|
||||
3. **Rotate secrets regularly** - Update client secrets periodically
|
||||
4. **Test thoroughly** - Verify only intended users can access
|
||||
|
||||
## Need Help?
|
||||
## 💡 Need Help?
|
||||
|
||||
- Check provider's OIDC documentation
|
||||
- Review Unraid API logs for detailed error messages
|
||||
- Ensure your provider supports standard OIDC discovery
|
||||
- Verify network connectivity between Unraid and provider
|
||||
|
||||
## Provider-Specific Setup
|
||||
## 🏢 Provider-Specific Setup
|
||||
|
||||
### Unraid.net Provider
|
||||
|
||||
The Unraid.net provider is built-in and pre-configured. You only need to configure authorization rules in the interface.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- **Issuer URL**: Pre-configured (built-in provider)
|
||||
- **Client ID/Secret**: Pre-configured (built-in provider)
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
:::warning[Security Notice]
|
||||
**Always use HTTPS for production redirect URIs!** The examples above use HTTP for initial setup and testing only. In production environments, you MUST use HTTPS (e.g., `https://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`) to ensure secure communication and prevent credential interception. Most OIDC providers will reject HTTP redirect URIs for security reasons.
|
||||
:::tip[Redirect URI Protocol]
|
||||
**Match the protocol to your server setup:** Use `http://` if accessing your Unraid server without SSL/TLS (typical for local network access). Use `https://` if you've configured SSL/TLS on your server. Some OIDC providers (like Google) require HTTPS and won't accept HTTP redirect URIs.
|
||||
:::
|
||||
|
||||
Configure authorization rules using Simple Mode (allowed email domains/addresses) or Advanced Mode for complex requirements.
|
||||
|
||||
### Google
|
||||
|
||||
<details>
|
||||
<summary><strong>📋 Setup Steps</strong></summary>
|
||||
|
||||
Set up OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/):
|
||||
|
||||
1. Go to **APIs & Services** → **Credentials**
|
||||
@@ -312,12 +328,14 @@ Set up OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.goo
|
||||
4. Add your redirect URI to **Authorized redirect URIs**
|
||||
5. Configure the OAuth consent screen if prompted
|
||||
|
||||
</details>
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- **Issuer URL**: `https://accounts.google.com`
|
||||
- **Client ID/Secret**: From your OAuth 2.0 client credentials
|
||||
- **Required Scopes**: `openid`, `profile`, `email`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
:::warning[Google Domain Requirements]
|
||||
**Google requires valid domain names for OAuth redirect URIs.** Local IP addresses and `.local` domains are not accepted. To use Google OAuth with your Unraid server, you'll need:
|
||||
@@ -341,7 +359,7 @@ Configure OIDC client in your Authelia `configuration.yml` with client ID `unrai
|
||||
- **Client ID**: `unraid-api` (or as configured in Authelia)
|
||||
- **Client Secret**: Your unhashed secret
|
||||
- **Required Scopes**: `openid`, `profile`, `email`, `groups`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
Use Advanced Mode with `groups` claim for group-based authorization.
|
||||
|
||||
@@ -355,7 +373,7 @@ Register a new app in [Azure Portal](https://portal.azure.com/) under Azure Acti
|
||||
- **Client ID**: Your Application (client) ID
|
||||
- **Client Secret**: Generated client secret
|
||||
- **Required Scopes**: `openid`, `profile`, `email`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
Authorization rules can be configured in the interface using email domains or advanced claims.
|
||||
|
||||
@@ -369,7 +387,7 @@ Create a new confidential client in Keycloak Admin Console with `openid-connect`
|
||||
- **Client ID**: `unraid-api` (or as configured in Keycloak)
|
||||
- **Client Secret**: From Keycloak Credentials tab
|
||||
- **Required Scopes**: `openid`, `profile`, `email`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
For role-based authorization, use Advanced Mode with `realm_access.roles` or `resource_access` claims.
|
||||
|
||||
@@ -379,11 +397,11 @@ Create a new OAuth2/OpenID Provider in Authentik, then create an Application and
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- **Issuer URL**: `https://authentik.example.com/application/o/unraid-api/`
|
||||
- **Issuer URL**: `https://authentik.example.com/application/o/<application_slug>/`
|
||||
- **Client ID**: From Authentik provider configuration
|
||||
- **Client Secret**: From Authentik provider configuration
|
||||
- **Required Scopes**: `openid`, `profile`, `email`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
Authorization rules can be configured in the interface.
|
||||
|
||||
@@ -397,6 +415,6 @@ Create a new OIDC Web Application in Okta Admin Console and assign appropriate u
|
||||
- **Client ID**: From Okta application configuration
|
||||
- **Client Secret**: From Okta application configuration
|
||||
- **Required Scopes**: `openid`, `profile`, `email`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
|
||||
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
|
||||
|
||||
Authorization rules can be configured in the interface using email domains or advanced claims.
|
||||
|
||||
@@ -1,71 +1,172 @@
|
||||
# Upcoming Features
|
||||
---
|
||||
title: Roadmap & Features
|
||||
description: Current status and upcoming features for the Unraid API
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
Note: This roadmap outlines planned features and improvements for the Unraid API. Features and timelines may change based on development priorities and community feedback.
|
||||
# Roadmap & Features
|
||||
|
||||
:::info Development Status
|
||||
This roadmap outlines completed and planned features for the Unraid API. Features and timelines may change based on development priorities and community feedback.
|
||||
:::
|
||||
|
||||
## Feature Status Legend
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| ✅ **Done** | Feature is complete and available |
|
||||
| 🚧 **In Progress** | Currently under active development |
|
||||
| 📅 **Planned** | Scheduled for future development |
|
||||
| 💡 **Under Consideration** | Being evaluated for future inclusion |
|
||||
|
||||
## Core Infrastructure
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| API Development Environment Improvements | Done | v4.0.0 |
|
||||
| Include API in Unraid OS | Planned (Q1 2025) | - |
|
||||
| Make API Open Source | Planned (Q1 2025) | - |
|
||||
| Separate API from Connect Plugin | Planned (Q2 2025) | - |
|
||||
| Developer Tools for Plugins | Planned (Q2 2025) | - |
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **API Development Environment Improvements** | v4.0.0 |
|
||||
| **Include API in Unraid OS** | Unraid v7.2-beta.1 |
|
||||
| **Separate API from Connect Plugin** | Unraid v7.2-beta.1 |
|
||||
|
||||
### Upcoming Features 📅
|
||||
|
||||
| Feature | Target Timeline |
|
||||
|---------|-----------------|
|
||||
| **Make API Open Source** | Q1 2025 |
|
||||
| **Developer Tools for Plugins** | Q2 2025 |
|
||||
|
||||
## Security & Authentication
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| Permissions System Rewrite | Done | v4.0.0 |
|
||||
| User Interface Component Library | In Progress | - |
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **Permissions System Rewrite** | v4.0.0 |
|
||||
| **OIDC/SSO Support** | Unraid v7.2-beta.1 |
|
||||
|
||||
### In Development 🚧
|
||||
|
||||
- **User Interface Component Library** - Enhanced security components for the UI
|
||||
|
||||
## User Interface Improvements
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| New Settings Pages | Planned (Q2 2025) | - |
|
||||
| Custom Theme Creator | Planned (Q2-Q3 2025) | - |
|
||||
| New Connect Settings Interface | Planned (Q1 2025) | - |
|
||||
### Planned Features 📅
|
||||
|
||||
| Feature | Target Timeline | Description |
|
||||
|---------|-----------------|-------------|
|
||||
| **New Settings Pages** | Q2 2025 | Modernized settings interface with improved UX |
|
||||
| **Custom Theme Creator** | Q2-Q3 2025 | Allow users to create and share custom themes |
|
||||
| **New Connect Settings Interface** | Q1 2025 | Redesigned Unraid Connect configuration |
|
||||
|
||||
## Array Management
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| Array Status Monitoring | Done | v4.0.0 |
|
||||
| Storage Pool Creation Interface | Planned (Q2 2025) | - |
|
||||
| Storage Pool Status Interface | Planned (Q2 2025) | - |
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **Array Status Monitoring** | v4.0.0 |
|
||||
|
||||
### Planned Features 📅
|
||||
|
||||
| Feature | Target Timeline | Description |
|
||||
|---------|-----------------|-------------|
|
||||
| **Storage Pool Creation Interface** | Q2 2025 | Simplified pool creation workflow |
|
||||
| **Storage Pool Status Interface** | Q2 2025 | Real-time pool health monitoring |
|
||||
|
||||
## Docker Integration
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| Docker Container Status Monitoring | Done | v4.0.0 |
|
||||
| New Docker Status Interface Design | Planned (Q3 2025) | - |
|
||||
| New Docker Status Interface | Planned (Q3 2025) | - |
|
||||
| Docker Container Setup Interface | Planned (Q3 2025) | - |
|
||||
| Docker Compose Support | Planned | - |
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **Docker Container Status Monitoring** | v4.0.0 |
|
||||
|
||||
### Planned Features 📅
|
||||
|
||||
| Feature | Target Timeline | Description |
|
||||
|---------|-----------------|-------------|
|
||||
| **New Docker Status Interface Design** | Q3 2025 | Modern container management UI |
|
||||
| **New Docker Status Interface** | Q3 2025 | Implementation of new design |
|
||||
| **Docker Container Setup Interface** | Q3 2025 | Streamlined container deployment |
|
||||
| **Docker Compose Support** | TBD | Native docker-compose.yml support |
|
||||
|
||||
## Share Management
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| Array/Cache Share Status Monitoring | Done | v4.0.0 |
|
||||
| Storage Share Creation & Settings | Planned | - |
|
||||
| Storage Share Management Interface | Planned | - |
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **Array/Cache Share Status Monitoring** | v4.0.0 |
|
||||
|
||||
### Under Consideration 💡
|
||||
|
||||
- **Storage Share Creation & Settings** - Enhanced share configuration options
|
||||
- **Storage Share Management Interface** - Unified share management dashboard
|
||||
|
||||
## Plugin System
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| New Plugins Interface | Planned (Q3 2025) | - |
|
||||
| Plugin Management Interface | Planned | - |
|
||||
| Plugin Development Tools | Planned | - |
|
||||
### Planned Features 📅
|
||||
|
||||
| Feature | Target Timeline | Description |
|
||||
|---------|-----------------|-------------|
|
||||
| **New Plugins Interface** | Q3 2025 | Redesigned plugin management UI |
|
||||
| **Plugin Management Interface** | TBD | Advanced plugin configuration |
|
||||
| **Plugin Development Tools** | TBD | SDK and tooling for developers |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Feature | Status | Tag |
|
||||
|---------|--------|-----|
|
||||
| Notifications System | Done | v4.0.0 |
|
||||
| Notifications Interface | Done | v4.0.0 |
|
||||
### Completed Features ✅
|
||||
|
||||
Features marked as "Done" are available in current releases. The tag column shows the version where a feature was first introduced.
|
||||
| Feature | Available Since |
|
||||
|---------|-----------------|
|
||||
| **Notifications System** | v4.0.0 |
|
||||
| **Notifications Interface** | v4.0.0 |
|
||||
|
||||
---
|
||||
|
||||
## Recent Releases
|
||||
|
||||
:::info Full Release History
|
||||
For a complete list of all releases, changelogs, and download links, visit the [Unraid API GitHub Releases](https://github.com/unraid/api/releases) page.
|
||||
:::
|
||||
|
||||
### Unraid v7.2-beta.1 Highlights
|
||||
|
||||
- 🎉 **API included in Unraid OS** - Native integration
|
||||
- 🔐 **OIDC/SSO Support** - Enterprise authentication
|
||||
- 📦 **Standalone API** - Separated from Connect plugin
|
||||
|
||||
### v4.0.0 Highlights
|
||||
|
||||
- 🛡️ **Permissions System Rewrite** - Enhanced security
|
||||
- 📊 **Comprehensive Monitoring** - Array, Docker, and Share status
|
||||
- 🔔 **Notifications System** - Real-time alerts and notifications
|
||||
- 🛠️ **Developer Environment** - Improved development tools
|
||||
|
||||
## Community Feedback
|
||||
|
||||
:::tip Have a Feature Request?
|
||||
We value community input! Please submit feature requests and feedback through:
|
||||
|
||||
- [Unraid Forums](https://forums.unraid.net)
|
||||
- [GitHub Issues](https://github.com/unraid/api/issues) - API is open source!
|
||||
|
||||
:::
|
||||
|
||||
## Version Support
|
||||
|
||||
| Unraid Version | API Version | Support Status |
|
||||
|----------------|-------------|----------------|
|
||||
| Unraid v7.2-beta.1+ | Latest | ✅ Active |
|
||||
| 7.0 - 7.1.x | v4.x via Plugin | ⚠️ Limited |
|
||||
| 6.12.x | v4.x via Plugin | ⚠️ Limited |
|
||||
| < 6.12 | Not Supported | ❌ EOL |
|
||||
|
||||
:::warning Legacy Support
|
||||
Versions prior to Unraid 7.2 require the API to be installed through the Unraid Connect plugin. Some features may not be available on older versions.
|
||||
:::
|
||||
|
||||
:::tip Pre-release Versions
|
||||
You can always install the Unraid Connect plugin to access pre-release versions of the API and get early access to new features before they're included in Unraid OS releases.
|
||||
:::
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.13.0",
|
||||
"version": "4.18.0",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { SsoUserService as ISsoUserService } from '@unraid/shared/services/sso.js';
|
||||
import { GraphQLError } from 'graphql/error/GraphQLError.js';
|
||||
|
||||
import type { ApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class SsoUserService implements ISsoUserService {
|
||||
private readonly logger = new Logger(SsoUserService.name);
|
||||
private ssoSubIdsConfigKey = 'api.ssoSubIds';
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Optional() private readonly fileModificationService?: UnraidFileModificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the current list of SSO user IDs
|
||||
* @returns Array of SSO user IDs
|
||||
*/
|
||||
async getSsoUsers(): Promise<string[]> {
|
||||
const ssoSubIds = this.configService.getOrThrow<ApiConfig['ssoSubIds']>(this.ssoSubIdsConfigKey);
|
||||
return ssoSubIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the complete list of SSO user IDs
|
||||
* @param userIds - The list of SSO user IDs to set
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async setSsoUsers(userIds: string[]): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
const currentUserSet = new Set(currentUsers);
|
||||
const newUserSet = new Set(userIds);
|
||||
|
||||
// If there's no change, no need to update
|
||||
if (newUserSet.symmetricDifference(currentUserSet).size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate user IDs
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
const invalidUserIds = userIds.filter((id) => !uuidRegex.test(id));
|
||||
if (invalidUserIds.length > 0) {
|
||||
throw new GraphQLError(`Invalid SSO user ID's: ${invalidUserIds.join(', ')}`);
|
||||
}
|
||||
|
||||
// Update the config
|
||||
this.configService.set(this.ssoSubIdsConfigKey, userIds);
|
||||
|
||||
// Handle file modification if available
|
||||
if (this.fileModificationService) {
|
||||
// If going from 0 to 1+ users, apply the SSO modification
|
||||
if (currentUserSet.size === 0 && newUserSet.size > 0) {
|
||||
try {
|
||||
await this.fileModificationService.applyModificationById('sso');
|
||||
this.logger.log('Applied SSO file modification after adding SSO users');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to apply SSO file modification', error);
|
||||
}
|
||||
}
|
||||
// If going from 1+ to 0 users, rollback the SSO modification
|
||||
else if (currentUserSet.size > 0 && newUserSet.size === 0) {
|
||||
try {
|
||||
await this.fileModificationService.rollbackModificationById('sso');
|
||||
this.logger.log('Rolled back SSO file modification after removing all SSO users');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to rollback SSO file modification', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No restart required - file modifications are applied immediately
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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';
|
||||
import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const mockInquirerService = {
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
describe('AddSSOUserCommand', () => {
|
||||
let command: AddSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AddSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: InquirerService, useValue: mockInquirerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<AddSSOUserCommand>(AddSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add a new SSO user successfully', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: false,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id', 'new-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('User added: new-user-id');
|
||||
expect(mockLogger.info).not.toHaveBeenCalledWith('Restarting the API');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add user if disclaimer is not accepted', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'n',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).not.toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add user if user already exists', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'existing-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'User existing-user-id already exists in SSO users'
|
||||
);
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error adding user:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
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,86 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ListSSOUserCommand', () => {
|
||||
let command: ListSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<ListSSOUserCommand>(ListSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should list all SSO users', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: expect.anything(),
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('user-1\nuser-2\nuser-3');
|
||||
});
|
||||
|
||||
it('should display message when no users found', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('No SSO users found');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await expect(command.run([])).rejects.toThrow('Connection failed');
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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';
|
||||
import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const mockInquirerService = {
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
describe('RemoveSSOUserCommand', () => {
|
||||
let command: RemoveSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
RemoveSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: InquirerService, useValue: mockInquirerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<RemoveSSOUserCommand>(RemoveSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should remove a specific SSO user successfully', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: true,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'user-2',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('User removed: user-2');
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('Restarting the API');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove all SSO users when "all" is selected', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: true,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'all',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('All users removed from SSO');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not remove user if user does not exist', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'user-2',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('User user-2 not found in SSO users');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exit when no SSO users are found', async () => {
|
||||
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
const error = new Error('No SSO Users Found');
|
||||
(error as any).name = 'NoSSOUsersFoundError';
|
||||
mockInquirerService.prompt.mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await command.run([]);
|
||||
} catch (error) {
|
||||
// Expected to throw due to process.exit
|
||||
}
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to fetch SSO users: %s',
|
||||
'No SSO Users Found'
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
285
api/src/unraid-api/cli/apikey/api-key.command.spec.ts
Normal file
285
api/src/unraid-api/cli/apikey/api-key.command.spec.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -16,6 +15,7 @@ interface KeyOptions {
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[];
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
@@ -53,29 +53,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,6 +92,14 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--overwrite',
|
||||
description: 'Overwrite existing API key if it exists',
|
||||
})
|
||||
parseOverwrite(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
|
||||
private async deleteKeys() {
|
||||
const allKeys = await this.apiKeyService.findAll();
|
||||
@@ -139,8 +140,27 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
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));
|
||||
// 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.logger.error(
|
||||
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +175,7 @@ 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);
|
||||
|
||||
@@ -2,10 +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 { SsoUserService } from '@app/unraid-api/auth/sso-user.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';
|
||||
@@ -24,16 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
PluginCliModule.register(),
|
||||
UnraidFileModifierModule,
|
||||
],
|
||||
providers: [
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
],
|
||||
exports: [ApiReportService, LogService, ApiKeyService, SsoUserService, 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,8 +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 { SsoUserService } from '@app/unraid-api/auth/sso-user.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';
|
||||
@@ -13,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 {
|
||||
@@ -26,11 +23,6 @@ import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plug
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command.js';
|
||||
import { AddSSOUserQuestionSet } from '@app/unraid-api/cli/sso/add-sso-user.questions.js';
|
||||
import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command.js';
|
||||
import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command.js';
|
||||
import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions.js';
|
||||
import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js';
|
||||
import { ValidateTokenCommand } from '@app/unraid-api/cli/sso/validate-token.command.js';
|
||||
import { StartCommand } from '@app/unraid-api/cli/start.command.js';
|
||||
@@ -39,7 +31,6 @@ import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
@@ -56,12 +47,9 @@ const DEFAULT_COMMANDS = [
|
||||
StartCommand,
|
||||
StatusCommand,
|
||||
StopCommand,
|
||||
// SSO commands
|
||||
// SSO commands (validation only)
|
||||
SSOCommand,
|
||||
ValidateTokenCommand,
|
||||
AddSSOUserCommand,
|
||||
RemoveSSOUserCommand,
|
||||
ListSSOUserCommand,
|
||||
// Plugin commands
|
||||
PluginCommand,
|
||||
ListPluginCommand,
|
||||
@@ -72,19 +60,14 @@ const DEFAULT_COMMANDS = [
|
||||
const DEFAULT_PROVIDERS = [
|
||||
AddApiKeyQuestionSet,
|
||||
DeleteApiKeyQuestionSet,
|
||||
AddSSOUserQuestionSet,
|
||||
RemoveSSOUserQuestionSet,
|
||||
RemovePluginQuestionSet,
|
||||
DeveloperQuestions,
|
||||
DeveloperToolsService,
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
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,104 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { UPDATE_SSO_USERS_MUTATION } from '@app/unraid-api/cli/mutations/update-sso-users.mutation.js';
|
||||
import { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { AddSSOUserQuestionSet } from '@app/unraid-api/cli/sso/add-sso-user.questions.js';
|
||||
|
||||
interface AddSSOUserCommandOptions {
|
||||
disclaimer: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@SubCommand({
|
||||
name: 'add-user',
|
||||
aliases: ['add', 'a'],
|
||||
description: 'Add a user for SSO',
|
||||
})
|
||||
export class AddSSOUserCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly inquirerService: InquirerService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_input: string[], options?: AddSSOUserCommandOptions): Promise<void> {
|
||||
try {
|
||||
options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options);
|
||||
if (options.disclaimer === 'y' && options.username) {
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.query({
|
||||
query: SSO_USERS_QUERY,
|
||||
});
|
||||
|
||||
const currentUsers = result.data?.settings?.api?.ssoSubIds || [];
|
||||
|
||||
if (currentUsers.includes(options.username)) {
|
||||
this.logger.error(`User ${options.username} already exists in SSO users`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUsers = [...currentUsers, options.username];
|
||||
|
||||
const mutationResult = await client.mutate({
|
||||
mutation: UPDATE_SSO_USERS_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: updatedUsers,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.info(`User added: ${options.username}`);
|
||||
|
||||
// Check if restart is required based on mutation response
|
||||
if (mutationResult.data?.updateSettings?.restartRequired) {
|
||||
this.logger.info('Restarting the API');
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.logger.error('Error adding user:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--username <username>',
|
||||
description: 'Cognito Username',
|
||||
})
|
||||
parseUsername(input: string) {
|
||||
if (
|
||||
!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input)
|
||||
) {
|
||||
throw new Error(`Username must be in the format of a UUID (e.g., ${v4()}}\n`);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--disclaimer <disclaimer>',
|
||||
description: 'Disclaimer (y/n)',
|
||||
})
|
||||
parseDisclaimer(input: string) {
|
||||
if (!input || !['y', 'n'].includes(input.toLowerCase())) {
|
||||
throw new Error('Please answer the diclaimer with (y/n)\n');
|
||||
}
|
||||
if (input.toLowerCase() === 'n') {
|
||||
process.exit(1);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Question, QuestionSet } from 'nest-commander';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@QuestionSet({ name: 'add-user' })
|
||||
export class AddSSOUserQuestionSet {
|
||||
static name = 'add-user';
|
||||
|
||||
@Question({
|
||||
message: `Enabling Single Sign-On (SSO) will simplify authentication by centralizing access to your Unraid server. However, this comes with certain security considerations: if your SSO account is compromised, unauthorized access to your server could occur.
|
||||
|
||||
Please note: your existing username and password will continue to work alongside SSO. We recommend using 2FA on your Unraid.net account or a single sign-on provider to enhance security.
|
||||
|
||||
Are you sure you want to proceed with adding a user for SSO? (y/n)
|
||||
`,
|
||||
name: 'disclaimer',
|
||||
validate(input) {
|
||||
if (!input) {
|
||||
return 'Please provide a response';
|
||||
}
|
||||
if (!['y', 'n'].includes(input.toLowerCase())) {
|
||||
return 'Please provide a valid response';
|
||||
}
|
||||
if (input.toLowerCase() === 'n') {
|
||||
process.exit(1);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})
|
||||
parseDisclaimer(val: string) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Question({
|
||||
message:
|
||||
'What is your Unique Unraid Account ID? Find it in your Unraid Account at https://account.unraid.net/settings\n',
|
||||
name: 'username',
|
||||
validate(input) {
|
||||
if (!input) {
|
||||
return 'Username is required';
|
||||
}
|
||||
const randomUUID = uuidv4();
|
||||
|
||||
if (
|
||||
!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||
input
|
||||
)
|
||||
) {
|
||||
return `Username must be in the format of a UUID (e.g., ${randomUUID}).`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})
|
||||
parseName(val: string) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
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 { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js';
|
||||
|
||||
@Injectable()
|
||||
@SubCommand({
|
||||
name: 'list-users',
|
||||
aliases: ['list', 'l'],
|
||||
description: 'List all users for SSO',
|
||||
})
|
||||
export class ListSSOUserCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_input: string[]): Promise<void> {
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.query({
|
||||
query: SSO_USERS_QUERY,
|
||||
});
|
||||
|
||||
const users = result.data?.settings?.api?.ssoSubIds || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
this.logger.info('No SSO users found');
|
||||
} else {
|
||||
this.logger.info(users.join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner, InquirerService, Option, 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 { UPDATE_SSO_USERS_MUTATION } from '@app/unraid-api/cli/mutations/update-sso-users.mutation.js';
|
||||
import { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import {
|
||||
NoSSOUsersFoundError,
|
||||
RemoveSSOUserQuestionSet,
|
||||
} from '@app/unraid-api/cli/sso/remove-sso-user.questions.js';
|
||||
|
||||
interface RemoveSSOUserCommandOptions {
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@SubCommand({
|
||||
name: 'remove-user',
|
||||
aliases: ['remove', 'r'],
|
||||
description: 'Remove a user (or all users) from SSO',
|
||||
})
|
||||
export class RemoveSSOUserCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly inquirerService: InquirerService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
public async run(_input: string[], options?: RemoveSSOUserCommandOptions): Promise<void> {
|
||||
try {
|
||||
options = await this.inquirerService.prompt(RemoveSSOUserQuestionSet.name, options);
|
||||
} catch (error) {
|
||||
if (error instanceof NoSSOUsersFoundError) {
|
||||
this.logger.error(error.message);
|
||||
process.exit(0);
|
||||
} else if (error instanceof Error) {
|
||||
this.logger.error('Failed to fetch SSO users: %s', error.message);
|
||||
process.exit(1);
|
||||
} else {
|
||||
this.logger.error('An unexpected error occurred');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.query({
|
||||
query: SSO_USERS_QUERY,
|
||||
});
|
||||
|
||||
const currentUsers = result.data?.settings?.api?.ssoSubIds || [];
|
||||
|
||||
if (options.username === 'all') {
|
||||
await client.mutate({
|
||||
mutation: UPDATE_SSO_USERS_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.info('All users removed from SSO');
|
||||
} else {
|
||||
const updatedUsers = currentUsers.filter((id: string) => id !== options.username);
|
||||
|
||||
if (updatedUsers.length === currentUsers.length) {
|
||||
this.logger.error(`User ${options.username} not found in SSO users`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutate({
|
||||
mutation: UPDATE_SSO_USERS_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: updatedUsers,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.info('User removed: ' + options.username);
|
||||
}
|
||||
this.logger.info('Restarting the API');
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
|
||||
@Option({
|
||||
name: 'username',
|
||||
flags: '--username <username>',
|
||||
description: 'Cognito Username',
|
||||
})
|
||||
parseUsername(input: string) {
|
||||
if (!input) {
|
||||
throw new Error('Username is required\n');
|
||||
}
|
||||
|
||||
if (
|
||||
!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input)
|
||||
) {
|
||||
throw new Error('Username must be in the format of a UUID (e.g., ${v4()}}\n');
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ChoicesFor, Question, QuestionSet } from 'nest-commander';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js';
|
||||
|
||||
export class NoSSOUsersFoundError extends Error {
|
||||
constructor() {
|
||||
super('No SSO Users Found');
|
||||
this.name = 'NoSSOUsersFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'remove-user' })
|
||||
export class RemoveSSOUserQuestionSet {
|
||||
constructor(private readonly internalClient: CliInternalClientService) {}
|
||||
static name = 'remove-user';
|
||||
|
||||
@Question({
|
||||
message: `Please select from the following list of users to remove from SSO, or enter all to remove all users from SSO.\n`,
|
||||
name: 'username',
|
||||
type: 'list',
|
||||
})
|
||||
parseName(val: string) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'username' })
|
||||
async choicesForUsername() {
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.query({
|
||||
query: SSO_USERS_QUERY,
|
||||
});
|
||||
|
||||
const users = result.data?.settings?.api?.ssoSubIds || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new NoSSOUsersFoundError();
|
||||
}
|
||||
|
||||
users.push('all');
|
||||
return users;
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command.js';
|
||||
import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command.js';
|
||||
import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command.js';
|
||||
import { ValidateTokenCommand } from '@app/unraid-api/cli/sso/validate-token.command.js';
|
||||
|
||||
@Injectable()
|
||||
@Command({
|
||||
name: 'sso',
|
||||
description: 'Main Command to Configure / Validate SSO Tokens',
|
||||
subCommands: [ValidateTokenCommand, AddSSOUserCommand, RemoveSSOUserCommand, ListSSOUserCommand],
|
||||
description: 'SSO Token Validation Command',
|
||||
subCommands: [ValidateTokenCommand],
|
||||
})
|
||||
export class SSOCommand extends CommandRunner {
|
||||
constructor(private readonly logger: LogService) {
|
||||
@@ -20,7 +17,15 @@ export class SSOCommand extends CommandRunner {
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.logger.info('Please provide a subcommand or use --help for more information');
|
||||
this.logger.info('SSO Token Validation Command');
|
||||
this.logger.info('');
|
||||
this.logger.info('To configure SSO providers and authorization rules:');
|
||||
this.logger.info(' Go to Settings -> Management Access in the WebGUI');
|
||||
this.logger.info('');
|
||||
this.logger.info('Available subcommands:');
|
||||
this.logger.info(' validate-token <token> - Validate an SSO session token');
|
||||
this.logger.info('');
|
||||
this.logger.info('Use --help for more information');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user