mirror of
https://github.com/unraid/api.git
synced 2026-05-05 14:41:54 -05:00
refactor: permissions system rewrite (#942)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Eli Bosley <ekbosley@gmail.com>
This commit is contained in:
@@ -51,6 +51,7 @@ typings/
|
||||
|
||||
# Visual Studio Code workspace
|
||||
.vscode/sftp.json
|
||||
.history/
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
PATHS_UNRAID_DATA=./dev/data # Where we store plugin data (e.g. permissions.json)
|
||||
PATHS_STATES=./dev/states # Where .ini files live (e.g. vars.ini)
|
||||
PATHS_AUTH_KEY=./dev/keys # Auth key directory
|
||||
PATHS_DYNAMIX_BASE=./dev/dynamix # Dynamix's data directory
|
||||
PATHS_DYNAMIX_CONFIG_DEFAULT=./dev/dynamix/default.cfg # Dynamix's default config file, which ships with unraid
|
||||
PATHS_DYNAMIX_CONFIG=./dev/dynamix/dynamix.cfg # Dynamix's config file
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "10f356da-1e9e-43b8-9028-a26a645539a6",
|
||||
"key": "73717ca0-8c15-40b9-bcca-8d85656d1438",
|
||||
"name": "Test API Key",
|
||||
"description": "Testing API key creation",
|
||||
"roles": ["guest", "upc"],
|
||||
"createdAt": "2024-10-29T19:59:12.569Z"
|
||||
}
|
||||
Generated
+46
-25
@@ -24,6 +24,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.4.7",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@reflet/cron": "^1.3.1",
|
||||
"@runonflux/nat-upnp": "^1.0.2",
|
||||
@@ -33,6 +34,7 @@
|
||||
"bytes": "^3.1.2",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"casbin": "^5.32.0",
|
||||
"catch-exit": "^1.2.2",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-table": "^0.3.11",
|
||||
@@ -1486,6 +1488,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@casbin/expression-eval": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@casbin/expression-eval/-/expression-eval-5.2.0.tgz",
|
||||
"integrity": "sha512-QNyxosVLIyMRPemwLs5IkuEp81YXMxb6uX/Y1dVR9Z8mCRfZjy/FWV1TuKz5q84oKbXwwo7Wg1IBMQ8Jgcw43g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsep": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/config-validator": {
|
||||
"version": "17.4.4",
|
||||
"dev": true,
|
||||
@@ -3771,6 +3782,17 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.2.1.tgz",
|
||||
"integrity": "sha512-vdt6VjhKC6vcLBJRUb97IuR6Htykn5kokZzmT8+S5XFOLLjUF7rzRpr+nUOhK9pi1L0hhbzSf2v2FJl4v64EJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"license": "MIT",
|
||||
@@ -6561,14 +6583,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/casbin": {
|
||||
"version": "5.30.0",
|
||||
"resolved": "https://registry.npmjs.org/casbin/-/casbin-5.30.0.tgz",
|
||||
"integrity": "sha512-GDc8sImStd+ddBVBfLpe5fJPBWRjeEaz7fkiAGuw0+LTHF2TVvVsMALIMOx+ofzQhm+EHCH7mfiJsrS1Kgef2w==",
|
||||
"version": "5.34.0",
|
||||
"resolved": "https://registry.npmjs.org/casbin/-/casbin-5.34.0.tgz",
|
||||
"integrity": "sha512-KXFsmwKU2tG9HE6qzp38+V9ZuZ+zDd7vD1x6CbnO96gmXPgLgr78i5YtBkxrYx3T20VtTZa+XVS9cHLhSCFvOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@casbin/expression-eval": "^5.2.0",
|
||||
"await-lock": "^2.0.1",
|
||||
"buffer": "^6.0.3",
|
||||
"csv-parse": "^5.3.5",
|
||||
"expression-eval": "^5.0.0",
|
||||
"minimatch": "^7.4.2"
|
||||
}
|
||||
},
|
||||
@@ -9242,15 +9265,6 @@
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expression-eval": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expression-eval/-/expression-eval-5.0.1.tgz",
|
||||
"integrity": "sha512-7SL4miKp19lI834/F6y156xlNg+i9Q41tteuGNCq9C06S78f1bm3BXuvf0+QpQxv369Pv/P2R7Hb17hzxLpbDA==",
|
||||
"deprecated": "The expression-eval npm package is no longer maintained. The package was originally published as part of a now-completed personal project, and I do not have incentives to continue maintenance.",
|
||||
"dependencies": {
|
||||
"jsep": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"license": "MIT"
|
||||
@@ -11258,6 +11272,7 @@
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.3.5.tgz",
|
||||
"integrity": "sha512-AoRLBDc6JNnKjNcmonituEABS5bcfqDhQAWWXNTFrqu6nVXBpBAGfcoTGZMFlIrh9FjmE1CQyX9CTNwZrXMMDA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
@@ -17520,6 +17535,14 @@
|
||||
"version": "0.2.3",
|
||||
"dev": true
|
||||
},
|
||||
"@casbin/expression-eval": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@casbin/expression-eval/-/expression-eval-5.2.0.tgz",
|
||||
"integrity": "sha512-QNyxosVLIyMRPemwLs5IkuEp81YXMxb6uX/Y1dVR9Z8mCRfZjy/FWV1TuKz5q84oKbXwwo7Wg1IBMQ8Jgcw43g==",
|
||||
"requires": {
|
||||
"jsep": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"@commitlint/config-validator": {
|
||||
"version": "17.4.4",
|
||||
"dev": true,
|
||||
@@ -18966,6 +18989,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/throttler": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.2.1.tgz",
|
||||
"integrity": "sha512-vdt6VjhKC6vcLBJRUb97IuR6Htykn5kokZzmT8+S5XFOLLjUF7rzRpr+nUOhK9pi1L0hhbzSf2v2FJl4v64EJA==",
|
||||
"requires": {}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"requires": {
|
||||
@@ -20851,14 +20880,14 @@
|
||||
}
|
||||
},
|
||||
"casbin": {
|
||||
"version": "5.30.0",
|
||||
"resolved": "https://registry.npmjs.org/casbin/-/casbin-5.30.0.tgz",
|
||||
"integrity": "sha512-GDc8sImStd+ddBVBfLpe5fJPBWRjeEaz7fkiAGuw0+LTHF2TVvVsMALIMOx+ofzQhm+EHCH7mfiJsrS1Kgef2w==",
|
||||
"version": "5.34.0",
|
||||
"resolved": "https://registry.npmjs.org/casbin/-/casbin-5.34.0.tgz",
|
||||
"integrity": "sha512-KXFsmwKU2tG9HE6qzp38+V9ZuZ+zDd7vD1x6CbnO96gmXPgLgr78i5YtBkxrYx3T20VtTZa+XVS9cHLhSCFvOA==",
|
||||
"requires": {
|
||||
"@casbin/expression-eval": "^5.2.0",
|
||||
"await-lock": "^2.0.1",
|
||||
"buffer": "^6.0.3",
|
||||
"csv-parse": "^5.3.5",
|
||||
"expression-eval": "^5.0.0",
|
||||
"minimatch": "^7.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22573,14 +22602,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"expression-eval": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expression-eval/-/expression-eval-5.0.1.tgz",
|
||||
"integrity": "sha512-7SL4miKp19lI834/F6y156xlNg+i9Q41tteuGNCq9C06S78f1bm3BXuvf0+QpQxv369Pv/P2R7Hb17hzxLpbDA==",
|
||||
"requires": {
|
||||
"jsep": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"extend": {
|
||||
"version": "3.0.2"
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.4.7",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@reflet/cron": "^1.3.1",
|
||||
"@runonflux/nat-upnp": "^1.0.2",
|
||||
@@ -64,6 +65,7 @@
|
||||
"bytes": "^3.1.2",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"casbin": "^5.32.0",
|
||||
"catch-exit": "^1.2.2",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-table": "^0.3.11",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { test, expect } from 'vitest';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer';
|
||||
import { initialState } from '@app/store/modules/config';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
test('it creates a FLASH config with NO OPTIONAL values', () => {
|
||||
const basicConfig = initialState;
|
||||
@@ -25,6 +26,7 @@ test('it creates a FLASH config with NO OPTIONAL values', () => {
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"username": "",
|
||||
@@ -62,6 +64,7 @@ test('it creates a MEMORY config with NO OPTIONAL values', () => {
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"username": "",
|
||||
@@ -105,6 +108,7 @@ test('it creates a FLASH config with OPTIONAL values', () => {
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"upnpEnabled": "yes",
|
||||
@@ -154,6 +158,7 @@ test('it creates a MEMORY config with OPTIONAL values', () => {
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"upnpEnabled": "yes",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { store } from '@app/store';
|
||||
|
||||
test('Before init returns default values for all fields', async () => {
|
||||
@@ -30,6 +31,7 @@ test('Before init returns default values for all fields', async () => {
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"upnpEnabled": "",
|
||||
@@ -80,6 +82,7 @@ test('After init returns values from cfg file for all fields', async () => {
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
email: 'test@example.com',
|
||||
idtoken: '',
|
||||
localApiKey: '',
|
||||
refreshtoken: '',
|
||||
regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0',
|
||||
upnpEnabled: 'no',
|
||||
@@ -96,9 +99,7 @@ test('After init returns values from cfg file for all fields', async () => {
|
||||
});
|
||||
|
||||
test('updateUserConfig merges in changes to current state', async () => {
|
||||
const { loadConfigFile, updateUserConfig } = await import(
|
||||
'@app/store/modules/config'
|
||||
);
|
||||
const { loadConfigFile, updateUserConfig } = await import('@app/store/modules/config');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
@@ -138,6 +139,7 @@ test('updateUserConfig merges in changes to current state', async () => {
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
email: 'test@example.com',
|
||||
idtoken: '',
|
||||
localApiKey: '',
|
||||
refreshtoken: '',
|
||||
regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0',
|
||||
upnpEnabled: 'no',
|
||||
|
||||
@@ -2,8 +2,8 @@ import { expect, test } from 'vitest';
|
||||
import { store } from '@app/store';
|
||||
|
||||
test('Returns paths', async () => {
|
||||
const { paths } = store.getState();
|
||||
expect(Object.keys(paths)).toMatchInlineSnapshot(`
|
||||
const { paths } = store.getState();
|
||||
expect(Object.keys(paths)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"core",
|
||||
"unraid-api-base",
|
||||
@@ -26,6 +26,7 @@ test('Returns paths', async () => {
|
||||
"log-base",
|
||||
"var-run",
|
||||
"auth-sessions",
|
||||
"auth-keys",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import ipRegex from 'ip-regex';
|
||||
import readLine from 'readline';
|
||||
import { setEnv } from '@app/cli/set-env';
|
||||
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
|
||||
import { cliLogger } from '@app/core/log';
|
||||
import { getters, store } from '@app/store';
|
||||
import { stdout } from 'process';
|
||||
import { loadConfigFile } from '@app/store/modules/config';
|
||||
import { getApiApolloClient } from '../../graphql/client/api/get-api-client';
|
||||
import {
|
||||
getCloudDocument,
|
||||
getServersDocument,
|
||||
type getServersQuery,
|
||||
type getCloudQuery,
|
||||
} from '../../graphql/generated/api/operations';
|
||||
import { MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp';
|
||||
import readLine from 'readline';
|
||||
|
||||
import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js';
|
||||
import ipRegex from 'ip-regex';
|
||||
|
||||
import { setEnv } from '@app/cli/set-env';
|
||||
import { cliLogger } from '@app/core/log';
|
||||
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import { MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { getters, store } from '@app/store';
|
||||
import { loadConfigFile } from '@app/store/modules/config';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp';
|
||||
|
||||
import type { getCloudQuery, getServersQuery } from '../../graphql/generated/api/operations';
|
||||
import { getApiApolloClient } from '../../graphql/client/api/get-api-client';
|
||||
import { getCloudDocument, getServersDocument } from '../../graphql/generated/api/operations';
|
||||
|
||||
type CloudQueryResult = NonNullable<ApolloQueryResult<getCloudQuery>['data']['cloud']>;
|
||||
type ServersQueryResultServer = NonNullable<ApolloQueryResult<getServersQuery>['data']['servers']>[0];
|
||||
@@ -263,7 +262,7 @@ export const report = async (...argv: string[]) => {
|
||||
const { config, emhttp } = store.getState();
|
||||
if (!config.upc.apikey) throw new Error('Missing UPC API key');
|
||||
|
||||
const client = getApiApolloClient({ upcApiKey: config.upc.apikey });
|
||||
const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' });
|
||||
// Fetch the cloud endpoint
|
||||
const cloud = await getCloudData(client);
|
||||
|
||||
@@ -288,7 +287,7 @@ export const report = async (...argv: string[]) => {
|
||||
environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT',
|
||||
nodeVersion: process.version,
|
||||
},
|
||||
apiKey: isApiKeyValid ? 'valid' : cloud?.apiKey.error ?? 'invalid',
|
||||
apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'),
|
||||
...(servers ? { servers } : {}),
|
||||
myServers: {
|
||||
status: config?.remote?.username ? 'authenticated' : 'signed out',
|
||||
@@ -304,7 +303,7 @@ export const report = async (...argv: string[]) => {
|
||||
status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT,
|
||||
timeout: cloud?.minigraphql.timeout ?? null,
|
||||
error:
|
||||
cloud?.minigraphql.error ?? !cloud?.minigraphql.status ? 'API Disconnected' : null,
|
||||
(cloud?.minigraphql.error ?? !cloud?.minigraphql.status) ? 'API Disconnected' : null,
|
||||
},
|
||||
cloud: {
|
||||
status: cloud?.cloud.status ?? 'error',
|
||||
|
||||
@@ -51,6 +51,7 @@ export const getWriteableConfig = <T extends ConfigType>(
|
||||
wanport: remote.wanport ?? initialState.remote.wanport,
|
||||
...(remote.upnpEnabled ? { upnpEnabled: remote.upnpEnabled } : {}),
|
||||
apikey: remote.apikey ?? initialState.remote.apikey,
|
||||
localApiKey: remote.localApiKey ?? initialState.remote.localApiKey,
|
||||
email: remote.email ?? initialState.remote.email,
|
||||
username: remote.username ?? initialState.remote.username,
|
||||
avatar: remote.avatar ?? initialState.remote.avatar,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
split,
|
||||
} from '@apollo/client/core/index.js';
|
||||
import { ApolloClient, HttpLink, InMemoryCache, split } from '@apollo/client/core/index.js';
|
||||
import { onError } from '@apollo/client/link/error/index.js';
|
||||
import { getInternalApiAddress } from '@app/consts';
|
||||
import WebSocket from 'ws';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import { getMainDefinition } from '@apollo/client/utilities/index.js';
|
||||
import { graphqlLogger } from '@app/core/log';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
|
||||
import { getMainDefinition } from '@apollo/client/utilities/index.js';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import { getInternalApiAddress } from '@app/consts';
|
||||
import { graphqlLogger } from '@app/core/log';
|
||||
import { getters } from '@app/store/index';
|
||||
|
||||
const getWebsocketWithHeaders = () => {
|
||||
@@ -27,18 +23,15 @@ const getWebsocketWithHeaders = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getApiApolloClient = ({ upcApiKey }: { upcApiKey: string }) => {
|
||||
export const getApiApolloClient = ({ localApiKey }: { localApiKey: string }) => {
|
||||
const nginxPort = getters?.emhttp()?.nginx?.httpPort ?? 80;
|
||||
graphqlLogger.debug(
|
||||
'Internal GraphQL URL: %s',
|
||||
getInternalApiAddress(true, nginxPort)
|
||||
);
|
||||
graphqlLogger.debug('Internal GraphQL URL: %s', getInternalApiAddress(true, nginxPort));
|
||||
const httpLink = new HttpLink({
|
||||
uri: getInternalApiAddress(true, nginxPort),
|
||||
fetch,
|
||||
headers: {
|
||||
Origin: '/var/run/unraid-cli.sock',
|
||||
'x-api-key': upcApiKey,
|
||||
'x-api-key': localApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
@@ -49,7 +42,7 @@ export const getApiApolloClient = ({ upcApiKey }: { upcApiKey: string }) => {
|
||||
webSocketImpl: getWebsocketWithHeaders(),
|
||||
url: getInternalApiAddress(false, nginxPort),
|
||||
connectionParams: () => {
|
||||
return { 'x-api-key': upcApiKey };
|
||||
return { 'x-api-key': localApiKey };
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -57,10 +50,7 @@ export const getApiApolloClient = ({ upcApiKey }: { upcApiKey: string }) => {
|
||||
const splitLink = split(
|
||||
({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return (
|
||||
definition.kind === 'OperationDefinition' &&
|
||||
definition.operation === 'subscription'
|
||||
);
|
||||
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
|
||||
},
|
||||
wsLink,
|
||||
httpLink
|
||||
@@ -68,10 +58,7 @@ export const getApiApolloClient = ({ upcApiKey }: { upcApiKey: string }) => {
|
||||
|
||||
const errorLink = onError(({ networkError }) => {
|
||||
if (networkError) {
|
||||
graphqlLogger.warn(
|
||||
'[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o',
|
||||
networkError
|
||||
);
|
||||
graphqlLogger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { getBannerPathIfPresent, getCasePathIfPresent } from "@app/core/utils/images/image-file-helpers";
|
||||
import { apiKeyToUser } from "@app/graphql/index";
|
||||
import { type Request, type Response } from "express";
|
||||
export const getImages = async (req: Request, res: Response) => {
|
||||
// @TODO - Clean up this function
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (
|
||||
apiKey &&
|
||||
typeof apiKey === 'string' &&
|
||||
(await apiKeyToUser(apiKey)).role !== 'guest'
|
||||
) {
|
||||
if (req.params.type === 'banner') {
|
||||
const path = await getBannerPathIfPresent();
|
||||
if (path) {
|
||||
res.sendFile(path);
|
||||
return;
|
||||
}
|
||||
} else if (req.params.type === 'case') {
|
||||
const path = await getCasePathIfPresent();
|
||||
if (path) {
|
||||
res.sendFile(path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(404).send('no customization of this type found');
|
||||
}
|
||||
|
||||
return res.status(403).send('unauthorized');
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Types from '@app/graphql/generated/api/types';
|
||||
|
||||
import { z } from 'zod'
|
||||
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
|
||||
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types'
|
||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
|
||||
type Properties<T> = Required<{
|
||||
@@ -51,6 +51,10 @@ export const NotificationTypeSchema = z.nativeEnum(NotificationType);
|
||||
|
||||
export const RegistrationStateSchema = z.nativeEnum(RegistrationState);
|
||||
|
||||
export const ResourceSchema = z.nativeEnum(Resource);
|
||||
|
||||
export const RoleSchema = z.nativeEnum(Role);
|
||||
|
||||
export const ServerStatusSchema = z.nativeEnum(ServerStatus);
|
||||
|
||||
export const TemperatureSchema = z.nativeEnum(Temperature);
|
||||
@@ -88,6 +92,29 @@ export function AccessUrlInputSchema(): z.ZodObject<Properties<AccessUrlInput>>
|
||||
})
|
||||
}
|
||||
|
||||
export function AddPermissionInputSchema(): z.ZodObject<Properties<AddPermissionInput>> {
|
||||
return z.object({
|
||||
action: z.string(),
|
||||
possession: z.string(),
|
||||
resource: ResourceSchema,
|
||||
role: RoleSchema
|
||||
})
|
||||
}
|
||||
|
||||
export function AddRoleForApiKeyInputSchema(): z.ZodObject<Properties<AddRoleForApiKeyInput>> {
|
||||
return z.object({
|
||||
apiKeyId: z.string(),
|
||||
role: RoleSchema
|
||||
})
|
||||
}
|
||||
|
||||
export function AddRoleForUserInputSchema(): z.ZodObject<Properties<AddRoleForUserInput>> {
|
||||
return z.object({
|
||||
role: RoleSchema,
|
||||
userId: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function AllowedOriginInputSchema(): z.ZodObject<Properties<AllowedOriginInput>> {
|
||||
return z.object({
|
||||
origins: z.array(z.string())
|
||||
@@ -97,11 +124,11 @@ export function AllowedOriginInputSchema(): z.ZodObject<Properties<AllowedOrigin
|
||||
export function ApiKeySchema(): z.ZodObject<Properties<ApiKey>> {
|
||||
return z.object({
|
||||
__typename: z.literal('ApiKey').optional(),
|
||||
createdAt: z.string(),
|
||||
description: z.string().nullish(),
|
||||
expiresAt: z.number(),
|
||||
key: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
scopes: definedNonNullAnySchema
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,6 +140,18 @@ export function ApiKeyResponseSchema(): z.ZodObject<Properties<ApiKeyResponse>>
|
||||
})
|
||||
}
|
||||
|
||||
export function ApiKeyWithSecretSchema(): z.ZodObject<Properties<ApiKeyWithSecret>> {
|
||||
return z.object({
|
||||
__typename: z.literal('ApiKeyWithSecret').optional(),
|
||||
createdAt: z.string(),
|
||||
description: z.string().nullish(),
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
export function ArrayTypeSchema(): z.ZodObject<Properties<ArrayType>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Array').optional(),
|
||||
@@ -281,6 +320,14 @@ export function ContainerPortSchema(): z.ZodObject<Properties<ContainerPort>> {
|
||||
})
|
||||
}
|
||||
|
||||
export function CreateApiKeyInputSchema(): z.ZodObject<Properties<CreateApiKeyInput>> {
|
||||
return z.object({
|
||||
description: z.string().nullish(),
|
||||
name: z.string(),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
export function DevicesSchema(): z.ZodObject<Properties<Devices>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Devices').optional(),
|
||||
@@ -521,7 +568,7 @@ export function MeSchema(): z.ZodObject<Properties<Me>> {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
permissions: definedNonNullAnySchema.nullish(),
|
||||
roles: z.string()
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -811,6 +858,13 @@ export function RemoteAccessSchema(): z.ZodObject<Properties<RemoteAccess>> {
|
||||
})
|
||||
}
|
||||
|
||||
export function RemoveRoleFromApiKeyInputSchema(): z.ZodObject<Properties<RemoveRoleFromApiKeyInput>> {
|
||||
return z.object({
|
||||
apiKeyId: z.string(),
|
||||
role: RoleSchema
|
||||
})
|
||||
}
|
||||
|
||||
export function ServerSchema(): z.ZodObject<Properties<Server>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Server').optional(),
|
||||
@@ -958,7 +1012,7 @@ export function UserSchema(): z.ZodObject<Properties<User>> {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
password: z.boolean().nullish(),
|
||||
roles: z.string()
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -967,7 +1021,7 @@ export function UserAccountSchema(): z.ZodObject<Properties<UserAccount>> {
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
roles: z.string()
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1175,14 +1229,6 @@ export function WelcomeSchema(): z.ZodObject<Properties<Welcome>> {
|
||||
})
|
||||
}
|
||||
|
||||
export function addApiKeyInputSchema(): z.ZodObject<Properties<addApiKeyInput>> {
|
||||
return z.object({
|
||||
key: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
userId: z.string().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function addUserInputSchema(): z.ZodObject<Properties<addUserInput>> {
|
||||
return z.object({
|
||||
description: z.string().nullish(),
|
||||
@@ -1198,25 +1244,12 @@ export function arrayDiskInputSchema(): z.ZodObject<Properties<arrayDiskInput>>
|
||||
})
|
||||
}
|
||||
|
||||
export function authenticateInputSchema(): z.ZodObject<Properties<authenticateInput>> {
|
||||
return z.object({
|
||||
password: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteUserInputSchema(): z.ZodObject<Properties<deleteUserInput>> {
|
||||
return z.object({
|
||||
name: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function updateApikeyInputSchema(): z.ZodObject<Properties<updateApikeyInput>> {
|
||||
return z.object({
|
||||
description: z.string().nullish(),
|
||||
expiresAt: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
export function usersInputSchema(): z.ZodObject<Properties<usersInput>> {
|
||||
return z.object({
|
||||
slim: z.boolean().nullish()
|
||||
|
||||
@@ -39,17 +39,34 @@ export type AccessUrlInput = {
|
||||
type: URL_TYPE;
|
||||
};
|
||||
|
||||
export type AddPermissionInput = {
|
||||
action: Scalars['String']['input'];
|
||||
possession: Scalars['String']['input'];
|
||||
resource: Resource;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type AddRoleForApiKeyInput = {
|
||||
apiKeyId: Scalars['ID']['input'];
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type AddRoleForUserInput = {
|
||||
role: Role;
|
||||
userId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AllowedOriginInput = {
|
||||
origins: Array<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
__typename?: 'ApiKey';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
expiresAt: Scalars['Long']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
scopes: Scalars['JSON']['output'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiKeyResponse = {
|
||||
@@ -58,6 +75,16 @@ export type ApiKeyResponse = {
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ApiKeyWithSecret = {
|
||||
__typename?: 'ApiKeyWithSecret';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ArrayType = Node & {
|
||||
__typename?: 'Array';
|
||||
/** Current boot disk */
|
||||
@@ -318,6 +345,12 @@ export enum ContainerState {
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type CreateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type Devices = {
|
||||
__typename?: 'Devices';
|
||||
gpu?: Maybe<Array<Maybe<Gpu>>>;
|
||||
@@ -563,7 +596,7 @@ export type Me = UserAccount & {
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions?: Maybe<Scalars['JSON']['output']>;
|
||||
roles: Scalars['String']['output'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export enum MemoryFormFactor {
|
||||
@@ -616,10 +649,11 @@ export type Mount = {
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
/** Create a new API key */
|
||||
addApikey?: Maybe<ApiKey>;
|
||||
/** Add new disk to array */
|
||||
addDiskToArray?: Maybe<ArrayType>;
|
||||
addPermission: Scalars['Boolean']['output'];
|
||||
addRoleForApiKey: Scalars['Boolean']['output'];
|
||||
addRoleForUser: Scalars['Boolean']['output'];
|
||||
/** Add a new user */
|
||||
addUser?: Maybe<User>;
|
||||
archiveAll: NotificationOverview;
|
||||
@@ -631,6 +665,7 @@ export type Mutation = {
|
||||
clearArrayDiskStatistics?: Maybe<Scalars['JSON']['output']>;
|
||||
connectSignIn: Scalars['Boolean']['output'];
|
||||
connectSignOut: Scalars['Boolean']['output'];
|
||||
createApiKey: ApiKeyWithSecret;
|
||||
createNotification: Notification;
|
||||
/** Deletes all archived notifications on server. */
|
||||
deleteArchivedNotifications: NotificationOverview;
|
||||
@@ -638,8 +673,6 @@ export type Mutation = {
|
||||
/** Delete a user */
|
||||
deleteUser?: Maybe<User>;
|
||||
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
|
||||
/** Get an existing API key */
|
||||
getApiKey?: Maybe<ApiKey>;
|
||||
login?: Maybe<Scalars['String']['output']>;
|
||||
mountArrayDisk?: Maybe<Disk>;
|
||||
/** Pause parity check */
|
||||
@@ -649,6 +682,7 @@ export type Mutation = {
|
||||
recalculateOverview: NotificationOverview;
|
||||
/** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */
|
||||
removeDiskFromArray?: Maybe<ArrayType>;
|
||||
removeRoleFromApiKey: Scalars['Boolean']['output'];
|
||||
/** Resume parity check */
|
||||
resumeParityCheck?: Maybe<Scalars['JSON']['output']>;
|
||||
setAdditionalAllowedOrigins: Array<Scalars['String']['output']>;
|
||||
@@ -665,14 +699,6 @@ export type Mutation = {
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: Notification;
|
||||
/** Update an existing API key */
|
||||
updateApikey?: Maybe<ApiKey>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationaddApikeyArgs = {
|
||||
input?: InputMaybe<updateApikeyInput>;
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
@@ -681,6 +707,21 @@ export type MutationaddDiskToArrayArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationaddPermissionArgs = {
|
||||
input: AddPermissionInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationaddRoleForApiKeyArgs = {
|
||||
input: AddRoleForApiKeyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationaddRoleForUserArgs = {
|
||||
input: AddRoleForUserInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationaddUserArgs = {
|
||||
input: addUserInput;
|
||||
};
|
||||
@@ -711,6 +752,11 @@ export type MutationconnectSignInArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationcreateApiKeyArgs = {
|
||||
input: CreateApiKeyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationcreateNotificationArgs = {
|
||||
input: NotificationData;
|
||||
};
|
||||
@@ -732,12 +778,6 @@ export type MutationenableDynamicRemoteAccessArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationgetApiKeyArgs = {
|
||||
input?: InputMaybe<authenticateInput>;
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationloginArgs = {
|
||||
password: Scalars['String']['input'];
|
||||
username: Scalars['String']['input'];
|
||||
@@ -754,6 +794,11 @@ export type MutationremoveDiskFromArrayArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationremoveRoleFromApiKeyArgs = {
|
||||
input: RemoveRoleFromApiKeyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationsetAdditionalAllowedOriginsArgs = {
|
||||
input: AllowedOriginInput;
|
||||
};
|
||||
@@ -788,12 +833,6 @@ export type MutationunreadNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationupdateApikeyArgs = {
|
||||
input?: InputMaybe<updateApikeyInput>;
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Network = Node & {
|
||||
__typename?: 'Network';
|
||||
accessUrls?: Maybe<Array<AccessUrl>>;
|
||||
@@ -995,8 +1034,8 @@ export type ProfileModel = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
/** Get all API keys */
|
||||
apiKeys?: Maybe<Array<Maybe<ApiKey>>>;
|
||||
apiKey?: Maybe<ApiKey>;
|
||||
apiKeys: Array<ApiKey>;
|
||||
/** An Unraid array consisting of 1 or 2 Parity disks and a number of Data disks. */
|
||||
array: ArrayType;
|
||||
cloud?: Maybe<Cloud>;
|
||||
@@ -1042,6 +1081,11 @@ export type Query = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryapiKeyArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QuerydiskArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -1136,6 +1180,52 @@ export type RemoteAccess = {
|
||||
port?: Maybe<Scalars['Port']['output']>;
|
||||
};
|
||||
|
||||
export type RemoveRoleFromApiKeyInput = {
|
||||
apiKeyId: Scalars['ID']['input'];
|
||||
role: Role;
|
||||
};
|
||||
|
||||
/** Available resources for permissions */
|
||||
export enum Resource {
|
||||
API_KEY = 'api_key',
|
||||
ARRAY = 'array',
|
||||
CLOUD = 'cloud',
|
||||
CONFIG = 'config',
|
||||
CONNECT = 'connect',
|
||||
CRASH_REPORTING_ENABLED = 'crash_reporting_enabled',
|
||||
CUSTOMIZATIONS = 'customizations',
|
||||
DASHBOARD = 'dashboard',
|
||||
DISK = 'disk',
|
||||
DISPLAY = 'display',
|
||||
DOCKER = 'docker',
|
||||
FLASH = 'flash',
|
||||
INFO = 'info',
|
||||
LOGS = 'logs',
|
||||
ME = 'me',
|
||||
NETWORK = 'network',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
ONLINE = 'online',
|
||||
OS = 'os',
|
||||
OWNER = 'owner',
|
||||
PERMISSION = 'permission',
|
||||
REGISTRATION = 'registration',
|
||||
SERVERS = 'servers',
|
||||
SERVICES = 'services',
|
||||
SHARE = 'share',
|
||||
VARS = 'vars',
|
||||
VMS = 'vms',
|
||||
WELCOME = 'welcome'
|
||||
}
|
||||
|
||||
/** Available roles for API keys and users */
|
||||
export enum Role {
|
||||
ADMIN = 'admin',
|
||||
GUEST = 'guest',
|
||||
MY_SERVERS = 'my_servers',
|
||||
NOTIFIER = 'notifier',
|
||||
UPC = 'upc'
|
||||
}
|
||||
|
||||
export type Server = {
|
||||
__typename?: 'Server';
|
||||
apikey: Scalars['String']['output'];
|
||||
@@ -1199,7 +1289,6 @@ export type Share = {
|
||||
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
apikeys?: Maybe<Array<Maybe<ApiKey>>>;
|
||||
array: ArrayType;
|
||||
config: Config;
|
||||
display?: Maybe<Display>;
|
||||
@@ -1357,14 +1446,14 @@ export type User = UserAccount & {
|
||||
name: Scalars['String']['output'];
|
||||
/** If the account has a password set */
|
||||
password?: Maybe<Scalars['Boolean']['output']>;
|
||||
roles: Scalars['String']['output'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type UserAccount = {
|
||||
description: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
roles: Scalars['String']['output'];
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type Vars = Node & {
|
||||
@@ -1603,12 +1692,6 @@ export type Welcome = {
|
||||
message: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type addApiKeyInput = {
|
||||
key?: InputMaybe<Scalars['String']['input']>;
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type addUserInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -1622,10 +1705,6 @@ export type arrayDiskInput = {
|
||||
slot?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type authenticateInput = {
|
||||
password: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type deleteUserInput = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1646,11 +1725,6 @@ export enum registrationType {
|
||||
UNLEASHED = 'UNLEASHED'
|
||||
}
|
||||
|
||||
export type updateApikeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
expiresAt: Scalars['Long']['input'];
|
||||
};
|
||||
|
||||
export type usersInput = {
|
||||
slim?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
@@ -1734,9 +1808,13 @@ export type ResolversInterfaceTypes<_RefType extends Record<string, unknown>> =
|
||||
export type ResolversTypes = ResolversObject<{
|
||||
AccessUrl: ResolverTypeWrapper<AccessUrl>;
|
||||
AccessUrlInput: AccessUrlInput;
|
||||
AddPermissionInput: AddPermissionInput;
|
||||
AddRoleForApiKeyInput: AddRoleForApiKeyInput;
|
||||
AddRoleForUserInput: AddRoleForUserInput;
|
||||
AllowedOriginInput: AllowedOriginInput;
|
||||
ApiKey: ResolverTypeWrapper<ApiKey>;
|
||||
ApiKeyResponse: ResolverTypeWrapper<ApiKeyResponse>;
|
||||
ApiKeyWithSecret: ResolverTypeWrapper<ApiKeyWithSecret>;
|
||||
Array: ResolverTypeWrapper<ArrayType>;
|
||||
ArrayCapacity: ResolverTypeWrapper<ArrayCapacity>;
|
||||
ArrayDisk: ResolverTypeWrapper<ArrayDisk>;
|
||||
@@ -1761,6 +1839,7 @@ export type ResolversTypes = ResolversObject<{
|
||||
ContainerPort: ResolverTypeWrapper<ContainerPort>;
|
||||
ContainerPortType: ContainerPortType;
|
||||
ContainerState: ContainerState;
|
||||
CreateApiKeyInput: CreateApiKeyInput;
|
||||
DateTime: ResolverTypeWrapper<Scalars['DateTime']['output']>;
|
||||
Devices: ResolverTypeWrapper<Devices>;
|
||||
Disk: ResolverTypeWrapper<Disk>;
|
||||
@@ -1817,6 +1896,9 @@ export type ResolversTypes = ResolversObject<{
|
||||
RegistrationState: RegistrationState;
|
||||
RelayResponse: ResolverTypeWrapper<RelayResponse>;
|
||||
RemoteAccess: ResolverTypeWrapper<RemoteAccess>;
|
||||
RemoveRoleFromApiKeyInput: RemoveRoleFromApiKeyInput;
|
||||
Resource: Resource;
|
||||
Role: Role;
|
||||
Server: ResolverTypeWrapper<Server>;
|
||||
ServerStatus: ServerStatus;
|
||||
Service: ResolverTypeWrapper<Service>;
|
||||
@@ -1843,14 +1925,11 @@ export type ResolversTypes = ResolversObject<{
|
||||
WAN_ACCESS_TYPE: WAN_ACCESS_TYPE;
|
||||
WAN_FORWARD_TYPE: WAN_FORWARD_TYPE;
|
||||
Welcome: ResolverTypeWrapper<Welcome>;
|
||||
addApiKeyInput: addApiKeyInput;
|
||||
addUserInput: addUserInput;
|
||||
arrayDiskInput: arrayDiskInput;
|
||||
authenticateInput: authenticateInput;
|
||||
deleteUserInput: deleteUserInput;
|
||||
mdState: mdState;
|
||||
registrationType: registrationType;
|
||||
updateApikeyInput: updateApikeyInput;
|
||||
usersInput: usersInput;
|
||||
}>;
|
||||
|
||||
@@ -1858,9 +1937,13 @@ export type ResolversTypes = ResolversObject<{
|
||||
export type ResolversParentTypes = ResolversObject<{
|
||||
AccessUrl: AccessUrl;
|
||||
AccessUrlInput: AccessUrlInput;
|
||||
AddPermissionInput: AddPermissionInput;
|
||||
AddRoleForApiKeyInput: AddRoleForApiKeyInput;
|
||||
AddRoleForUserInput: AddRoleForUserInput;
|
||||
AllowedOriginInput: AllowedOriginInput;
|
||||
ApiKey: ApiKey;
|
||||
ApiKeyResponse: ApiKeyResponse;
|
||||
ApiKeyWithSecret: ApiKeyWithSecret;
|
||||
Array: ArrayType;
|
||||
ArrayCapacity: ArrayCapacity;
|
||||
ArrayDisk: ArrayDisk;
|
||||
@@ -1877,6 +1960,7 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
ContainerHostConfig: ContainerHostConfig;
|
||||
ContainerMount: ContainerMount;
|
||||
ContainerPort: ContainerPort;
|
||||
CreateApiKeyInput: CreateApiKeyInput;
|
||||
DateTime: Scalars['DateTime']['output'];
|
||||
Devices: Devices;
|
||||
Disk: Disk;
|
||||
@@ -1923,6 +2007,7 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
Registration: Registration;
|
||||
RelayResponse: RelayResponse;
|
||||
RemoteAccess: RemoteAccess;
|
||||
RemoveRoleFromApiKeyInput: RemoveRoleFromApiKeyInput;
|
||||
Server: Server;
|
||||
Service: Service;
|
||||
SetupRemoteAccessInput: SetupRemoteAccessInput;
|
||||
@@ -1942,12 +2027,9 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
VmDomain: VmDomain;
|
||||
Vms: Vms;
|
||||
Welcome: Welcome;
|
||||
addApiKeyInput: addApiKeyInput;
|
||||
addUserInput: addUserInput;
|
||||
arrayDiskInput: arrayDiskInput;
|
||||
authenticateInput: authenticateInput;
|
||||
deleteUserInput: deleteUserInput;
|
||||
updateApikeyInput: updateApikeyInput;
|
||||
usersInput: usersInput;
|
||||
}>;
|
||||
|
||||
@@ -1960,11 +2042,11 @@ export type AccessUrlResolvers<ContextType = Context, ParentType extends Resolve
|
||||
}>;
|
||||
|
||||
export type ApiKeyResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ApiKey'] = ResolversParentTypes['ApiKey']> = ResolversObject<{
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
expiresAt?: Resolver<ResolversTypes['Long'], ParentType, ContextType>;
|
||||
key?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
scopes?: Resolver<ResolversTypes['JSON'], ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -1974,6 +2056,16 @@ export type ApiKeyResponseResolvers<ContextType = Context, ParentType extends Re
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type ApiKeyWithSecretResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ApiKeyWithSecret'] = ResolversParentTypes['ApiKeyWithSecret']> = ResolversObject<{
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
key?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type ArrayResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Array'] = ResolversParentTypes['Array']> = ResolversObject<{
|
||||
boot?: Resolver<Maybe<ResolversTypes['ArrayDisk']>, ParentType, ContextType>;
|
||||
caches?: Resolver<Array<ResolversTypes['ArrayDisk']>, ParentType, ContextType>;
|
||||
@@ -2311,7 +2403,7 @@ export type MeResolvers<ContextType = Context, ParentType extends ResolversParen
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
permissions?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
roles?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -2346,8 +2438,10 @@ export type MountResolvers<ContextType = Context, ParentType extends ResolversPa
|
||||
}>;
|
||||
|
||||
export type MutationResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = ResolversObject<{
|
||||
addApikey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationaddApikeyArgs, 'name'>>;
|
||||
addDiskToArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationaddDiskToArrayArgs>>;
|
||||
addPermission?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddPermissionArgs, 'input'>>;
|
||||
addRoleForApiKey?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddRoleForApiKeyArgs, 'input'>>;
|
||||
addRoleForUser?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddRoleForUserArgs, 'input'>>;
|
||||
addUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationaddUserArgs, 'input'>>;
|
||||
archiveAll?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationarchiveAllArgs>>;
|
||||
archiveNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationarchiveNotificationArgs, 'id'>>;
|
||||
@@ -2356,18 +2450,19 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
|
||||
clearArrayDiskStatistics?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<MutationclearArrayDiskStatisticsArgs, 'id'>>;
|
||||
connectSignIn?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationconnectSignInArgs, 'input'>>;
|
||||
connectSignOut?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
createApiKey?: Resolver<ResolversTypes['ApiKeyWithSecret'], ParentType, ContextType, RequireFields<MutationcreateApiKeyArgs, 'input'>>;
|
||||
createNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationcreateNotificationArgs, 'input'>>;
|
||||
deleteArchivedNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
|
||||
deleteNotification?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, RequireFields<MutationdeleteNotificationArgs, 'id' | 'type'>>;
|
||||
deleteUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationdeleteUserArgs, 'input'>>;
|
||||
enableDynamicRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationenableDynamicRemoteAccessArgs, 'input'>>;
|
||||
getApiKey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationgetApiKeyArgs, 'name'>>;
|
||||
login?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationloginArgs, 'password' | 'username'>>;
|
||||
mountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationmountArrayDiskArgs, 'id'>>;
|
||||
pauseParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
reboot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
recalculateOverview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
|
||||
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationremoveDiskFromArrayArgs>>;
|
||||
removeRoleFromApiKey?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationremoveRoleFromApiKeyArgs, 'input'>>;
|
||||
resumeParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
setAdditionalAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationsetAdditionalAllowedOriginsArgs, 'input'>>;
|
||||
setupRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationsetupRemoteAccessArgs, 'input'>>;
|
||||
@@ -2379,7 +2474,6 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
|
||||
unarchiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveNotificationsArgs>>;
|
||||
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationunmountArrayDiskArgs, 'id'>>;
|
||||
unreadNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationunreadNotificationArgs, 'id'>>;
|
||||
updateApikey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationupdateApikeyArgs, 'name'>>;
|
||||
}>;
|
||||
|
||||
export type NetworkResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Network'] = ResolversParentTypes['Network']> = ResolversObject<{
|
||||
@@ -2559,7 +2653,8 @@ export type ProfileModelResolvers<ContextType = Context, ParentType extends Reso
|
||||
}>;
|
||||
|
||||
export type QueryResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = ResolversObject<{
|
||||
apiKeys?: Resolver<Maybe<Array<Maybe<ResolversTypes['ApiKey']>>>, ParentType, ContextType>;
|
||||
apiKey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<QueryapiKeyArgs, 'id'>>;
|
||||
apiKeys?: Resolver<Array<ResolversTypes['ApiKey']>, ParentType, ContextType>;
|
||||
array?: Resolver<ResolversTypes['Array'], ParentType, ContextType>;
|
||||
cloud?: Resolver<Maybe<ResolversTypes['Cloud']>, ParentType, ContextType>;
|
||||
config?: Resolver<ResolversTypes['Config'], ParentType, ContextType>;
|
||||
@@ -2659,7 +2754,6 @@ export type ShareResolvers<ContextType = Context, ParentType extends ResolversPa
|
||||
}>;
|
||||
|
||||
export type SubscriptionResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = ResolversObject<{
|
||||
apikeys?: SubscriptionResolver<Maybe<Array<Maybe<ResolversTypes['ApiKey']>>>, "apikeys", ParentType, ContextType>;
|
||||
array?: SubscriptionResolver<ResolversTypes['Array'], "array", ParentType, ContextType>;
|
||||
config?: SubscriptionResolver<ResolversTypes['Config'], "config", ParentType, ContextType>;
|
||||
display?: SubscriptionResolver<Maybe<ResolversTypes['Display']>, "display", ParentType, ContextType>;
|
||||
@@ -2778,7 +2872,7 @@ export type UserResolvers<ContextType = Context, ParentType extends ResolversPar
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
password?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
roles?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -2787,7 +2881,7 @@ export type UserAccountResolvers<ContextType = Context, ParentType extends Resol
|
||||
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
roles?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type VarsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Vars'] = ResolversParentTypes['Vars']> = ResolversObject<{
|
||||
@@ -2988,6 +3082,7 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
|
||||
AccessUrl?: AccessUrlResolvers<ContextType>;
|
||||
ApiKey?: ApiKeyResolvers<ContextType>;
|
||||
ApiKeyResponse?: ApiKeyResponseResolvers<ContextType>;
|
||||
ApiKeyWithSecret?: ApiKeyWithSecretResolvers<ContextType>;
|
||||
Array?: ArrayResolvers<ContextType>;
|
||||
ArrayCapacity?: ArrayCapacityResolvers<ContextType>;
|
||||
ArrayDisk?: ArrayDiskResolvers<ContextType>;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { decodeJwt } from 'jose';
|
||||
|
||||
import type { ConnectSignInInput } from '@app/graphql/generated/api/types';
|
||||
import { NODE_ENV } from '@app/environment';
|
||||
import {
|
||||
type ConnectSignInInput,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver';
|
||||
import { getters, store } from '@app/store/index';
|
||||
import { loginUser } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
|
||||
export const connectSignIn = async (
|
||||
input: ConnectSignInInput
|
||||
): Promise<boolean> => {
|
||||
export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean> => {
|
||||
if (getters.emhttp().status === FileLoadStatus.LOADED) {
|
||||
const result =
|
||||
NODE_ENV === 'development'
|
||||
@@ -22,14 +20,11 @@ export const connectSignIn = async (
|
||||
flashGuid: getters.emhttp().var.flashGuid,
|
||||
});
|
||||
if (result !== API_KEY_STATUS.API_KEY_VALID) {
|
||||
throw new GraphQLError(
|
||||
`Validating API Key Failed with Error: ${result}`
|
||||
);
|
||||
throw new Error(`Validating API Key Failed with Error: ${result}`);
|
||||
}
|
||||
|
||||
const userInfo = input.idToken
|
||||
? decodeJwt(input.idToken)
|
||||
: input.userInfo ?? null;
|
||||
const userInfo = input.idToken ? decodeJwt(input.idToken) : (input.userInfo ?? null);
|
||||
|
||||
if (
|
||||
!userInfo ||
|
||||
!userInfo.preferred_username ||
|
||||
@@ -37,20 +32,45 @@ export const connectSignIn = async (
|
||||
typeof userInfo.preferred_username !== 'string' ||
|
||||
typeof userInfo.email !== 'string'
|
||||
) {
|
||||
throw new GraphQLError('Missing User Attributes');
|
||||
throw new Error('Missing User Attributes');
|
||||
}
|
||||
|
||||
// @TODO once we deprecate old sign in method, switch this to do all validation requests
|
||||
await store.dispatch(
|
||||
loginUser({
|
||||
avatar:
|
||||
typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
|
||||
username: userInfo.preferred_username,
|
||||
email: userInfo.email,
|
||||
apikey: input.apiKey,
|
||||
})
|
||||
);
|
||||
return true;
|
||||
try {
|
||||
const { remote } = getters.config();
|
||||
const { localApiKey: localApiKeyFromConfig } = remote;
|
||||
|
||||
let localApiKeyToUse = localApiKeyFromConfig;
|
||||
|
||||
if (localApiKeyFromConfig == '') {
|
||||
const apiKeyService = new ApiKeyService();
|
||||
// Create local API key
|
||||
const localApiKey = await apiKeyService.create(
|
||||
`LOCAL_KEY_${userInfo.preferred_username.toUpperCase()}`,
|
||||
`Local API key for Connect user ${userInfo.email}`,
|
||||
[Role.ADMIN]
|
||||
);
|
||||
|
||||
if (!localApiKey?.key) {
|
||||
throw new Error('Failed to create local API key');
|
||||
}
|
||||
|
||||
localApiKeyToUse = localApiKey.key;
|
||||
}
|
||||
|
||||
await store.dispatch(
|
||||
loginUser({
|
||||
avatar: typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
|
||||
username: userInfo.preferred_username,
|
||||
email: userInfo.email,
|
||||
apikey: input.apiKey,
|
||||
localApiKey: localApiKeyToUse,
|
||||
})
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to login user: ${error}`);
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { RemoteGraphQLEventFragmentFragment } from '@app/graphql/generated/client/graphql';
|
||||
import { remoteQueryLogger } from '@app/core/log';
|
||||
import { getApiApolloClient } from '@app/graphql/client/api/get-api-client';
|
||||
import {
|
||||
RemoteGraphQLEventType,
|
||||
type RemoteGraphQLEventFragmentFragment,
|
||||
} from '@app/graphql/generated/client/graphql';
|
||||
import { RemoteGraphQLEventType } from '@app/graphql/generated/client/graphql';
|
||||
import { SEND_REMOTE_QUERY_RESPONSE } from '@app/graphql/mothership/mutations';
|
||||
import { parseGraphQLQuery } from '@app/graphql/resolvers/subscription/remote-graphql/remote-graphql-helpers';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client';
|
||||
@@ -14,12 +12,19 @@ export const executeRemoteGraphQLQuery = async (
|
||||
) => {
|
||||
remoteQueryLogger.debug({ query: data }, 'Executing remote query');
|
||||
const client = GraphQLClient.getInstance();
|
||||
const apiKey = getters.config().remote.apikey;
|
||||
const localApiKey = getters.config().remote.localApiKey;
|
||||
|
||||
if (!localApiKey) {
|
||||
throw new Error('Local API key is missing');
|
||||
}
|
||||
|
||||
const apiKey = localApiKey;
|
||||
const originalBody = data.body;
|
||||
|
||||
try {
|
||||
const parsedQuery = parseGraphQLQuery(originalBody);
|
||||
const localClient = getApiApolloClient({
|
||||
upcApiKey: apiKey,
|
||||
localApiKey: apiKey,
|
||||
});
|
||||
remoteQueryLogger.trace({ query: parsedQuery.query }, '[DEVONLY] Running query');
|
||||
const localResult = await localClient.query({
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
input authenticateInput {
|
||||
password: String!
|
||||
}
|
||||
|
||||
input addApiKeyInput {
|
||||
name: String
|
||||
key: String
|
||||
userId: String
|
||||
}
|
||||
|
||||
input updateApikeyInput {
|
||||
description: String
|
||||
expiresAt: Long!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""Get all API keys"""
|
||||
apiKeys: [ApiKey]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
"""Get an existing API key"""
|
||||
getApiKey(name: String!, input: authenticateInput): ApiKey
|
||||
|
||||
"""Create a new API key"""
|
||||
addApikey(name: String!, input: updateApikeyInput): ApiKey
|
||||
|
||||
"""Update an existing API key"""
|
||||
updateApikey(name: String!, input: updateApikeyInput): ApiKey
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
apikeys: [ApiKey]
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
name: String!
|
||||
key: String!
|
||||
description: String
|
||||
scopes: JSON!
|
||||
expiresAt: Long!
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Available resources for permissions
|
||||
"""
|
||||
enum Resource {
|
||||
api_key
|
||||
cloud
|
||||
config
|
||||
crash_reporting_enabled
|
||||
customizations
|
||||
disk
|
||||
display
|
||||
flash
|
||||
info
|
||||
logs
|
||||
online
|
||||
os
|
||||
owner
|
||||
permission
|
||||
registration
|
||||
servers
|
||||
share
|
||||
vars
|
||||
connect
|
||||
notifications
|
||||
array
|
||||
dashboard
|
||||
docker
|
||||
network
|
||||
services
|
||||
vms
|
||||
me
|
||||
welcome
|
||||
}
|
||||
|
||||
"""
|
||||
Available roles for API keys and users
|
||||
"""
|
||||
enum Role {
|
||||
admin
|
||||
upc
|
||||
my_servers
|
||||
notifier
|
||||
guest
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type ApiKeyWithSecret {
|
||||
id: ID!
|
||||
key: String!
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
input CreateApiKeyInput {
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
}
|
||||
|
||||
input AddPermissionInput {
|
||||
role: Role!
|
||||
resource: Resource!
|
||||
action: String!
|
||||
possession: String!
|
||||
}
|
||||
|
||||
input AddRoleForUserInput {
|
||||
userId: ID!
|
||||
role: Role!
|
||||
}
|
||||
|
||||
input AddRoleForApiKeyInput {
|
||||
apiKeyId: ID!
|
||||
role: Role!
|
||||
}
|
||||
|
||||
input RemoveRoleFromApiKeyInput {
|
||||
apiKeyId: ID!
|
||||
role: Role!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret!
|
||||
addPermission(input: AddPermissionInput!): Boolean!
|
||||
addRoleForUser(input: AddRoleForUserInput!): Boolean!
|
||||
addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean!
|
||||
removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean!
|
||||
}
|
||||
|
||||
type Query {
|
||||
apiKeys: [ApiKey!]!
|
||||
apiKey(id: ID!): ApiKey
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
type Query {
|
||||
"""Current user account"""
|
||||
"""
|
||||
Current user account
|
||||
"""
|
||||
me: Me
|
||||
}
|
||||
|
||||
"""The current user"""
|
||||
"""
|
||||
The current user
|
||||
"""
|
||||
type Me implements UserAccount {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String!
|
||||
roles: String!
|
||||
roles: [Role!]!
|
||||
permissions: JSON
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
me: Me
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ interface UserAccount {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String!
|
||||
roles: String!
|
||||
roles: [Role!]!
|
||||
}
|
||||
|
||||
input usersInput {
|
||||
@@ -10,9 +10,13 @@ input usersInput {
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""User account"""
|
||||
"""
|
||||
User account
|
||||
"""
|
||||
user(id: ID!): User
|
||||
"""User accounts"""
|
||||
"""
|
||||
User accounts
|
||||
"""
|
||||
users(input: usersInput): [User!]!
|
||||
}
|
||||
|
||||
@@ -27,9 +31,13 @@ input deleteUserInput {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
"""Add a new user"""
|
||||
"""
|
||||
Add a new user
|
||||
"""
|
||||
addUser(input: addUserInput!): User
|
||||
"""Delete a user"""
|
||||
"""
|
||||
Delete a user
|
||||
"""
|
||||
deleteUser(input: deleteUserInput!): User
|
||||
}
|
||||
|
||||
@@ -38,13 +46,19 @@ type Subscription {
|
||||
users: [User]!
|
||||
}
|
||||
|
||||
"""A local user account"""
|
||||
"""
|
||||
A local user account
|
||||
"""
|
||||
type User implements UserAccount {
|
||||
id: ID!
|
||||
"""A unique name for the user"""
|
||||
"""
|
||||
A unique name for the user
|
||||
"""
|
||||
name: String!
|
||||
description: String!
|
||||
roles: String!
|
||||
"""If the account has a password set"""
|
||||
roles: [Role!]!
|
||||
"""
|
||||
If the account has a password set
|
||||
"""
|
||||
password: Boolean
|
||||
}
|
||||
}
|
||||
|
||||
+26
-23
@@ -1,34 +1,37 @@
|
||||
import 'reflect-metadata';
|
||||
import 'global-agent/bootstrap.js';
|
||||
import '@app/dotenv';
|
||||
|
||||
import { type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { unlinkSync } from 'fs';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
import type { RawServerDefault } from 'fastify';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import { store } from '@app/store';
|
||||
import { loadConfigFile } from '@app/store/modules/config';
|
||||
import { logger } from '@app/core/log';
|
||||
import { startStoreSync } from '@app/store/store-sync';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp';
|
||||
import { StateManager } from '@app/store/watch/state-watch';
|
||||
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch';
|
||||
import { loadRegistrationKey } from '@app/store/modules/registration';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { fileExistsSync } from '@app/core/utils/files/file-exists';
|
||||
import { PORT, environment } from '@app/environment';
|
||||
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event';
|
||||
import { PingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs';
|
||||
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch';
|
||||
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
|
||||
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
|
||||
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware';
|
||||
import { validateApiKeyIfPresent } from '@app/store/listeners/api-key-listener';
|
||||
import { bootstrapNestServer } from '@app/unraid-api/main';
|
||||
import { type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { type RawServerDefault } from 'fastify';
|
||||
import { setupLogRotation } from '@app/core/logrotate/setup-logrotate';
|
||||
import { WebSocket } from 'ws';
|
||||
import exitHook from 'exit-hook';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { logger } from '@app/core/log';
|
||||
import { setupLogRotation } from '@app/core/logrotate/setup-logrotate';
|
||||
import { fileExistsSync } from '@app/core/utils/files/file-exists';
|
||||
import { environment, PORT } from '@app/environment';
|
||||
import * as envVars from '@app/environment';
|
||||
import { PingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs';
|
||||
import { store } from '@app/store';
|
||||
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
|
||||
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event';
|
||||
import { validateApiKeyIfPresent } from '@app/store/listeners/api-key-listener';
|
||||
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware';
|
||||
import { loadConfigFile } from '@app/store/modules/config';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp';
|
||||
import { loadRegistrationKey } from '@app/store/modules/registration';
|
||||
import { startStoreSync } from '@app/store/store-sync';
|
||||
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch';
|
||||
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch';
|
||||
import { StateManager } from '@app/store/watch/state-watch';
|
||||
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
|
||||
import { bootstrapNestServer } from '@app/unraid-api/main';
|
||||
|
||||
let server: NestFastifyApplication<RawServerDefault> | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RemoteGraphQLEventFragmentFragment } from '@app/graphql/generated/client/graphql';
|
||||
import { remoteQueryLogger } from '@app/core/log';
|
||||
import { getApiApolloClient } from '@app/graphql/client/api/get-api-client';
|
||||
import { RemoteGraphQLEventType } from '@app/graphql/generated/client/graphql';
|
||||
import { SEND_REMOTE_QUERY_RESPONSE } from '@app/graphql/mothership/mutations';
|
||||
import { parseGraphQLQuery } from '@app/graphql/resolvers/subscription/remote-graphql/remote-graphql-helpers';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client';
|
||||
import { hasRemoteSubscription } from '@app/store/getters/index';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index';
|
||||
import { type SubscriptionWithSha256 } from '@app/store/types';
|
||||
|
||||
export const addRemoteSubscription = createAsyncThunk<
|
||||
SubscriptionWithSha256,
|
||||
RemoteGraphQLEventFragmentFragment['remoteGraphQLEventData'],
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('remoteGraphQL/addRemoteSubscription', async (data, { getState }) => {
|
||||
if (hasRemoteSubscription(data.sha256, getState())) {
|
||||
throw new Error(`Subscription Already Exists for SHA256: ${data.sha256}`);
|
||||
}
|
||||
|
||||
const { config } = getState();
|
||||
|
||||
remoteQueryLogger.debug('Creating subscription for %o', data);
|
||||
const apiKey = config.remote.localApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('Local API key is missing');
|
||||
}
|
||||
|
||||
const body = parseGraphQLQuery(data.body);
|
||||
const client = getApiApolloClient({
|
||||
localApiKey: apiKey,
|
||||
});
|
||||
const mothershipClient = GraphQLClient.getInstance();
|
||||
const observable = client.subscribe({
|
||||
query: body.query,
|
||||
variables: body.variables,
|
||||
});
|
||||
const subscription = observable.subscribe({
|
||||
async next(val) {
|
||||
remoteQueryLogger.debug('Got value %o', val);
|
||||
if (val.data) {
|
||||
const result = await mothershipClient?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ data: val.data }),
|
||||
type: RemoteGraphQLEventType.REMOTE_SUBSCRIPTION_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
remoteQueryLogger.debug('Remote Query Publish Result %o', result);
|
||||
}
|
||||
},
|
||||
async error(errorValue) {
|
||||
try {
|
||||
await mothershipClient?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ errors: errorValue }),
|
||||
type: RemoteGraphQLEventType.REMOTE_SUBSCRIPTION_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
remoteQueryLogger.info('Failed to mutate error result to endpoint');
|
||||
}
|
||||
remoteQueryLogger.error('Error executing remote subscription: %o', errorValue);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sha256: data.sha256,
|
||||
subscription,
|
||||
};
|
||||
});
|
||||
@@ -1,38 +1,35 @@
|
||||
import {
|
||||
addListener,
|
||||
createListenerMiddleware,
|
||||
type TypedAddListener,
|
||||
type TypedStartListening,
|
||||
} from '@reduxjs/toolkit';
|
||||
import type { TypedAddListener, TypedStartListening } from '@reduxjs/toolkit';
|
||||
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
|
||||
import { type AppDispatch, type RootState } from '@app/store';
|
||||
import { enableUpnpListener } from '@app/store/listeners/upnp-listener';
|
||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener';
|
||||
import { enableVersionListener } from '@app/store/listeners/version-listener';
|
||||
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener';
|
||||
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener';
|
||||
import { enableArrayEventListener } from '@app/store/listeners/array-event-listener';
|
||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener';
|
||||
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener';
|
||||
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener';
|
||||
import { enableServerStateListener } from '@app/store/listeners/server-state-listener';
|
||||
import { enableUpnpListener } from '@app/store/listeners/upnp-listener';
|
||||
import { enableVersionListener } from '@app/store/listeners/version-listener';
|
||||
import { enableWanAccessChangeListener } from '@app/store/listeners/wan-access-change-listener';
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { enableNotificationPathListener } from '@app/store/listeners/notification-path-listener';
|
||||
|
||||
import { enableLocalApiKeyListener } from './local-api-key-listener';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
|
||||
|
||||
export const startAppListening =
|
||||
listenerMiddleware.startListening as AppStartListening;
|
||||
export const startAppListening = listenerMiddleware.startListening as AppStartListening;
|
||||
|
||||
export type AppStartListeningParams = Parameters<typeof startAppListening>[0];
|
||||
|
||||
export const addAppListener = addListener as TypedAddListener<
|
||||
RootState,
|
||||
AppDispatch
|
||||
>;
|
||||
export const addAppListener = addListener as TypedAddListener<RootState, AppDispatch>;
|
||||
|
||||
export const startMiddlewareListeners = () => {
|
||||
// Begin listening for events
|
||||
enableLocalApiKeyListener();
|
||||
enableConfigFileListener('flash')();
|
||||
enableConfigFileListener('memory')();
|
||||
enableUpnpListener();
|
||||
@@ -43,4 +40,4 @@ export const startMiddlewareListeners = () => {
|
||||
enableWanAccessChangeListener();
|
||||
enableServerStateListener();
|
||||
enableNotificationPathListener();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { logger } from '@app/core/log';
|
||||
import { NODE_ENV } from '@app/environment';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver';
|
||||
import { getters } from '@app/store/index';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware';
|
||||
import { updateUserConfig } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
|
||||
export const enableLocalApiKeyListener = () =>
|
||||
startAppListening({
|
||||
predicate(_, currentState) {
|
||||
return (
|
||||
currentState.config.status === FileLoadStatus.LOADED &&
|
||||
currentState.config.remote.apikey !== '' &&
|
||||
currentState.config.remote.localApiKey === ''
|
||||
);
|
||||
},
|
||||
async effect(_, { dispatch }) {
|
||||
try {
|
||||
const { remote } = getters.config();
|
||||
const { apikey, username } = remote;
|
||||
// Validate the API key with the key server
|
||||
const validationResult =
|
||||
NODE_ENV === 'development'
|
||||
? API_KEY_STATUS.API_KEY_VALID
|
||||
: await validateApiKeyWithKeyServer({
|
||||
apiKey: apikey as string,
|
||||
flashGuid: getters.emhttp().var.flashGuid,
|
||||
});
|
||||
|
||||
if (validationResult !== API_KEY_STATUS.API_KEY_VALID) {
|
||||
throw new Error('API key validation failed');
|
||||
}
|
||||
|
||||
const apiKeyService = new ApiKeyService();
|
||||
// Create local API key
|
||||
const localApiKey = await apiKeyService.create(
|
||||
`LOCAL_KEY_${(username as string).toUpperCase()}`,
|
||||
`Local API key for Connect user ${username}`,
|
||||
[Role.ADMIN]
|
||||
);
|
||||
|
||||
if (localApiKey?.key) {
|
||||
dispatch(
|
||||
updateUserConfig({
|
||||
remote: {
|
||||
localApiKey: localApiKey.key,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error('Failed to create local API key - no key returned');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create local API key', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export const initialState: SliceState = {
|
||||
wanport: '',
|
||||
upnpEnabled: '',
|
||||
apikey: '',
|
||||
localApiKey: '',
|
||||
email: '',
|
||||
username: '',
|
||||
avatar: '',
|
||||
@@ -71,8 +72,8 @@ export const initialState: SliceState = {
|
||||
} as const;
|
||||
|
||||
export const loginUser = createAsyncThunk<
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'| 'localApiKey'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'| 'localApiKey'>,
|
||||
{ state: RootState }
|
||||
>('config/login-user', async (userInfo) => {
|
||||
logger.info('Logging in user: %s', userInfo.username);
|
||||
@@ -301,6 +302,7 @@ export const config = createSlice({
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: action.payload.apikey,
|
||||
localApiKey: action.payload.localApiKey,
|
||||
email: action.payload.email,
|
||||
username: action.payload.username,
|
||||
avatar: action.payload.avatar,
|
||||
@@ -312,6 +314,7 @@ export const config = createSlice({
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: '',
|
||||
localApiKey: '',
|
||||
avatar: '',
|
||||
email: '',
|
||||
username: '',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { join, resolve as resolvePath } from 'path';
|
||||
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
core: import.meta.dirname,
|
||||
'unraid-api-base': '/usr/local/unraid-api/' as const,
|
||||
@@ -62,6 +63,9 @@ const initialState = {
|
||||
'var-run': '/var/run' as const,
|
||||
// contains sess_ files that correspond to authenticated user sessions
|
||||
'auth-sessions': '/var/lib/php' as const,
|
||||
'auth-keys': resolvePath(
|
||||
process.env.PATHS_AUTH_KEY ?? ('/boot/config/plugins/dynamix.my.servers/keys' as const)
|
||||
),
|
||||
};
|
||||
|
||||
export const paths = createSlice({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {
|
||||
FastifyInstance as BaseFastifyInstance,
|
||||
FastifyReply as BaseFastifyReply,
|
||||
FastifyRequest as BaseFastifyRequest,
|
||||
} from 'fastify';
|
||||
|
||||
export type FastifyInstance = BaseFastifyInstance;
|
||||
export type FastifyRequest = BaseFastifyRequest;
|
||||
export type FastifyReply = BaseFastifyReply;
|
||||
Vendored
+53
-54
@@ -1,68 +1,67 @@
|
||||
import { type MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { type DynamicRemoteAccessType } from '@app/graphql/generated/api/types';
|
||||
import { type DynamicRemoteAccessType, type MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
|
||||
interface MyServersConfig extends Record<string, unknown> {
|
||||
api: {
|
||||
version: string;
|
||||
extraOrigins: string;
|
||||
};
|
||||
local: {
|
||||
'2Fa'?: string;
|
||||
'showT2Fa'?: string;
|
||||
};
|
||||
notifier: {
|
||||
apikey: string;
|
||||
};
|
||||
remote: {
|
||||
'2Fa'?: string;
|
||||
wanaccess: string;
|
||||
wanport: string;
|
||||
upnpEnabled?: string;
|
||||
apikey: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
regWizTime: string;
|
||||
accesstoken: string;
|
||||
idtoken: string;
|
||||
refreshtoken: string;
|
||||
allowedOrigins?: string;
|
||||
dynamicRemoteAccessType?: DynamicRemoteAccessType;
|
||||
};
|
||||
upc: {
|
||||
apikey: string;
|
||||
};
|
||||
api: {
|
||||
version: string;
|
||||
extraOrigins: string;
|
||||
};
|
||||
local: {
|
||||
'2Fa'?: string;
|
||||
showT2Fa?: string;
|
||||
};
|
||||
notifier: {
|
||||
apikey: string;
|
||||
};
|
||||
remote: {
|
||||
'2Fa'?: string;
|
||||
wanaccess: string;
|
||||
wanport: string;
|
||||
upnpEnabled?: string;
|
||||
apikey: string;
|
||||
localApiKey?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
regWizTime: string;
|
||||
accesstoken: string;
|
||||
idtoken: string;
|
||||
refreshtoken: string;
|
||||
allowedOrigins?: string;
|
||||
dynamicRemoteAccessType?: DynamicRemoteAccessType;
|
||||
};
|
||||
upc: {
|
||||
apikey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig {
|
||||
api: {
|
||||
extraOrigins: string;
|
||||
};
|
||||
local: MyServersConfig['local'] & {
|
||||
'2Fa': string;
|
||||
'showT2Fa': string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
'2Fa': string;
|
||||
upnpEnabled: string;
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType;
|
||||
};
|
||||
api: {
|
||||
extraOrigins: string;
|
||||
};
|
||||
local: MyServersConfig['local'] & {
|
||||
'2Fa': string;
|
||||
showT2Fa: string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
'2Fa': string;
|
||||
upnpEnabled: string;
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigMemory extends MyServersConfig {
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus;
|
||||
upnpStatus?: null | string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
allowedOrigins: string;
|
||||
};
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus;
|
||||
upnpStatus?: null | string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
allowedOrigins: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigMemoryWithMandatoryHiddenFields
|
||||
extends MyServersConfigMemory {
|
||||
export interface MyServersConfigMemoryWithMandatoryHiddenFields extends MyServersConfigMemory {
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus;
|
||||
upnpStatus?: null | string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuthZGuard } from 'nest-authz';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module';
|
||||
import { CronModule } from '@app/unraid-api/cron/cron.module';
|
||||
import { GraphModule } from '@app/unraid-api/graph/graph.module';
|
||||
import { RestModule } from '@app/unraid-api/rest/rest.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { CronModule } from '@app/unraid-api/cron/cron.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -18,10 +24,23 @@ import { CronModule } from '@app/unraid-api/cron/cron.module';
|
||||
CronModule,
|
||||
GraphModule,
|
||||
RestModule,
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 10000, // 10 seconds
|
||||
limit: 100, // 100 requests per 10 seconds
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: GraphqlAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthZGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { type CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { BYPASS_CORS_CHECKS } from '@app/environment';
|
||||
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { BYPASS_CORS_CHECKS } from '@app/environment';
|
||||
import { FastifyRequest } from '@app/types/fastify';
|
||||
|
||||
import { type CookieService } from '../auth/cookie.service';
|
||||
|
||||
/**
|
||||
@@ -64,7 +68,7 @@ export const configureFastifyCors =
|
||||
* @param req the request object
|
||||
* @param callback the callback to call with the CORS options
|
||||
*/
|
||||
(req: any, callback: (error: Error | null, options: CorsOptions) => void) => {
|
||||
(req: FastifyRequest, callback: (error: Error | null, options: CorsOptions) => void) => {
|
||||
const { cookies } = req;
|
||||
if (typeof cookies === 'object') {
|
||||
service.hasValidAuthCookie(cookies).then((isValid) => {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { ensureDir } from 'fs-extra';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import type { ApiKey, ApiKeyWithSecret } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
vi.mock('fs/promises', async () => ({
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('@app/store');
|
||||
vi.mock('@app/graphql/generated/api/operations', () => ({
|
||||
ApiKeyWithSecretSchema: vi.fn(),
|
||||
ApiKeySchema: vi.fn(),
|
||||
}));
|
||||
vi.mock('fs-extra', () => ({
|
||||
ensureDir: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let apiKeyService: ApiKeyService;
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
verbose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const mockBasePath = '/mock/path/to/keys';
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
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],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Create mock logger methods
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock the Logger constructor
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug);
|
||||
vi.spyOn(Logger.prototype, 'verbose').mockImplementation(mockLogger.verbose);
|
||||
|
||||
// Mock the paths getter
|
||||
vi.mocked(getters.paths).mockReturnValue({
|
||||
'auth-keys': mockBasePath,
|
||||
} as any);
|
||||
|
||||
// Mock ensureDir
|
||||
vi.mocked(ensureDir).mockResolvedValue();
|
||||
|
||||
apiKeyService = new ApiKeyService();
|
||||
await apiKeyService.initialize();
|
||||
|
||||
vi.spyOn(apiKeyService as any, 'generateApiKey').mockReturnValue('test-api-key');
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'test-api-id',
|
||||
}));
|
||||
|
||||
// Add default schema mocks
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
} as any);
|
||||
vi.mocked(ApiKeySchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should ensure directory exists', async () => {
|
||||
vi.mocked(ensureDir).mockResolvedValue();
|
||||
const service = new ApiKeyService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(ensureDir).toHaveBeenCalledWith(mockBasePath);
|
||||
});
|
||||
|
||||
it('should return correct paths', async () => {
|
||||
vi.mocked(ensureDir).mockResolvedValue();
|
||||
const paths = apiKeyService.getPaths();
|
||||
const testId = 'test-id';
|
||||
|
||||
expect(paths.basePath).toBe(mockBasePath);
|
||||
expect(paths.keyFile(testId)).toBe(join(mockBasePath, `${testId}.json`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create ApiKeyWithSecret with generated key', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||
const inputName = 'Test API Key';
|
||||
const expectedName = 'TEST_API_KEY';
|
||||
|
||||
const result = await apiKeyService.create(inputName, description ?? '', roles);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id,
|
||||
key,
|
||||
name: expectedName,
|
||||
description,
|
||||
roles,
|
||||
createdAt: expect.any(String),
|
||||
});
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('should validate input parameters', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey');
|
||||
|
||||
await expect(apiKeyService.create('', 'desc', [Role.GUEST])).rejects.toThrow(
|
||||
'API key name is required'
|
||||
);
|
||||
|
||||
await expect(apiKeyService.create('name', 'desc', [])).rejects.toThrow(
|
||||
'At least one role must be specified'
|
||||
);
|
||||
|
||||
await expect(apiKeyService.create('name', 'desc', ['invalid_role' as Role])).rejects.toThrow(
|
||||
'Invalid role specified'
|
||||
);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all API keys', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json', 'key2.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
|
||||
const result = await apiKeyService.findAll();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(mockApiKey);
|
||||
expect(result[1]).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json', 'key2.json'] as any);
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('Read error'));
|
||||
|
||||
const result = await apiKeyService.findAll();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return API key by id', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
vi.mocked(ApiKeySchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKey),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if API key not found (ENOENT error)', async () => {
|
||||
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await apiKeyService.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw GraphQLError if JSON parsing fails', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await expect(apiKeyService.findById(mockApiKey.id)).rejects.toThrow(
|
||||
'Failed to read API key'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdWithSecret', () => {
|
||||
it('should return API key with secret when found', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKeyWithSecret),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(readFile).toHaveBeenCalledWith(
|
||||
join(mockBasePath, `${mockApiKeyWithSecret.id}.json`),
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue({ code: 'ENOENT' });
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on invalid data structure', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new ZodError([]);
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await expect(apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id)).rejects.toThrow(
|
||||
'Invalid API key data structure'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on file read error', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('Read failed'));
|
||||
|
||||
await expect(apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id)).rejects.toThrow(
|
||||
'Failed to read API key file'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByKey', () => {
|
||||
it('should return API key by key value when multiple keys exist', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json', 'key2.json'] as any);
|
||||
vi.mocked(readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...mockApiKeyWithSecret, key: 'different-key' }))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApiKeyWithSecret));
|
||||
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return null if key not found in any file', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json', 'key2.json'] as any);
|
||||
vi.mocked(readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ...mockApiKeyWithSecret, key: 'different-key-1' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ...mockApiKeyWithSecret, key: 'different-key-2' })
|
||||
);
|
||||
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findByKey('non-existent-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw authentication error when file read fails', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('Read error'));
|
||||
|
||||
await expect(apiKeyService.findByKey(mockApiKeyWithSecret.key)).rejects.toThrow(
|
||||
'Authentication system error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw specific error for corrupted JSON', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new SyntaxError('Invalid JSON');
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await expect(apiKeyService.findByKey(mockApiKeyWithSecret.key)).rejects.toThrow(
|
||||
'Authentication system error: Corrupted key file'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByKey', () => {
|
||||
it('should return UserAccount when API key exists', async () => {
|
||||
const findByKeySpy = vi
|
||||
.spyOn(apiKeyService, 'findByKey')
|
||||
.mockResolvedValue(mockApiKeyWithSecret);
|
||||
const result = await apiKeyService.findOneByKey('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockApiKeyWithSecret.id,
|
||||
name: mockApiKeyWithSecret.name,
|
||||
description: mockApiKeyWithSecret.description,
|
||||
roles: mockApiKeyWithSecret.roles,
|
||||
});
|
||||
expect(findByKeySpy).toHaveBeenCalledWith('test-api-key');
|
||||
});
|
||||
|
||||
it('should use default description when none provided', async () => {
|
||||
const keyWithoutDesc = { ...mockApiKeyWithSecret, description: null };
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(keyWithoutDesc);
|
||||
const result = await apiKeyService.findOneByKey('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: keyWithoutDesc.id,
|
||||
name: keyWithoutDesc.name,
|
||||
description: `API Key ${keyWithoutDesc.name}`,
|
||||
roles: keyWithoutDesc.roles,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(null);
|
||||
|
||||
await expect(apiKeyService.findOneByKey('non-existent-key')).rejects.toThrow(
|
||||
'API key not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(null);
|
||||
|
||||
await expect(apiKeyService.findOneByKey('non-existent-key')).rejects.toThrow(
|
||||
'API key not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when unexpected error occurs', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(apiKeyService.findOneByKey('test-api-key')).rejects.toThrow(
|
||||
'Failed to retrieve user account'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveApiKey', () => {
|
||||
it('should save API key to file', async () => {
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKeyWithSecret),
|
||||
} as any);
|
||||
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await apiKeyService.saveApiKey(mockApiKeyWithSecret);
|
||||
|
||||
const writeFileCalls = vi.mocked(writeFile).mock.calls;
|
||||
|
||||
expect(writeFileCalls.length).toBe(1);
|
||||
|
||||
const [filePath, fileContent] = writeFileCalls[0] ?? [];
|
||||
const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`);
|
||||
|
||||
expect(filePath).toBe(expectedPath);
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
const savedApiKey = JSON.parse(fileContent);
|
||||
|
||||
expect(savedApiKey).toEqual(mockApiKeyWithSecret);
|
||||
} else {
|
||||
throw new Error('File content should be a string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on write error', async () => {
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKeyWithSecret),
|
||||
} as any);
|
||||
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow(
|
||||
'Failed to save API key: Write failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on invalid API key structure', async () => {
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new ZodError([
|
||||
{
|
||||
code: 'custom',
|
||||
path: ['name'],
|
||||
message: 'Name cannot be empty',
|
||||
},
|
||||
]);
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
name: '', // Invalid: name cannot be empty
|
||||
} as ApiKeyWithSecret;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Invalid data structure'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError when roles array is empty', async () => {
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new ZodError([
|
||||
{
|
||||
code: 'custom',
|
||||
path: ['roles'],
|
||||
message: 'Roles array cannot be empty',
|
||||
},
|
||||
]);
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [], // Invalid: roles cannot be empty
|
||||
} as ApiKeyWithSecret;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Invalid data structure'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { ensureDir } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
||||
import { ApiKey, ApiKeyWithSecret, Role, UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
protected readonly basePath: string;
|
||||
protected readonly keyFile: (id: string) => string;
|
||||
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
constructor() {
|
||||
this.basePath = getters.paths()['auth-keys'];
|
||||
this.keyFile = (id: string) => join(this.basePath, `${id}.json`);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.logger.verbose(`Ensuring API key directory exists: ${this.basePath}`);
|
||||
|
||||
try {
|
||||
await ensureDir(this.basePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create API key directory: ${error}`);
|
||||
throw new GraphQLError('Failed to initialize API key storage');
|
||||
}
|
||||
this.logger.verbose(`Using API key base path: ${this.basePath}`);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private sanitizeName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9-_]/g, '_').toUpperCase();
|
||||
}
|
||||
|
||||
async create(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
roles: Role[]
|
||||
): Promise<ApiKeyWithSecret> {
|
||||
const trimmedName = name?.trim();
|
||||
const sanitizedName = this.sanitizeName(trimmedName);
|
||||
|
||||
if (!trimmedName) {
|
||||
throw new GraphQLError('API key name is required');
|
||||
}
|
||||
|
||||
if (!roles?.length) {
|
||||
throw new GraphQLError('At least one role must be specified');
|
||||
}
|
||||
|
||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||
throw new GraphQLError('Invalid role specified');
|
||||
}
|
||||
|
||||
const apiKey: ApiKeyWithSecret = {
|
||||
id: uuidv4(),
|
||||
key: this.generateApiKey(),
|
||||
name: sanitizedName,
|
||||
description,
|
||||
roles,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.saveApiKey(apiKey);
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async findAll(): Promise<ApiKey[]> {
|
||||
try {
|
||||
const files = await readdir(this.basePath);
|
||||
const apiKeys: ApiKey[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
const apiKey = ApiKeySchema().parse(JSON.parse(content));
|
||||
|
||||
apiKeys.push(apiKey);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error(`Invalid API key structure in file ${file}`, error.errors);
|
||||
|
||||
continue;
|
||||
}
|
||||
this.logger.warn(`Error reading API key file ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiKeys;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read API key directory: ${error}`);
|
||||
throw new GraphQLError('Failed to list API keys');
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const content = await readFile(this.keyFile(id), 'utf8');
|
||||
|
||||
try {
|
||||
const apiKey = ApiKeySchema().parse(JSON.parse(content));
|
||||
|
||||
return apiKey;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error(`Invalid API key structure for ID ${id}`, error.errors);
|
||||
throw new GraphQLError('Invalid API key data structure');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.logger.warn(`API key file not found for ID ${id}`);
|
||||
|
||||
return null;
|
||||
} else {
|
||||
this.logger.error(`Error reading API key file for ID ${id}: ${error}`);
|
||||
throw new GraphQLError(
|
||||
`Failed to read API key: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async findByIdWithSecret(id: string): Promise<ApiKeyWithSecret | null> {
|
||||
try {
|
||||
const content = await readFile(this.keyFile(id), 'utf8');
|
||||
const apiKey = JSON.parse(content);
|
||||
|
||||
return ApiKeyWithSecretSchema().parse(apiKey);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error('Invalid API key data structure', error);
|
||||
throw new GraphQLError('Invalid API key data structure');
|
||||
}
|
||||
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.error('Failed to read API key file', error);
|
||||
throw new GraphQLError('Failed to read API key file');
|
||||
}
|
||||
}
|
||||
|
||||
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
|
||||
if (!key) return null;
|
||||
|
||||
try {
|
||||
const files = await readdir(this.basePath);
|
||||
const keyBuffer1 = Buffer.from(key);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
let parsedContent;
|
||||
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new GraphQLError('Authentication system error: Corrupted key file');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const apiKey = ApiKeyWithSecretSchema().parse(parsedContent);
|
||||
const keyBuffer2 = Buffer.from(apiKey.key);
|
||||
|
||||
if (
|
||||
keyBuffer1.length === keyBuffer2.length &&
|
||||
crypto.timingSafeEqual(keyBuffer1, keyBuffer2)
|
||||
) {
|
||||
apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST);
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Error processing API key file ${file}: ${error}`);
|
||||
throw new GraphQLError('Authentication system error');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to read API key storage: ${error}`);
|
||||
throw new GraphQLError('Authentication system unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async findOneByKey(apiKey: string): Promise<UserAccount | null> {
|
||||
try {
|
||||
const key = await this.findByKey(apiKey);
|
||||
|
||||
if (!key) {
|
||||
throw new GraphQLError('API key not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
description: key.description ?? `API Key ${key.name}`,
|
||||
name: key.name,
|
||||
roles: key.roles,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user by key: ${error}`);
|
||||
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new GraphQLError('Failed to retrieve user account');
|
||||
}
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||
try {
|
||||
const validatedApiKey = ApiKeyWithSecretSchema().parse(apiKey);
|
||||
|
||||
await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(validatedApiKey, null, 2));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error('Invalid API key structure', error.errors);
|
||||
throw new GraphQLError('Failed to save API key: Invalid data structure');
|
||||
} else if (error instanceof Error) {
|
||||
throw new GraphQLError(`Failed to save API key: ${error.message}`);
|
||||
} else {
|
||||
throw new GraphQLError('Failed to save API key: Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getPaths() {
|
||||
return {
|
||||
basePath: this.basePath,
|
||||
keyFile: this.keyFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import type { GqlContextType } from '@nestjs/graphql';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { type Observable } from 'rxjs';
|
||||
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy';
|
||||
import { IS_PUBLIC_KEY } from '@app/unraid-api/auth/public.decorator';
|
||||
import {
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
type CanActivate,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlExecutionContext, type GqlContextType } from '@nestjs/graphql';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { type Observable } from 'rxjs';
|
||||
|
||||
import { UserCookieStrategy } from './cookie.strategy';
|
||||
|
||||
@Injectable()
|
||||
@@ -26,7 +23,7 @@ export class GraphqlAuthGuard
|
||||
|
||||
handleRequest<UserAccount>(err, user: UserAccount | null, info, context) {
|
||||
if (err) {
|
||||
console.log('Error in handleRequest', err);
|
||||
this.logger.error('Error in handleRequest', err);
|
||||
throw err;
|
||||
}
|
||||
if (!user) {
|
||||
|
||||
@@ -1,31 +1,72 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ExecutionContext, Logger, Module, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy';
|
||||
|
||||
import { AUTHZ_ENFORCER, AuthZModule } from 'nest-authz';
|
||||
|
||||
import { getRequest } from '@app/utils';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { BASE_POLICY, CASBIN_MODEL } from './casbin';
|
||||
import { CasbinModule } from './casbin/casbin.module';
|
||||
import { CasbinService } from './casbin/casbin.service';
|
||||
import { CookieService, SESSION_COOKIE_CONFIG } from './cookie.service';
|
||||
import { UserCookieStrategy } from './cookie.strategy';
|
||||
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard';
|
||||
import { UsersService } from '@app/unraid-api/auth/users.service';
|
||||
import { AccessControlModule, ACGuard } from 'nest-access-control';
|
||||
import { setupPermissions } from '@app/core/permissions';
|
||||
import { ServerHeaderStrategy } from './header.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule.register({}), AccessControlModule.forRoles(setupPermissions())],
|
||||
imports: [
|
||||
PassportModule.register({
|
||||
defaultStrategy: [ServerHeaderStrategy.key, UserCookieStrategy.key],
|
||||
}),
|
||||
CasbinModule,
|
||||
AuthZModule.register({
|
||||
imports: [CasbinModule],
|
||||
enforcerProvider: {
|
||||
provide: AUTHZ_ENFORCER,
|
||||
useFactory: async (casbinService: CasbinService) => {
|
||||
return casbinService.initializeEnforcer(CASBIN_MODEL, BASE_POLICY);
|
||||
},
|
||||
inject: [CasbinService],
|
||||
},
|
||||
userFromContext: (ctx: ExecutionContext) => {
|
||||
const logger = new Logger('AuthZModule');
|
||||
|
||||
try {
|
||||
const request = getRequest(ctx);
|
||||
const roles = request?.user?.roles || [];
|
||||
|
||||
if (!Array.isArray(roles)) {
|
||||
throw new UnauthorizedException('User roles must be an array');
|
||||
}
|
||||
|
||||
return roles.join(',');
|
||||
} catch (error) {
|
||||
logger.error('Failed to extract user context', error);
|
||||
throw new UnauthorizedException('Failed to authenticate user');
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
ApiKeyService,
|
||||
ServerHeaderStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
{ provide: SESSION_COOKIE_CONFIG, useValue: CookieService.defaultOpts() },
|
||||
{ provide: 'USERS_SERVICE', useClass: UsersService },
|
||||
{ provide: 'AUTH_SERVICE', useClass: AuthService },
|
||||
{ provide: 'COOKIE_SERVICE', useClass: CookieService },
|
||||
{ provide: 'APP_GUARD', useClass: GraphqlAuthGuard },
|
||||
{
|
||||
provide: 'APP_GUARD',
|
||||
useClass: ACGuard,
|
||||
provide: SESSION_COOKIE_CONFIG,
|
||||
useValue: CookieService.defaultOpts(),
|
||||
},
|
||||
],
|
||||
exports: [PassportModule],
|
||||
exports: [
|
||||
AuthService,
|
||||
ApiKeyService,
|
||||
PassportModule,
|
||||
ServerHeaderStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
AuthZModule,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,25 +1,202 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ApiKey, ApiKeyWithSecret, UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '@app/unraid-api/auth/users.service';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service';
|
||||
import { CookieService } from './cookie.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let authService: AuthService;
|
||||
let apiKeyService: ApiKeyService;
|
||||
let authzService: AuthZService;
|
||||
let cookieService: CookieService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: 'USERS_SERVICE', useClass: UsersService },
|
||||
{ provide: 'COOKIE_SERVICE', useClass: CookieService },
|
||||
{ provide: 'SESSION_COOKIE_CONFIG', useValue: { name: 'session' } },
|
||||
],
|
||||
}).compile();
|
||||
const mockApiKey: ApiKey = {
|
||||
__typename: 'ApiKey',
|
||||
id: '10f356da-1e9e-43b8-9028-a26a645539a6',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST, Role.UPC],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
const mockUser: UserAccount = {
|
||||
id: '-1',
|
||||
description: 'Test User',
|
||||
name: 'test_user',
|
||||
roles: [Role.GUEST, Role.UPC],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const enforcer = await newEnforcer();
|
||||
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validateCookiesCasbin', () => {
|
||||
it('should validate cookies and ensure user roles', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(mockUser);
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([Role.ADMIN]);
|
||||
|
||||
const result = await authService.validateCookiesCasbin({});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when auth cookie is invalid', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(false);
|
||||
|
||||
await expect(authService.validateCookiesCasbin({})).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when session user is missing', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null);
|
||||
|
||||
await expect(authService.validateCookiesCasbin({})).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should add guest role when user has no roles', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(mockUser);
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
|
||||
|
||||
const addRoleSpy = vi.spyOn(authzService, 'addRoleForUser');
|
||||
const result = await authService.validateCookiesCasbin({});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(addRoleSpy).toHaveBeenCalledWith(mockUser.id, 'guest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncApiKeyRoles', () => {
|
||||
it('should sync roles correctly', async () => {
|
||||
const deleteRoleSpy = vi.spyOn(authzService, 'deleteRoleForUser');
|
||||
const addRoleSpy = vi.spyOn(authzService, 'addRoleForUser');
|
||||
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue(['old-role']);
|
||||
|
||||
await authService.syncApiKeyRoles('test-id', ['new-role']);
|
||||
|
||||
expect(deleteRoleSpy).toHaveBeenCalledWith('test-id', 'old-role');
|
||||
expect(addRoleSpy).toHaveBeenCalledWith('test-id', 'new-role');
|
||||
});
|
||||
|
||||
it('should handle failed role deletion', async () => {
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue(['old-role']);
|
||||
vi.spyOn(authzService, 'deleteRoleForUser').mockRejectedValue(
|
||||
new Error('Failed to delete role')
|
||||
);
|
||||
|
||||
await expect(authService.syncApiKeyRoles('test-id', ['new-role'])).rejects.toThrow(
|
||||
'Failed to delete role'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle failed role addition', async () => {
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue(['old-role']);
|
||||
vi.spyOn(authzService, 'deleteRoleForUser').mockResolvedValue(true);
|
||||
vi.spyOn(authzService, 'addRoleForUser').mockRejectedValue(new Error('Failed to add role'));
|
||||
|
||||
await expect(authService.syncApiKeyRoles('test-id', ['new-role'])).rejects.toThrow(
|
||||
'Failed to add role'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRoleToApiKey', () => {
|
||||
it('should add role to API key', async () => {
|
||||
const apiKeyId = 'test-id';
|
||||
const role = Role.GUEST;
|
||||
|
||||
const mockApiKeyWithoutRole = {
|
||||
...mockApiKey,
|
||||
roles: [Role.UPC],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.UPC],
|
||||
});
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
vi.spyOn(authzService, 'addRoleForUser').mockResolvedValue(true);
|
||||
|
||||
const result = await authService.addRoleToApiKey(apiKeyId, role);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.UPC, role],
|
||||
});
|
||||
expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid API key', async () => {
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(null);
|
||||
|
||||
await expect(authService.addRoleToApiKey('invalid-id', Role.GUEST)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const result = await authService.removeRoleFromApiKey(apiKey.id, Role.ADMIN);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...apiKeyWithSecret,
|
||||
roles: [Role.GUEST],
|
||||
});
|
||||
expect(authzService.deleteRoleForUser).toHaveBeenCalledWith(apiKey.id, Role.ADMIN);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid API key', async () => {
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(null);
|
||||
|
||||
await expect(authService.removeRoleFromApiKey('invalid-id', Role.GUEST)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,223 @@
|
||||
import { type UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { UsersService } from '@app/unraid-api/auth/users.service';
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import type { UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { handleAuthError } from '@app/utils';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { CookieService } from './cookie.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('USERS_SERVICE') private usersService: UsersService,
|
||||
@Inject('COOKIE_SERVICE') private cookieService: CookieService
|
||||
private cookieService: CookieService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
private authzService: AuthZService
|
||||
) {}
|
||||
|
||||
async validateUser(apiKey: string): Promise<UserAccount> {
|
||||
const user = this.usersService.findOne(apiKey);
|
||||
if (user) {
|
||||
return user;
|
||||
async validateApiKeyCasbin(apiKey: string): Promise<UserAccount> {
|
||||
try {
|
||||
const apiKeyEntity = await this.apiKeyService.findByKey(apiKey);
|
||||
|
||||
if (!apiKeyEntity) {
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
apiKeyEntity.roles ??= [];
|
||||
|
||||
await this.syncApiKeyRoles(apiKeyEntity.id, apiKeyEntity.roles);
|
||||
this.logger.debug(
|
||||
`Validating API key with roles: ${JSON.stringify(
|
||||
await this.authzService.getRolesForUser(apiKeyEntity.id)
|
||||
)}`
|
||||
);
|
||||
|
||||
return {
|
||||
id: apiKeyEntity.id,
|
||||
name: apiKeyEntity.name,
|
||||
description: apiKeyEntity.description ?? `API Key ${apiKeyEntity.name}`,
|
||||
roles: apiKeyEntity.roles,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to validate API key', error);
|
||||
}
|
||||
console.log('Invalid User');
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
async validateCookies(cookies: object): Promise<UserAccount> {
|
||||
if (await this.cookieService.hasValidAuthCookie(cookies)) {
|
||||
return this.usersService.getSessionUser();
|
||||
async validateCookiesCasbin(cookies: object): Promise<UserAccount> {
|
||||
try {
|
||||
if (!(await this.cookieService.hasValidAuthCookie(cookies))) {
|
||||
throw new UnauthorizedException('No user session found');
|
||||
}
|
||||
|
||||
const user = await this.getSessionUser();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid user session');
|
||||
}
|
||||
|
||||
// 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(`User ${user.id} has roles: ${existingRoles}`);
|
||||
|
||||
return user;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to validate session', error);
|
||||
}
|
||||
console.log('No user session found');
|
||||
throw new UnauthorizedException('No user session found');
|
||||
}
|
||||
|
||||
public async syncApiKeyRoles(apiKeyId: string, roles: string[]): Promise<void> {
|
||||
try {
|
||||
// Get existing roles and convert to Set
|
||||
const existingRolesSet = new Set(await this.authzService.getRolesForUser(apiKeyId));
|
||||
const newRolesSet = new Set(roles);
|
||||
|
||||
// Calculate roles to add (in new roles but not in existing)
|
||||
const rolesToAdd = roles.filter((role) => !existingRolesSet.has(role));
|
||||
|
||||
// Calculate roles to remove (in existing but not in new)
|
||||
const rolesToRemove = Array.from(existingRolesSet).filter((role) => !newRolesSet.has(role));
|
||||
|
||||
// Perform role updates
|
||||
await Promise.all([
|
||||
...rolesToAdd.map((role) => this.authzService.addRoleForUser(apiKeyId, role)),
|
||||
...rolesToRemove.map((role) => this.authzService.deleteRoleForUser(apiKeyId, role)),
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to sync roles for API key', error, { apiKeyId });
|
||||
}
|
||||
}
|
||||
|
||||
public async addRoleToUser(userId: string, role: Role): Promise<boolean> {
|
||||
if (!userId || !role) {
|
||||
throw new UnauthorizedException('User ID and role are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const hasRole = await this.authzService.hasRoleForUser(userId, role);
|
||||
|
||||
if (hasRole) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.authzService.addRoleForUser(userId, role);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to add role to user', error, { userId, role });
|
||||
}
|
||||
}
|
||||
|
||||
public async addRoleToApiKey(apiKeyId: string, role: Role): Promise<boolean> {
|
||||
if (!apiKeyId || !role) {
|
||||
throw new UnauthorizedException('API key ID and role are required');
|
||||
}
|
||||
|
||||
const apiKey = await this.apiKeyService.findById(apiKeyId);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException('API key not found');
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
await this.authzService.addRoleForUser(apiKeyId, role);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to add role to API key', error, { apiKeyId, role });
|
||||
}
|
||||
}
|
||||
|
||||
public async removeRoleFromApiKey(apiKeyId: string, role: Role): Promise<boolean> {
|
||||
if (!apiKeyId || !role) {
|
||||
throw new UnauthorizedException('API key ID and role are required');
|
||||
}
|
||||
|
||||
const apiKey = await this.apiKeyService.findById(apiKeyId);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException('API key not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyWithSecret = await this.apiKeyService.findByIdWithSecret(apiKeyId);
|
||||
|
||||
if (!apiKeyWithSecret) {
|
||||
throw new UnauthorizedException('API key not found with secret');
|
||||
}
|
||||
|
||||
apiKeyWithSecret.roles = apiKeyWithSecret.roles.filter((r) => r !== role);
|
||||
await this.apiKeyService.saveApiKey(apiKeyWithSecret);
|
||||
await this.authzService.deleteRoleForUser(apiKeyId, role);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to remove role from API key', error, {
|
||||
apiKeyId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncUserRoles(userId: string, roles: Role[]): Promise<void> {
|
||||
try {
|
||||
// Get existing roles and convert to Set
|
||||
const existingRolesSet = new Set(
|
||||
(await this.authzService.getRolesForUser(userId)).map((role) => role as Role)
|
||||
);
|
||||
const newRolesSet = new Set(roles);
|
||||
|
||||
// Calculate roles to add (in new roles but not in existing)
|
||||
const rolesToAdd = roles.filter((role) => !existingRolesSet.has(role));
|
||||
|
||||
// Calculate roles to remove (in existing but not in new)
|
||||
const rolesToRemove = Array.from(existingRolesSet).filter((role) => !newRolesSet.has(role));
|
||||
|
||||
// Perform role updates
|
||||
await Promise.all([
|
||||
...rolesToAdd.map((role) => this.authzService.addRoleForUser(userId, role)),
|
||||
...rolesToRemove.map((role) => this.authzService.deleteRoleForUser(userId, role)),
|
||||
]);
|
||||
|
||||
this.logger.debug(
|
||||
`Synced roles for user ${userId}. Added: ${rolesToAdd.join(
|
||||
','
|
||||
)}, Removed: ${rolesToRemove.join(',')}`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to sync roles for user', error, { userId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user object representing a session.
|
||||
* Note: Does NOT perform validation.
|
||||
*
|
||||
* @returns a service account that represents the user session (i.e. a webgui user).
|
||||
*/
|
||||
async getSessionUser(): Promise<UserAccount> {
|
||||
this.logger.debug('getSessionUser called!');
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'UPC service account',
|
||||
name: 'upc',
|
||||
roles: [Role.UPC],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CasbinService } from './casbin.service';
|
||||
|
||||
@Module({
|
||||
providers: [CasbinService],
|
||||
exports: [CasbinService],
|
||||
})
|
||||
export class CasbinModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
|
||||
@Injectable()
|
||||
export class CasbinService {
|
||||
private readonly logger = new Logger(CasbinService.name);
|
||||
|
||||
/**
|
||||
* Initializes a Casbin enforcer with the given model and policies.
|
||||
*/
|
||||
async initializeEnforcer(model: string, policy: string) {
|
||||
this.logger.log('Initializing Casbin enforcer');
|
||||
|
||||
const casbinModel = new CasbinModel();
|
||||
casbinModel.loadModelFromText(model);
|
||||
const casbinPolicy = new StringAdapter(policy);
|
||||
|
||||
try {
|
||||
const enforcer = await newEnforcer(casbinModel, casbinPolicy);
|
||||
enforcer.enableLog(true);
|
||||
|
||||
return enforcer;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to create Casbin enforcer: ${errorMessage}`);
|
||||
|
||||
throw new InternalServerErrorException(`Failed to create Casbin enforcer: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './policy';
|
||||
@@ -0,0 +1,18 @@
|
||||
export const CASBIN_MODEL = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[role_definition]
|
||||
g = _, _
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
|
||||
keyMatch2(r.obj, p.obj) && \
|
||||
(r.act == p.act || p.act == '*')
|
||||
`;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AuthAction } from 'nest-authz';
|
||||
|
||||
import { Resource, Role } from '@app/graphql/generated/api/types';
|
||||
|
||||
export const BASE_POLICY = `
|
||||
# Admin permissions
|
||||
p, ${Role.ADMIN}, *, *, *
|
||||
|
||||
# UPC permissions for API keys
|
||||
p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.CREATE_ANY}
|
||||
p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.UPDATE_ANY}
|
||||
|
||||
# UPC permissions
|
||||
p, ${Role.UPC}, ${Resource.CLOUD}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, crash-reporting-enabled, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.DISK}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.FLASH}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.INFO}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.LOGS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.OS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.OWNER}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.REGISTRATION}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.SERVERS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.VARS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.UPDATE_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_ANY}
|
||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_OWN}
|
||||
p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.UPDATE_ANY}
|
||||
|
||||
# My Servers permissions
|
||||
p, ${Role.MY_SERVERS}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.CONFIG}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.CONNECT}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.UPDATE_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.DASHBOARD}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, docker/container, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.INFO}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.LOGS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.NETWORK}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.SERVICES}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.VARS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, ${Resource.VMS}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, vms/domain, ${AuthAction.READ_ANY}
|
||||
p, ${Role.MY_SERVERS}, unraid-version, ${AuthAction.READ_ANY}
|
||||
|
||||
# Notifier permissions
|
||||
p, ${Role.NOTIFIER}, ${Resource.NOTIFICATIONS}, ${AuthAction.CREATE_OWN}
|
||||
|
||||
# Guest permissions
|
||||
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
||||
p, ${Role.GUEST}, ${Resource.WELCOME}, ${AuthAction.READ_ANY}
|
||||
|
||||
# Role inheritance
|
||||
g, ${Role.ADMIN}, ${Role.GUEST}
|
||||
g, ${Role.UPC}, ${Role.GUEST}
|
||||
g, ${Role.MY_SERVERS}, ${Role.GUEST}
|
||||
g, ${Role.NOTIFIER}, ${Role.GUEST}
|
||||
`;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists';
|
||||
import { getters } from '@app/store';
|
||||
import { batchProcess } from '@app/utils';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
|
||||
/** token for dependency injection of a session cookie options object */
|
||||
export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';
|
||||
@@ -10,6 +11,9 @@ export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';
|
||||
type SessionCookieConfig = {
|
||||
namePrefix: string;
|
||||
sessionDir: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'lax' | 'strict' | 'none';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -22,7 +26,13 @@ export class CookieService {
|
||||
* @returns new SessionCookieOptions with `namePrefix: 'unraid_', sessionDir: '/var/lib/php'`
|
||||
*/
|
||||
static defaultOpts(): SessionCookieConfig {
|
||||
return { namePrefix: 'unraid_', sessionDir: getters.paths()['auth-sessions'] };
|
||||
return {
|
||||
namePrefix: 'unraid_',
|
||||
sessionDir: getters.paths()['auth-sessions'],
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
|
||||
import { Strategy } from 'passport-custom';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
import type { CustomRequest } from '../types/request';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const strategyName = 'user-cookie';
|
||||
|
||||
@@ -11,11 +13,11 @@ export class UserCookieStrategy extends PassportStrategy(Strategy, strategyName)
|
||||
static key = strategyName;
|
||||
private readonly logger = new Logger(UserCookieStrategy.name);
|
||||
|
||||
constructor(@Inject('AUTH_SERVICE') private authService: AuthService) {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
public validate = async (req: CustomRequest): Promise<any> => {
|
||||
return this.authService.validateCookies(req.cookies);
|
||||
return this.authService.validateCookiesCasbin(req.cookies);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
import { type FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class FastifyThrottlerGuard extends ThrottlerGuard {
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
const request = req as unknown as FastifyRequest;
|
||||
return request.ip ?? request.ips?.[0] ?? request.headers?.['x-forwarded-for'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
getRequestResponse(context: ExecutionContext) {
|
||||
const gqlContext = GqlExecutionContext.create(context);
|
||||
const ctx = gqlContext.getContext();
|
||||
|
||||
if (!ctx.res) {
|
||||
ctx.res = {
|
||||
headers: {},
|
||||
header: function (name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
} else if (!ctx.res.header && ctx.res.headers) {
|
||||
ctx.res.header = function (name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
req: ctx.req,
|
||||
res: ctx.res,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
|
||||
import { Strategy } from 'passport-http-header-strategy';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { User } from '@app/graphql/generated/api/types';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,14 +12,41 @@ export class ServerHeaderStrategy extends PassportStrategy(Strategy, 'server-htt
|
||||
static key = 'server-http-header';
|
||||
private readonly logger = new Logger(ServerHeaderStrategy.name);
|
||||
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {
|
||||
super({ header: 'x-api-key', passReqToCallback: false });
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super({
|
||||
header: 'x-api-key',
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
public validate = async (apiKey: string): Promise<any> => {
|
||||
this.logger.debug('Validating API key');
|
||||
const user = await this.authService.validateUser(apiKey);
|
||||
async validate(req: any): Promise<User | null> {
|
||||
const request = req.req || req;
|
||||
const key = request.headers?.['x-api-key'];
|
||||
|
||||
return user;
|
||||
};
|
||||
if (!key) {
|
||||
this.logger.debug('No API key provided');
|
||||
throw new UnauthorizedException('No API key provided');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(key)) {
|
||||
this.logger.warn('Invalid API key format');
|
||||
throw new UnauthorizedException('Invalid API key format');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.authService.validateApiKeyCasbin(key);
|
||||
this.logger.debug('API key validation successful', {
|
||||
userId: user?.id,
|
||||
roles: user?.roles,
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error('API key validation failed', {
|
||||
errorType: error instanceof Error ? error.constructor.name : 'Unknown',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new UnauthorizedException('API key validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { BYPASS_PERMISSION_CHECKS } from '@app/environment';
|
||||
import { type UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor() {}
|
||||
|
||||
private logger = new Logger(UsersService.name);
|
||||
apiKeyToUser(apiKey: string): UserAccount | null {
|
||||
const config = getters.config();
|
||||
if (BYPASS_PERMISSION_CHECKS === true) {
|
||||
this.logger.warn(`BYPASSING_PERMISSION_CHECK`);
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'BYPASS_PERMISSION_CHECK',
|
||||
name: 'BYPASS_PERMISSION_CHECK',
|
||||
roles: 'admin',
|
||||
};
|
||||
}
|
||||
if (apiKey === config.remote.apikey)
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'My servers service account',
|
||||
name: 'my_servers',
|
||||
roles: 'my_servers',
|
||||
};
|
||||
if (apiKey === config.upc.apikey)
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'UPC service account',
|
||||
name: 'upc',
|
||||
roles: 'upc',
|
||||
};
|
||||
if (apiKey === config.notifier.apikey)
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'Notifier service account',
|
||||
name: 'notifier',
|
||||
roles: 'notifier',
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
findOne(apiKey: string): UserAccount | null {
|
||||
return this.apiKeyToUser(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user object representing a session.
|
||||
* Note: Does NOT perform validation.
|
||||
*
|
||||
* @returns a service account that represents the user session (i.e. a webgui user).
|
||||
*/
|
||||
getSessionUser(): UserAccount {
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'UPC Cookie-Based Service Account',
|
||||
name: 'upc',
|
||||
roles: 'upc',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
import { store } from '@app/store/index';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller';
|
||||
import {
|
||||
ConnectResolvers,
|
||||
type DynamicRemoteAccessStatus,
|
||||
DynamicRemoteAccessType,
|
||||
type EnableDynamicRemoteAccessInput,
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type {
|
||||
DynamicRemoteAccessStatus,
|
||||
EnableDynamicRemoteAccessInput,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import {
|
||||
setAllowedRemoteAccessUrl,
|
||||
} from '@app/store/modules/dynamic-remote-access';
|
||||
import { ConnectResolvers, DynamicRemoteAccessType } from '@app/graphql/generated/api/types';
|
||||
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller';
|
||||
import { store } from '@app/store/index';
|
||||
import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access';
|
||||
|
||||
@Resolver('Connect')
|
||||
export class ConnectResolver implements ConnectResolvers {
|
||||
protected logger = new Logger(ConnectResolver.name);
|
||||
|
||||
@Query('connect')
|
||||
@UseRoles({
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: 'connect/dynamic-remote-access',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public connect() {
|
||||
return {};
|
||||
@@ -30,7 +29,7 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
|
||||
@ResolveField()
|
||||
public id() {
|
||||
return 'connect'
|
||||
return 'connect';
|
||||
}
|
||||
|
||||
@ResolveField()
|
||||
@@ -45,10 +44,10 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UseRoles({
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: 'connect/dynamic-remote-access',
|
||||
action: 'update',
|
||||
possession: 'own',
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async enableDynamicRemoteAccess(
|
||||
@Args('input') dynamicRemoteAccessInput: EnableDynamicRemoteAccessInput
|
||||
@@ -57,10 +56,7 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
const state = store.getState();
|
||||
|
||||
const { dynamicRemoteAccessType } = state.config.remote;
|
||||
if (
|
||||
!dynamicRemoteAccessType ||
|
||||
dynamicRemoteAccessType === DynamicRemoteAccessType.DISABLED
|
||||
) {
|
||||
if (!dynamicRemoteAccessType || dynamicRemoteAccessType === DynamicRemoteAccessType.DISABLED) {
|
||||
throw new GraphQLError('Dynamic Remote Access is not enabled.', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
});
|
||||
@@ -74,14 +70,9 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
return true;
|
||||
} else if (
|
||||
controller.getRunningRemoteAccessType() ===
|
||||
DynamicRemoteAccessType.DISABLED
|
||||
) {
|
||||
} else if (controller.getRunningRemoteAccessType() === DynamicRemoteAccessType.DISABLED) {
|
||||
if (dynamicRemoteAccessInput.url) {
|
||||
store.dispatch(
|
||||
setAllowedRemoteAccessUrl(dynamicRemoteAccessInput.url)
|
||||
);
|
||||
store.dispatch(setAllowedRemoteAccessUrl(dynamicRemoteAccessInput.url));
|
||||
}
|
||||
controller.beginRemoteAccess({
|
||||
getState: store.getState,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { AccessUrl, Network } from '@app/graphql/generated/api/types';
|
||||
import { getServerIps } from '@app/graphql/resolvers/subscription/network';
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { AccessUrl, Network, Resource } from '@app/graphql/generated/api/types';
|
||||
import { getServerIps } from '@app/graphql/resolvers/subscription/network';
|
||||
|
||||
@Resolver('Network')
|
||||
export class NetworkResolver {
|
||||
constructor() {}
|
||||
|
||||
@UseRoles({
|
||||
resource: 'network',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.NETWORK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query('network')
|
||||
public async network(): Promise<Network> {
|
||||
return {
|
||||
id: 'network'
|
||||
id: 'network',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { getArrayData } from '@app/core/modules/array/get-array-data';
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { store } from '@app/store/index';
|
||||
import { Resolver, Query, Subscription } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Resolver('Array')
|
||||
export class ArrayResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'array',
|
||||
action: 'read',
|
||||
possession: 'own'
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async array() {
|
||||
return getArrayData(store.getState);
|
||||
}
|
||||
|
||||
@Subscription('array')
|
||||
@UseRoles({
|
||||
resource: 'array',
|
||||
action: 'read',
|
||||
possession: 'own'
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async arraySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.ARRAY);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthActionVerb, AuthPossession, AuthZService } from 'nest-authz';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ApiKey } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyWithSecret, Resource, Role } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
describe('AuthResolver', () => {
|
||||
let resolver: AuthResolver;
|
||||
let authService: AuthService;
|
||||
let apiKeyService: ApiKeyService;
|
||||
let authzService: AuthZService;
|
||||
let cookieService: CookieService;
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
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],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
const enforcer = await newEnforcer();
|
||||
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
resolver = new AuthResolver(authService, apiKeyService);
|
||||
});
|
||||
|
||||
describe('apiKeys', () => {
|
||||
it('should return all API keys', async () => {
|
||||
const mockApiKeys = [mockApiKey];
|
||||
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(mockApiKeys);
|
||||
|
||||
const result = await resolver.apiKeys();
|
||||
|
||||
expect(result).toEqual(mockApiKeys);
|
||||
expect(apiKeyService.findAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKey', () => {
|
||||
it('should return API key by id', async () => {
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await resolver.apiKey(mockApiKey.id);
|
||||
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(mockApiKey.id);
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(null);
|
||||
|
||||
const result = await resolver.apiKey('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(apiKeyService.findById).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createApiKey', () => {
|
||||
it('should create new API key and sync roles', async () => {
|
||||
const input = {
|
||||
name: 'New API Key',
|
||||
description: 'New API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();
|
||||
|
||||
const result = await resolver.createApiKey(input);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith(
|
||||
input.name,
|
||||
input.description,
|
||||
input.roles
|
||||
);
|
||||
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(mockApiKey.id, mockApiKey.roles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRoleForUser', () => {
|
||||
it('should add role to user', async () => {
|
||||
const input = {
|
||||
userId: 'user-1',
|
||||
role: Role.ADMIN,
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'addRoleToUser').mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.addRoleForUser(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(authService.addRoleToUser).toHaveBeenCalledWith(input.userId, Role[input.role]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRoleForApiKey', () => {
|
||||
it('should add role to API key', async () => {
|
||||
const input = {
|
||||
apiKeyId: mockApiKey.id,
|
||||
role: Role.ADMIN,
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'addRoleToApiKey').mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.addRoleForApiKey(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(authService.addRoleToApiKey).toHaveBeenCalledWith(input.apiKeyId, Role[input.role]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRoleFromApiKey', () => {
|
||||
it('should remove role from API key', async () => {
|
||||
const input = {
|
||||
apiKeyId: mockApiKey.id,
|
||||
role: Role.ADMIN,
|
||||
};
|
||||
|
||||
vi.spyOn(authService, 'removeRoleFromApiKey').mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.removeRoleFromApiKey(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(authService.removeRoleFromApiKey).toHaveBeenCalledWith(
|
||||
input.apiKeyId,
|
||||
Role[input.role]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type {
|
||||
AddRoleForApiKeyInput,
|
||||
AddRoleForUserInput,
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
CreateApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { Resource, Role } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service';
|
||||
|
||||
@Resolver('Auth')
|
||||
@UseGuards(GraphqlAuthGuard)
|
||||
@Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private apiKeyService: ApiKeyService
|
||||
) {}
|
||||
|
||||
@Query()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeys(): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findAll();
|
||||
}
|
||||
|
||||
@Query()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKey(@Args('id') id: string): Promise<ApiKey | null> {
|
||||
return this.apiKeyService.findById(id);
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async createApiKey(
|
||||
@Args('input')
|
||||
input: CreateApiKeyInput
|
||||
): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = await this.apiKeyService.create(
|
||||
input.name,
|
||||
input.description ?? undefined,
|
||||
input.roles
|
||||
);
|
||||
|
||||
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async addRoleForUser(
|
||||
@Args('input')
|
||||
input: AddRoleForUserInput
|
||||
): Promise<boolean> {
|
||||
return this.authService.addRoleToUser(input.userId, Role[input.role]);
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async addRoleForApiKey(
|
||||
@Args('input')
|
||||
input: AddRoleForApiKeyInput
|
||||
): Promise<boolean> {
|
||||
return this.authService.addRoleToApiKey(input.apiKeyId, Role[input.role]);
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async removeRoleFromApiKey(
|
||||
@Args('input')
|
||||
input: RemoveRoleFromApiKeyInput
|
||||
): Promise<boolean> {
|
||||
return this.authService.removeRoleFromApiKey(input.apiKeyId, Role[input.role]);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
getAllowedOrigins,
|
||||
getExtraOrigins,
|
||||
} from '@app/common/allowed-origins';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type {
|
||||
Cloud,
|
||||
ConnectSignInInput,
|
||||
RemoteAccess,
|
||||
SetupRemoteAccessInput,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { getAllowedOrigins, getExtraOrigins } from '@app/common/allowed-origins';
|
||||
import {
|
||||
DynamicRemoteAccessType,
|
||||
Resource,
|
||||
WAN_ACCESS_TYPE,
|
||||
WAN_FORWARD_TYPE,
|
||||
type ConnectSignInInput,
|
||||
type SetupRemoteAccessInput,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import type { Cloud, RemoteAccess } from '@app/graphql/generated/api/types';
|
||||
|
||||
import { connectSignIn } from '@app/graphql/resolvers/mutation/connect/connect-sign-in';
|
||||
import { checkApi } from '@app/graphql/resolvers/query/cloud/check-api';
|
||||
import { checkCloud } from '@app/graphql/resolvers/query/cloud/check-cloud';
|
||||
@@ -18,16 +22,14 @@ import { checkMinigraphql } from '@app/graphql/resolvers/query/cloud/check-minig
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access';
|
||||
import { getters, store } from '@app/store/index';
|
||||
import { logoutUser } from '@app/store/modules/config';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Resolver('Cloud')
|
||||
export class CloudResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'cloud',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CLOUD,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async cloud(): Promise<Cloud> {
|
||||
const minigraphql = checkMinigraphql();
|
||||
@@ -47,42 +49,38 @@ export class CloudResolver {
|
||||
error:
|
||||
`${apiKey.error ? `API KEY: ${apiKey.error}` : ''}${
|
||||
cloud.error ? `NETWORK: ${cloud.error}` : ''
|
||||
}${minigraphql.error ? `CLOUD: ${minigraphql.error}` : ''}` ||
|
||||
null,
|
||||
}${minigraphql.error ? `CLOUD: ${minigraphql.error}` : ''}` || null,
|
||||
};
|
||||
}
|
||||
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'connect',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CONNECT,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async remoteAccess(): Promise<RemoteAccess> {
|
||||
const hasWanAccess = getters.config().remote.wanaccess === 'yes';
|
||||
const dynamicRemoteAccessSettings: RemoteAccess = {
|
||||
accessType: hasWanAccess
|
||||
? getters.config().remote.dynamicRemoteAccessType !==
|
||||
DynamicRemoteAccessType.DISABLED
|
||||
? getters.config().remote.dynamicRemoteAccessType !== DynamicRemoteAccessType.DISABLED
|
||||
? WAN_ACCESS_TYPE.DYNAMIC
|
||||
: WAN_ACCESS_TYPE.ALWAYS
|
||||
: WAN_ACCESS_TYPE.DISABLED,
|
||||
forwardType: getters.config().remote.upnpEnabled
|
||||
? WAN_FORWARD_TYPE.UPNP
|
||||
: WAN_FORWARD_TYPE.STATIC,
|
||||
port: getters.config().remote.wanport
|
||||
? Number(getters.config().remote.wanport)
|
||||
: null,
|
||||
port: getters.config().remote.wanport ? Number(getters.config().remote.wanport) : null,
|
||||
};
|
||||
|
||||
return dynamicRemoteAccessSettings;
|
||||
}
|
||||
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'connect',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CONNECT,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async extraAllowedOrigins(): Promise<Array<string>> {
|
||||
const extraOrigins = getExtraOrigins();
|
||||
@@ -91,44 +89,37 @@ export class CloudResolver {
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UseRoles({
|
||||
resource: 'connect',
|
||||
action: 'update',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.CONNECT,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async connectSignIn(
|
||||
@Args('input') input: ConnectSignInInput
|
||||
): Promise<boolean> {
|
||||
public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise<boolean> {
|
||||
/**
|
||||
* @todo Move to service
|
||||
*/
|
||||
return connectSignIn(input);
|
||||
return await connectSignIn(input);
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UseRoles({
|
||||
resource: 'connect',
|
||||
action: 'update',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.CONNECT,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async connectSignOut() {
|
||||
await store.dispatch(
|
||||
logoutUser({ reason: 'Manual Sign Out Using API' })
|
||||
);
|
||||
await store.dispatch(logoutUser({ reason: 'Manual Sign Out Using API' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UseRoles({
|
||||
resource: 'connect',
|
||||
action: 'update',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.CONNECT,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async setupRemoteAccess(
|
||||
@Args('input') input: SetupRemoteAccessInput
|
||||
): Promise<boolean> {
|
||||
public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise<boolean> {
|
||||
await store.dispatch(setupRemoteAccessThunk(input)).unwrap();
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { AllowedOriginInput } from '@app/graphql/generated/api/types';
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { type AllowedOriginInput, Config, ConfigErrorState } from '@app/graphql/generated/api/types';
|
||||
import { Config, ConfigErrorState, Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters, store } from '@app/store/index';
|
||||
import { updateAllowedOrigins } from '@app/store/modules/config';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Resolver('Config')
|
||||
export class ConfigResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'config',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async config(): Promise<Config> {
|
||||
const emhttp = getters.emhttp();
|
||||
@@ -20,16 +23,15 @@ export class ConfigResolver {
|
||||
valid: emhttp.var.configValid,
|
||||
error: emhttp.var.configValid
|
||||
? null
|
||||
: ConfigErrorState[emhttp.var.configState] ??
|
||||
ConfigErrorState.UNKNOWN_ERROR,
|
||||
: (ConfigErrorState[emhttp.var.configState] ?? ConfigErrorState.UNKNOWN_ERROR),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation('setAdditionalAllowedOrigins')
|
||||
@UseRoles({
|
||||
resource: 'config',
|
||||
action: 'update',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async setAdditionalAllowedOrigins(@Args('input') input: AllowedOriginInput) {
|
||||
await store.dispatch(updateAllowedOrigins(input.origins));
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { getDisks } from '@app/core/modules/get-disks';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { getDisks } from '@app/core/modules/get-disks';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
|
||||
@Resolver('Disks')
|
||||
export class DisksResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'disks',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disks() {
|
||||
const disks = await getDisks({
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { type Display } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { Display } from '@app/graphql/generated/api/types';
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
|
||||
const states = {
|
||||
// Success
|
||||
custom: {
|
||||
@@ -58,10 +61,10 @@ const states = {
|
||||
@Resolver()
|
||||
export class DisplayResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'display',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async display(): Promise<Display> {
|
||||
/**
|
||||
@@ -70,8 +73,8 @@ export class DisplayResolver {
|
||||
const dynamixBasePath = getters.paths()['dynamix-base'];
|
||||
const configFilePath = join(dynamixBasePath, 'case-model.cfg');
|
||||
const result = {
|
||||
id: 'display'
|
||||
}
|
||||
id: 'display',
|
||||
};
|
||||
|
||||
// If the config file doesn't exist then it's a new OS install
|
||||
// Default to "default"
|
||||
@@ -93,7 +96,7 @@ export class DisplayResolver {
|
||||
if (serverCase.trim().length === 0) {
|
||||
return {
|
||||
case: states.default,
|
||||
...result
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,15 +106,15 @@ export class DisplayResolver {
|
||||
...states.default,
|
||||
icon: serverCase,
|
||||
},
|
||||
...result
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
@Subscription('display')
|
||||
@UseRoles({
|
||||
resource: 'display',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async displaySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.DISPLAY);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { getDockerContainers } from '@app/core/modules/index';
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { getDockerContainers } from '@app/core/modules/index';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
|
||||
@Resolver('Docker')
|
||||
export class DockerResolver {
|
||||
@UseRoles({
|
||||
resource: 'docker',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query()
|
||||
public docker() {
|
||||
@@ -16,10 +19,10 @@ export class DockerResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@UseRoles({
|
||||
resource: 'docker/container',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField()
|
||||
public async containers() {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
|
||||
@Resolver()
|
||||
export class FlashResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'flash',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async flash() {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
import { baseboard, system } from 'systeminformation';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { getMachineId } from '@app/core/utils/misc/get-machine-id';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import {
|
||||
generateApps,
|
||||
generateCpu,
|
||||
@@ -9,21 +16,18 @@ import {
|
||||
generateOs,
|
||||
generateVersions,
|
||||
} from '@app/graphql/resolvers/query/info';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { baseboard, system } from 'systeminformation';
|
||||
|
||||
@Resolver('Info')
|
||||
export class InfoResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'info',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async info() {
|
||||
return {
|
||||
id: 'info'
|
||||
id: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,10 +86,10 @@ export class InfoResolver {
|
||||
}
|
||||
|
||||
@Subscription('info')
|
||||
@UseRoles({
|
||||
resource: 'info',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async infoSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.INFO);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
NotificationFilter,
|
||||
NotificationOverview,
|
||||
NotificationType,
|
||||
Resource,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { Importance } from '@app/graphql/generated/client/graphql';
|
||||
|
||||
@@ -17,17 +17,17 @@ import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Resolver('Notifications')
|
||||
export class NotificationsResolver {
|
||||
constructor(@Inject('NOTIFICATIONS_SERVICE') readonly notificationsService: NotificationsService) {}
|
||||
constructor(readonly notificationsService: NotificationsService) {}
|
||||
|
||||
/**============================================
|
||||
* Queries
|
||||
*=============================================**/
|
||||
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'notifications',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async notifications() {
|
||||
return {
|
||||
@@ -127,20 +127,20 @@ export class NotificationsResolver {
|
||||
*=============================================**/
|
||||
|
||||
@Subscription()
|
||||
@UseRoles({
|
||||
resource: 'notifications',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async notificationAdded() {
|
||||
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED);
|
||||
}
|
||||
|
||||
@Subscription()
|
||||
@UseRoles({
|
||||
resource: 'notifications',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async notificationsOverview() {
|
||||
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
|
||||
@Resolver()
|
||||
export class OnlineResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'online',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.ONLINE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async online() {
|
||||
return true;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
|
||||
@Resolver()
|
||||
export class OwnerResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'owner',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.OWNER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async owner() {
|
||||
const { remote } = getters.config();
|
||||
@@ -29,10 +32,10 @@ export class OwnerResolver {
|
||||
}
|
||||
|
||||
@Subscription('owner')
|
||||
@UseRoles({
|
||||
resource: 'owner',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.OWNER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public ownerSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.OWNER);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { Registration } from '@app/graphql/generated/api/types';
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { getKeyFile } from '@app/core/utils/misc/get-key-file';
|
||||
import {
|
||||
registrationType,
|
||||
type Registration,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { registrationType, Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Resolver()
|
||||
export class RegistrationResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'registration',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.REGISTRATION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async registration() {
|
||||
const emhttp = getters.emhttp();
|
||||
@@ -31,12 +31,8 @@ export class RegistrationResolver {
|
||||
type: emhttp.var.regTy,
|
||||
state: emhttp.var.regState,
|
||||
// Based on https://github.com/unraid/dynamix.unraid.net/blob/c565217fa8b2acf23943dc5c22a12d526cdf70a1/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php#L64
|
||||
expiration: (
|
||||
1_000 * (isTrial || isExpired ? Number(emhttp.var.regTm2) : 0)
|
||||
).toString(),
|
||||
updateExpiration: emhttp.var.regExp
|
||||
? (Number(emhttp.var.regExp) * 1_000).toString()
|
||||
: null,
|
||||
expiration: (1_000 * (isTrial || isExpired ? Number(emhttp.var.regTm2) : 0)).toString(),
|
||||
updateExpiration: emhttp.var.regExp ? (Number(emhttp.var.regExp) * 1_000).toString() : null,
|
||||
keyFile: {
|
||||
location: emhttp.var.regFile,
|
||||
contents: await getKeyFile(),
|
||||
@@ -46,10 +42,10 @@ export class RegistrationResolver {
|
||||
}
|
||||
|
||||
@Subscription('registration')
|
||||
@UseRoles({
|
||||
resource: 'registration',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.REGISTRATION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public registrationSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.REGISTRATION);
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module';
|
||||
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver';
|
||||
|
||||
import { AuthResolver } from './auth/auth.resolver';
|
||||
import { CloudResolver } from './cloud/cloud.resolver';
|
||||
import { ConfigResolver } from './config/config.resolver';
|
||||
import { DisksResolver } from './disks/disks.resolver';
|
||||
import { DisplayResolver } from './display/display.resolver';
|
||||
import { NotificationsResolver } from './notifications/notifications.resolver';
|
||||
import { OnlineResolver } from './online/online.resolver';
|
||||
import { InfoResolver } from './info/info.resolver';
|
||||
import { VmsResolver } from './vms/vms.resolver';
|
||||
import { FlashResolver } from './flash/flash.resolver';
|
||||
import { InfoResolver } from './info/info.resolver';
|
||||
import { NotificationsResolver } from './notifications/notifications.resolver';
|
||||
import { NotificationsService } from './notifications/notifications.service';
|
||||
import { OnlineResolver } from './online/online.resolver';
|
||||
import { OwnerResolver } from './owner/owner.resolver';
|
||||
import { RegistrationResolver } from './registration/registration.resolver';
|
||||
import { ServerResolver } from './servers/server.resolver';
|
||||
import { VarsResolver } from './vars/vars.resolver';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver';
|
||||
import { NotificationsService } from './notifications/notifications.service';
|
||||
import { VmsResolver } from './vms/vms.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [
|
||||
ArrayResolver,
|
||||
AuthResolver,
|
||||
CloudResolver,
|
||||
ConfigResolver,
|
||||
DisksResolver,
|
||||
@@ -33,7 +39,8 @@ import { NotificationsService } from './notifications/notifications.service';
|
||||
ServerResolver,
|
||||
VarsResolver,
|
||||
VmsResolver,
|
||||
{ provide: 'NOTIFICATIONS_SERVICE', useClass: NotificationsService },
|
||||
NotificationsService,
|
||||
],
|
||||
exports: [AuthModule, AuthResolver],
|
||||
})
|
||||
export class ResolversModule {}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { getLocalServer } from '@app/graphql/schema/utils';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { type Server } from '@app/graphql/generated/client/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { getLocalServer } from '@app/graphql/schema/utils';
|
||||
|
||||
@Resolver()
|
||||
export class ServerResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'server',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async server(): Promise<Server | null> {
|
||||
return getLocalServer()[0];
|
||||
@@ -18,20 +21,20 @@ export class ServerResolver {
|
||||
|
||||
@Resolver('servers')
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'server',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async servers(): Promise<Server[]> {
|
||||
return getLocalServer();
|
||||
}
|
||||
|
||||
@Subscription('server')
|
||||
@UseRoles({
|
||||
resource: 'server',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async serversSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.SERVERS);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
|
||||
@Resolver()
|
||||
export class VarsResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'vars',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.VARS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async vars() {
|
||||
return {
|
||||
id: 'vars',
|
||||
...getters.emhttp().var ?? {},
|
||||
}
|
||||
...(getters.emhttp().var ?? {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { getDomains } from '@app/core/modules/vms/get-domains';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { getDomains } from '@app/core/modules/vms/get-domains';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
|
||||
@Resolver()
|
||||
export class VmsResolver {
|
||||
@Query()
|
||||
@UseRoles({
|
||||
resource: 'vms',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async vms() {
|
||||
return {};
|
||||
@@ -16,10 +19,10 @@ export class VmsResolver {
|
||||
|
||||
@Resolver('domain')
|
||||
@Query()
|
||||
@UseRoles({
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: 'vms/domain',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async domain() {
|
||||
return getDomains();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import { DynamicRemoteAccessType, Service } from '@app/graphql/generated/api/types';
|
||||
import { DynamicRemoteAccessType, Resource, Service } from '@app/graphql/generated/api/types';
|
||||
import { store } from '@app/store/index';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Resolver('Services')
|
||||
export class ServicesResolver {
|
||||
@@ -34,19 +36,16 @@ export class ServicesResolver {
|
||||
},
|
||||
version: API_VERSION,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@Query('services')
|
||||
@UseRoles({
|
||||
resource: 'services',
|
||||
action: 'read',
|
||||
possession: 'own',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.SERVICES,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public services(): Service[] {
|
||||
const dynamicRemoteAccess = this.getDynamicRemoteAccessService();
|
||||
return [
|
||||
this.getApiService(),
|
||||
...(dynamicRemoteAccess ? [dynamicRemoteAccess] : []),
|
||||
];
|
||||
return [this.getApiService(), ...(dynamicRemoteAccess ? [dynamicRemoteAccess] : [])];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { getShares } from '@app/core/utils/index';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { getShares } from '@app/core/utils/index';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
|
||||
@Resolver('Shares')
|
||||
export class SharesResolver {
|
||||
constructor() {}
|
||||
|
||||
@UseRoles({
|
||||
resource: 'shares',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
})
|
||||
@Query('shares')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.SHARE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async shares() {
|
||||
const userShares = getShares('users');
|
||||
const diskShares = getShares('disks');
|
||||
|
||||
+14
-12
@@ -1,23 +1,25 @@
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { LoggerErrorInterceptor, Logger as PinoLogger } from 'nestjs-pino';
|
||||
import { AppModule } from './app/app.module';
|
||||
import Fastify from 'fastify';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { FastifyAdapter } from '@nestjs/platform-fastify';
|
||||
|
||||
import { HttpExceptionFilter } from '@app/unraid-api/exceptions/http-exceptions.filter';
|
||||
import { GraphQLExceptionsFilter } from '@app/unraid-api/exceptions/graphql-exceptions.filter';
|
||||
import { PORT } from '@app/environment';
|
||||
import { type FastifyInstance } from 'fastify';
|
||||
import { type Server, type IncomingMessage, type ServerResponse } from 'http';
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify from 'fastify';
|
||||
import { LoggerErrorInterceptor, Logger as PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import type { FastifyInstance } from '@app/types/fastify';
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { PORT } from '@app/environment';
|
||||
import { GraphQLExceptionsFilter } from '@app/unraid-api/exceptions/graphql-exceptions.filter';
|
||||
import { HttpExceptionFilter } from '@app/unraid-api/exceptions/http-exceptions.filter';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { configureFastifyCors } from './app/cors';
|
||||
import { CookieService } from './auth/cookie.service';
|
||||
|
||||
export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
|
||||
const server: FastifyInstance<Server, IncomingMessage, ServerResponse> = Fastify({
|
||||
const server = Fastify({
|
||||
logger: false,
|
||||
});
|
||||
}) as FastifyInstance;
|
||||
|
||||
apiLogger.debug('Creating Nest Server');
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { FastifyReply } from '@app/types/fastify';
|
||||
import { Resource } from '@app/graphql/generated/api/types';
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service';
|
||||
import { Controller, Get, Res, Logger, Param } from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@Controller()
|
||||
export class RestController {
|
||||
@@ -16,10 +19,10 @@ export class RestController {
|
||||
}
|
||||
|
||||
@Get('/graphql/api/logs')
|
||||
@UseRoles({
|
||||
resource: 'logs',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async getLogs(@Res() res: FastifyReply) {
|
||||
try {
|
||||
@@ -32,15 +35,12 @@ export class RestController {
|
||||
}
|
||||
|
||||
@Get('/graphql/api/customizations/:type')
|
||||
@UseRoles({
|
||||
resource: 'customizations',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async getCustomizations(
|
||||
@Param('type') type: string,
|
||||
@Res() res: FastifyReply
|
||||
) {
|
||||
async getCustomizations(@Param('type') type: string, @Res() res: FastifyReply) {
|
||||
if (type !== 'banner' && type !== 'case') {
|
||||
throw new Error('Invalid Customization Type');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { FastifyRequest } from '@app/types/fastify';
|
||||
|
||||
export interface CustomRequest extends FastifyRequest {}
|
||||
export interface CustomRequest extends FastifyRequest {}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import strftime from 'strftime';
|
||||
|
||||
import { UserAccount } from './graphql/generated/api/types';
|
||||
import { FastifyRequest } from './types/fastify';
|
||||
|
||||
export function notNull<T>(value: T): value is NonNullable<T> {
|
||||
return value !== null;
|
||||
}
|
||||
@@ -168,3 +174,72 @@ export function formatDatetime(
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the request object from the execution context.
|
||||
*
|
||||
* @param ctx - Execution context
|
||||
* @returns Request object
|
||||
*/
|
||||
export function getRequest(ctx: ExecutionContext) {
|
||||
const contextType = ctx.getType<'http' | 'graphql'>();
|
||||
let request: (FastifyRequest & { user?: UserAccount }) | null = null;
|
||||
|
||||
if (contextType === 'http') {
|
||||
request = ctx.switchToHttp().getRequest();
|
||||
} else if (contextType === 'graphql') {
|
||||
request = GqlExecutionContext.create(ctx).getContext().req;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported execution context type: ${contextType}. Only HTTP and GraphQL contexts are supported.`
|
||||
);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error handler for auth operations that converts any error
|
||||
* into an UnauthorizedException with proper logging and redacts API keys.
|
||||
*
|
||||
* @param logger - Logger instance to use for error logging
|
||||
* @param operation - Description of the operation that failed
|
||||
* @param error - The caught error
|
||||
* @param context - Additional context information (e.g., user ID, API key)
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
export function handleAuthError(
|
||||
logger: Logger,
|
||||
operation: string,
|
||||
error: unknown,
|
||||
context?: Record<string, string>
|
||||
): never {
|
||||
// Sanitize context by creating a deep clone
|
||||
const sanitizedContext = context ? structuredClone(context) : {};
|
||||
|
||||
if (sanitizedContext) {
|
||||
updateObject(sanitizedContext, (obj) => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string' && key.toLowerCase().includes('key')) {
|
||||
(obj as any)[key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const contextStr = Object.keys(sanitizedContext || {}).length
|
||||
? ` ${JSON.stringify(sanitizedContext)}`
|
||||
: '';
|
||||
|
||||
logger.error(`${operation} ${contextStr}`, error);
|
||||
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
// Use generic message for unknown errors to prevent information leakage
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
||||
|
||||
throw new UnauthorizedException(`${operation}: ${errorMessage}`);
|
||||
}
|
||||
|
||||
+26
-10
@@ -1,28 +1,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import nodeExternals from 'rollup-plugin-node-externals';
|
||||
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import nodeExternals from 'rollup-plugin-node-externals';
|
||||
import { VitePluginNode } from 'vite-plugin-node';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
nodeExternals(),
|
||||
nodeResolve(),
|
||||
viteCommonjs(),
|
||||
nodeResolve({
|
||||
preferBuiltins: true,
|
||||
exportConditions: ['node'],
|
||||
}),
|
||||
viteCommonjs({
|
||||
include: ['@fastify/type-provider-typebox', 'node_modules/**'],
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [{ src: 'src/graphql/schema/types', dest: '' }],
|
||||
}),
|
||||
...(mode === 'development'
|
||||
? VitePluginNode({
|
||||
adapter: ({ app, req, res }) => {
|
||||
// Example adapter code to run src/index.ts with VitePluginNode
|
||||
app(req, res);
|
||||
},
|
||||
adapter: 'nest',
|
||||
appPath: 'src/index.ts',
|
||||
tsCompiler: 'swc',
|
||||
initAppOnBoot: true,
|
||||
})
|
||||
: []),
|
||||
@@ -40,6 +43,7 @@ export default defineConfig(({ mode }) => {
|
||||
'class-transformer/storage',
|
||||
'unicorn-magic',
|
||||
],
|
||||
include: ['@nestjs/common', '@nestjs/core', 'reflect-metadata', 'fastify'],
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
@@ -53,10 +57,22 @@ export default defineConfig(({ mode }) => {
|
||||
entryFileNames: '[name].js',
|
||||
format: 'es', // Change the format to 'es' to support top-level await
|
||||
},
|
||||
preserveEntrySignatures: 'strict',
|
||||
},
|
||||
modulePreload: false,
|
||||
minify: false,
|
||||
target: 'node20',
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
include: [/node_modules/, /fastify/],
|
||||
exclude: ['cpu-features'],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
hmr: true,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user