diff --git a/.gitignore b/.gitignore index af416ada4..ba8769a53 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ typings/ # Visual Studio Code workspace .vscode/sftp.json +.history/ # OSX .DS_Store diff --git a/api/.env.development b/api/.env.development index fe91c351d..30db6cf00 100644 --- a/api/.env.development +++ b/api/.env.development @@ -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 diff --git a/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json b/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json new file mode 100644 index 000000000..8eb8ab7d9 --- /dev/null +++ b/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json @@ -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" +} diff --git a/api/package-lock.json b/api/package-lock.json index 56e9bbea9..6bcedc941 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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" }, diff --git a/api/package.json b/api/package.json index 73d26b65f..6ece8a34e 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index 9c9a3c68c..cf3796a1b 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -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", diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index 025d7b33e..1243c8e0a 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -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', diff --git a/api/src/__test__/store/modules/paths.test.ts b/api/src/__test__/store/modules/paths.test.ts index 4bb3a1212..09d529d17 100644 --- a/api/src/__test__/store/modules/paths.test.ts +++ b/api/src/__test__/store/modules/paths.test.ts @@ -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", ] `); }); diff --git a/api/src/cli/commands/report.ts b/api/src/cli/commands/report.ts index 266de1e6b..13e32e964 100644 --- a/api/src/cli/commands/report.ts +++ b/api/src/cli/commands/report.ts @@ -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['data']['cloud']>; type ServersQueryResultServer = NonNullable['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', diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index f27adc42c..f6f82502c 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -51,6 +51,7 @@ export const getWriteableConfig = ( 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, diff --git a/api/src/graphql/client/api/get-api-client.ts b/api/src/graphql/client/api/get-api-client.ts index 118a0a655..be92192a2 100644 --- a/api/src/graphql/client/api/get-api-client.ts +++ b/api/src/graphql/client/api/get-api-client.ts @@ -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); } }); diff --git a/api/src/graphql/express/get-images.ts b/api/src/graphql/express/get-images.ts deleted file mode 100644 index a1dc3530d..000000000 --- a/api/src/graphql/express/get-images.ts +++ /dev/null @@ -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'); -}; diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index 78318f73e..bdd1a4a45 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -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 = 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> }) } +export function AddPermissionInputSchema(): z.ZodObject> { + return z.object({ + action: z.string(), + possession: z.string(), + resource: ResourceSchema, + role: RoleSchema + }) +} + +export function AddRoleForApiKeyInputSchema(): z.ZodObject> { + return z.object({ + apiKeyId: z.string(), + role: RoleSchema + }) +} + +export function AddRoleForUserInputSchema(): z.ZodObject> { + return z.object({ + role: RoleSchema, + userId: z.string() + }) +} + export function AllowedOriginInputSchema(): z.ZodObject> { return z.object({ origins: z.array(z.string()) @@ -97,11 +124,11 @@ export function AllowedOriginInputSchema(): z.ZodObject> { 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> }) } +export function ApiKeyWithSecretSchema(): z.ZodObject> { + 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> { return z.object({ __typename: z.literal('Array').optional(), @@ -281,6 +320,14 @@ export function ContainerPortSchema(): z.ZodObject> { }) } +export function CreateApiKeyInputSchema(): z.ZodObject> { + return z.object({ + description: z.string().nullish(), + name: z.string(), + roles: z.array(RoleSchema) + }) +} + export function DevicesSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Devices').optional(), @@ -521,7 +568,7 @@ export function MeSchema(): z.ZodObject> { 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> { }) } +export function RemoveRoleFromApiKeyInputSchema(): z.ZodObject> { + return z.object({ + apiKeyId: z.string(), + role: RoleSchema + }) +} + export function ServerSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Server').optional(), @@ -958,7 +1012,7 @@ export function UserSchema(): z.ZodObject> { 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> { 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> { }) } -export function addApiKeyInputSchema(): z.ZodObject> { - return z.object({ - key: z.string().nullish(), - name: z.string().nullish(), - userId: z.string().nullish() - }) -} - export function addUserInputSchema(): z.ZodObject> { return z.object({ description: z.string().nullish(), @@ -1198,25 +1244,12 @@ export function arrayDiskInputSchema(): z.ZodObject> }) } -export function authenticateInputSchema(): z.ZodObject> { - return z.object({ - password: z.string() - }) -} - export function deleteUserInputSchema(): z.ZodObject> { return z.object({ name: z.string() }) } -export function updateApikeyInputSchema(): z.ZodObject> { - return z.object({ - description: z.string().nullish(), - expiresAt: z.number() - }) -} - export function usersInputSchema(): z.ZodObject> { return z.object({ slim: z.boolean().nullish() diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 4b98302f8..30095d50b 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -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; }; export type ApiKey = { __typename?: 'ApiKey'; + createdAt: Scalars['DateTime']['output']; description?: Maybe; - expiresAt: Scalars['Long']['output']; - key: Scalars['String']['output']; + id: Scalars['ID']['output']; name: Scalars['String']['output']; - scopes: Scalars['JSON']['output']; + roles: Array; }; 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; + id: Scalars['ID']['output']; + key: Scalars['String']['output']; + name: Scalars['String']['output']; + roles: Array; +}; + export type ArrayType = Node & { __typename?: 'Array'; /** Current boot disk */ @@ -318,6 +345,12 @@ export enum ContainerState { RUNNING = 'RUNNING' } +export type CreateApiKeyInput = { + description?: InputMaybe; + name: Scalars['String']['input']; + roles: Array; +}; + export type Devices = { __typename?: 'Devices'; gpu?: Maybe>>; @@ -563,7 +596,7 @@ export type Me = UserAccount & { id: Scalars['ID']['output']; name: Scalars['String']['output']; permissions?: Maybe; - roles: Scalars['String']['output']; + roles: Array; }; export enum MemoryFormFactor { @@ -616,10 +649,11 @@ export type Mount = { export type Mutation = { __typename?: 'Mutation'; - /** Create a new API key */ - addApikey?: Maybe; /** Add new disk to array */ addDiskToArray?: Maybe; + addPermission: Scalars['Boolean']['output']; + addRoleForApiKey: Scalars['Boolean']['output']; + addRoleForUser: Scalars['Boolean']['output']; /** Add a new user */ addUser?: Maybe; archiveAll: NotificationOverview; @@ -631,6 +665,7 @@ export type Mutation = { clearArrayDiskStatistics?: Maybe; 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; enableDynamicRemoteAccess: Scalars['Boolean']['output']; - /** Get an existing API key */ - getApiKey?: Maybe; login?: Maybe; mountArrayDisk?: Maybe; /** 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; + removeRoleFromApiKey: Scalars['Boolean']['output']; /** Resume parity check */ resumeParityCheck?: Maybe; setAdditionalAllowedOrigins: Array; @@ -665,14 +699,6 @@ export type Mutation = { unmountArrayDisk?: Maybe; /** Marks a notification as unread. */ unreadNotification: Notification; - /** Update an existing API key */ - updateApikey?: Maybe; -}; - - -export type MutationaddApikeyArgs = { - input?: InputMaybe; - 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; - 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; - name: Scalars['String']['input']; -}; - export type Network = Node & { __typename?: 'Network'; accessUrls?: Maybe>; @@ -995,8 +1034,8 @@ export type ProfileModel = { export type Query = { __typename?: 'Query'; - /** Get all API keys */ - apiKeys?: Maybe>>; + apiKey?: Maybe; + apiKeys: Array; /** An Unraid array consisting of 1 or 2 Parity disks and a number of Data disks. */ array: ArrayType; cloud?: Maybe; @@ -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; }; +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: ArrayType; config: Config; display?: Maybe; @@ -1357,14 +1446,14 @@ export type User = UserAccount & { name: Scalars['String']['output']; /** If the account has a password set */ password?: Maybe; - roles: Scalars['String']['output']; + roles: Array; }; export type UserAccount = { description: Scalars['String']['output']; id: Scalars['ID']['output']; name: Scalars['String']['output']; - roles: Scalars['String']['output']; + roles: Array; }; export type Vars = Node & { @@ -1603,12 +1692,6 @@ export type Welcome = { message: Scalars['String']['output']; }; -export type addApiKeyInput = { - key?: InputMaybe; - name?: InputMaybe; - userId?: InputMaybe; -}; - export type addUserInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -1622,10 +1705,6 @@ export type arrayDiskInput = { slot?: InputMaybe; }; -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; - expiresAt: Scalars['Long']['input']; -}; - export type usersInput = { slim?: InputMaybe; }; @@ -1734,9 +1808,13 @@ export type ResolversInterfaceTypes<_RefType extends Record> = export type ResolversTypes = ResolversObject<{ AccessUrl: ResolverTypeWrapper; AccessUrlInput: AccessUrlInput; + AddPermissionInput: AddPermissionInput; + AddRoleForApiKeyInput: AddRoleForApiKeyInput; + AddRoleForUserInput: AddRoleForUserInput; AllowedOriginInput: AllowedOriginInput; ApiKey: ResolverTypeWrapper; ApiKeyResponse: ResolverTypeWrapper; + ApiKeyWithSecret: ResolverTypeWrapper; Array: ResolverTypeWrapper; ArrayCapacity: ResolverTypeWrapper; ArrayDisk: ResolverTypeWrapper; @@ -1761,6 +1839,7 @@ export type ResolversTypes = ResolversObject<{ ContainerPort: ResolverTypeWrapper; ContainerPortType: ContainerPortType; ContainerState: ContainerState; + CreateApiKeyInput: CreateApiKeyInput; DateTime: ResolverTypeWrapper; Devices: ResolverTypeWrapper; Disk: ResolverTypeWrapper; @@ -1817,6 +1896,9 @@ export type ResolversTypes = ResolversObject<{ RegistrationState: RegistrationState; RelayResponse: ResolverTypeWrapper; RemoteAccess: ResolverTypeWrapper; + RemoveRoleFromApiKeyInput: RemoveRoleFromApiKeyInput; + Resource: Resource; + Role: Role; Server: ResolverTypeWrapper; ServerStatus: ServerStatus; Service: ResolverTypeWrapper; @@ -1843,14 +1925,11 @@ export type ResolversTypes = ResolversObject<{ WAN_ACCESS_TYPE: WAN_ACCESS_TYPE; WAN_FORWARD_TYPE: WAN_FORWARD_TYPE; Welcome: ResolverTypeWrapper; - 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; export type ApiKeyResolvers = ResolversObject<{ + createdAt?: Resolver; description?: Resolver, ParentType, ContextType>; - expiresAt?: Resolver; - key?: Resolver; + id?: Resolver; name?: Resolver; - scopes?: Resolver; + roles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -1974,6 +2056,16 @@ export type ApiKeyResponseResolvers; }>; +export type ApiKeyWithSecretResolvers = ResolversObject<{ + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; + id?: Resolver; + key?: Resolver; + name?: Resolver; + roles?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type ArrayResolvers = ResolversObject<{ boot?: Resolver, ParentType, ContextType>; caches?: Resolver, ParentType, ContextType>; @@ -2311,7 +2403,7 @@ export type MeResolvers; name?: Resolver; permissions?: Resolver, ParentType, ContextType>; - roles?: Resolver; + roles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -2346,8 +2438,10 @@ export type MountResolvers; export type MutationResolvers = ResolversObject<{ - addApikey?: Resolver, ParentType, ContextType, RequireFields>; addDiskToArray?: Resolver, ParentType, ContextType, Partial>; + addPermission?: Resolver>; + addRoleForApiKey?: Resolver>; + addRoleForUser?: Resolver>; addUser?: Resolver, ParentType, ContextType, RequireFields>; archiveAll?: Resolver>; archiveNotification?: Resolver>; @@ -2356,18 +2450,19 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; connectSignIn?: Resolver>; connectSignOut?: Resolver; + createApiKey?: Resolver>; createNotification?: Resolver>; deleteArchivedNotifications?: Resolver; deleteNotification?: Resolver>; deleteUser?: Resolver, ParentType, ContextType, RequireFields>; enableDynamicRemoteAccess?: Resolver>; - getApiKey?: Resolver, ParentType, ContextType, RequireFields>; login?: Resolver, ParentType, ContextType, RequireFields>; mountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; pauseParityCheck?: Resolver, ParentType, ContextType>; reboot?: Resolver, ParentType, ContextType>; recalculateOverview?: Resolver; removeDiskFromArray?: Resolver, ParentType, ContextType, Partial>; + removeRoleFromApiKey?: Resolver>; resumeParityCheck?: Resolver, ParentType, ContextType>; setAdditionalAllowedOrigins?: Resolver, ParentType, ContextType, RequireFields>; setupRemoteAccess?: Resolver>; @@ -2379,7 +2474,6 @@ export type MutationResolvers>; unmountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; unreadNotification?: Resolver>; - updateApikey?: Resolver, ParentType, ContextType, RequireFields>; }>; export type NetworkResolvers = ResolversObject<{ @@ -2559,7 +2653,8 @@ export type ProfileModelResolvers; export type QueryResolvers = ResolversObject<{ - apiKeys?: Resolver>>, ParentType, ContextType>; + apiKey?: Resolver, ParentType, ContextType, RequireFields>; + apiKeys?: Resolver, ParentType, ContextType>; array?: Resolver; cloud?: Resolver, ParentType, ContextType>; config?: Resolver; @@ -2659,7 +2754,6 @@ export type ShareResolvers; export type SubscriptionResolvers = ResolversObject<{ - apikeys?: SubscriptionResolver>>, "apikeys", ParentType, ContextType>; array?: SubscriptionResolver; config?: SubscriptionResolver; display?: SubscriptionResolver, "display", ParentType, ContextType>; @@ -2778,7 +2872,7 @@ export type UserResolvers; name?: Resolver; password?: Resolver, ParentType, ContextType>; - roles?: Resolver; + roles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -2787,7 +2881,7 @@ export type UserAccountResolvers; id?: Resolver; name?: Resolver; - roles?: Resolver; + roles?: Resolver, ParentType, ContextType>; }>; export type VarsResolvers = ResolversObject<{ @@ -2988,6 +3082,7 @@ export type Resolvers = ResolversObject<{ AccessUrl?: AccessUrlResolvers; ApiKey?: ApiKeyResolvers; ApiKeyResponse?: ApiKeyResponseResolvers; + ApiKeyWithSecret?: ApiKeyWithSecretResolvers; Array?: ArrayResolvers; ArrayCapacity?: ArrayCapacityResolvers; ArrayDisk?: ArrayDiskResolvers; diff --git a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts index 3157de991..781954fe5 100644 --- a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts +++ b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts @@ -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 => { +export const connectSignIn = async (input: ConnectSignInInput): Promise => { 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; } diff --git a/api/src/graphql/resolvers/subscription/remote-graphql/remote-query.ts b/api/src/graphql/resolvers/subscription/remote-graphql/remote-query.ts index 49cf225ef..c89b37a2c 100644 --- a/api/src/graphql/resolvers/subscription/remote-graphql/remote-query.ts +++ b/api/src/graphql/resolvers/subscription/remote-graphql/remote-query.ts @@ -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({ diff --git a/api/src/graphql/schema/types/apikeys/apikey.graphql b/api/src/graphql/schema/types/apikeys/apikey.graphql deleted file mode 100644 index 58ba4b6f2..000000000 --- a/api/src/graphql/schema/types/apikeys/apikey.graphql +++ /dev/null @@ -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! -} \ No newline at end of file diff --git a/api/src/graphql/schema/types/auth/auth.graphql b/api/src/graphql/schema/types/auth/auth.graphql new file mode 100644 index 000000000..19f129e9c --- /dev/null +++ b/api/src/graphql/schema/types/auth/auth.graphql @@ -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 +} diff --git a/api/src/graphql/schema/types/users/me.graphql b/api/src/graphql/schema/types/users/me.graphql index 23affdfc1..d6f7f9618 100644 --- a/api/src/graphql/schema/types/users/me.graphql +++ b/api/src/graphql/schema/types/users/me.graphql @@ -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 -} \ No newline at end of file +} diff --git a/api/src/graphql/schema/types/users/user.graphql b/api/src/graphql/schema/types/users/user.graphql index 3389b1419..9961c7e12 100644 --- a/api/src/graphql/schema/types/users/user.graphql +++ b/api/src/graphql/schema/types/users/user.graphql @@ -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 -} \ No newline at end of file +} diff --git a/api/src/index.ts b/api/src/index.ts index 4626210d1..d48b3cc64 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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 | null = null; diff --git a/api/src/store/actions/add-remote-subscription.ts b/api/src/store/actions/add-remote-subscription.ts new file mode 100644 index 000000000..eadac206f --- /dev/null +++ b/api/src/store/actions/add-remote-subscription.ts @@ -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, + }; +}); diff --git a/api/src/store/listeners/listener-middleware.ts b/api/src/store/listeners/listener-middleware.ts index a3416f428..a14210ac4 100644 --- a/api/src/store/listeners/listener-middleware.ts +++ b/api/src/store/listeners/listener-middleware.ts @@ -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; -export const startAppListening = - listenerMiddleware.startListening as AppStartListening; +export const startAppListening = listenerMiddleware.startListening as AppStartListening; export type AppStartListeningParams = Parameters[0]; -export const addAppListener = addListener as TypedAddListener< - RootState, - AppDispatch ->; +export const addAppListener = addListener as TypedAddListener; export const startMiddlewareListeners = () => { // Begin listening for events + enableLocalApiKeyListener(); enableConfigFileListener('flash')(); enableConfigFileListener('memory')(); enableUpnpListener(); @@ -43,4 +40,4 @@ export const startMiddlewareListeners = () => { enableWanAccessChangeListener(); enableServerStateListener(); enableNotificationPathListener(); -} +}; diff --git a/api/src/store/listeners/local-api-key-listener.ts b/api/src/store/listeners/local-api-key-listener.ts new file mode 100644 index 000000000..27cb736ad --- /dev/null +++ b/api/src/store/listeners/local-api-key-listener.ts @@ -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); + } + }, + }); diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 10aecb5f8..6163297ad 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -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, - Pick, + Pick, + Pick, { 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: '', diff --git a/api/src/store/modules/paths.ts b/api/src/store/modules/paths.ts index 81519c71d..7cc3d0d42 100644 --- a/api/src/store/modules/paths.ts +++ b/api/src/store/modules/paths.ts @@ -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({ diff --git a/api/src/types/fastify.ts b/api/src/types/fastify.ts new file mode 100644 index 000000000..4458cd144 --- /dev/null +++ b/api/src/types/fastify.ts @@ -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; diff --git a/api/src/types/my-servers-config.d.ts b/api/src/types/my-servers-config.d.ts index a19167ea4..af37e9a46 100644 --- a/api/src/types/my-servers-config.d.ts +++ b/api/src/types/my-servers-config.d.ts @@ -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 { - 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; }; } - diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index bb6dbe777..6e86716f5 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -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 {} diff --git a/api/src/unraid-api/app/cors.ts b/api/src/unraid-api/app/cors.ts index 4cc86dc15..884038b31 100644 --- a/api/src/unraid-api/app/cors.ts +++ b/api/src/unraid-api/app/cors.ts @@ -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) => { diff --git a/api/src/unraid-api/auth/api-key.service.spec.ts b/api/src/unraid-api/auth/api-key.service.spec.ts new file mode 100644 index 000000000..9826aa1c1 --- /dev/null +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -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; + error: ReturnType; + warn: ReturnType; + debug: ReturnType; + verbose: ReturnType; + }; + 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' + ); + }); + }); +}); diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts new file mode 100644 index 000000000..37b473f90 --- /dev/null +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/api/src/unraid-api/auth/auth.guard.ts b/api/src/unraid-api/auth/auth.guard.ts index a1fe7afa2..07e7b2e43 100644 --- a/api/src/unraid-api/auth/auth.guard.ts +++ b/api/src/unraid-api/auth/auth.guard.ts @@ -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(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) { diff --git a/api/src/unraid-api/auth/auth.module.ts b/api/src/unraid-api/auth/auth.module.ts index 0e8f1e2a2..ef4269006 100644 --- a/api/src/unraid-api/auth/auth.module.ts +++ b/api/src/unraid-api/auth/auth.module.ts @@ -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 {} diff --git a/api/src/unraid-api/auth/auth.service.spec.ts b/api/src/unraid-api/auth/auth.service.spec.ts index 811cbbf82..70a92a77f 100644 --- a/api/src/unraid-api/auth/auth.service.spec.ts +++ b/api/src/unraid-api/auth/auth.service.spec.ts @@ -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); - }); + 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 + ); + }); + }); }); diff --git a/api/src/unraid-api/auth/auth.service.ts b/api/src/unraid-api/auth/auth.service.ts index 38c78f879..7744ba66e 100644 --- a/api/src/unraid-api/auth/auth.service.ts +++ b/api/src/unraid-api/auth/auth.service.ts @@ -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 { - const user = this.usersService.findOne(apiKey); - if (user) { - return user; + async validateApiKeyCasbin(apiKey: string): Promise { + 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 { - if (await this.cookieService.hasValidAuthCookie(cookies)) { - return this.usersService.getSessionUser(); + async validateCookiesCasbin(cookies: object): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + this.logger.debug('getSessionUser called!'); + return { + id: '-1', + description: 'UPC service account', + name: 'upc', + roles: [Role.UPC], + }; } } diff --git a/api/src/unraid-api/auth/casbin/casbin.module.ts b/api/src/unraid-api/auth/casbin/casbin.module.ts new file mode 100644 index 000000000..4441eb499 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/casbin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { CasbinService } from './casbin.service'; + +@Module({ + providers: [CasbinService], + exports: [CasbinService], +}) +export class CasbinModule {} diff --git a/api/src/unraid-api/auth/casbin/casbin.service.ts b/api/src/unraid-api/auth/casbin/casbin.service.ts new file mode 100644 index 000000000..5cfe2e0ae --- /dev/null +++ b/api/src/unraid-api/auth/casbin/casbin.service.ts @@ -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}`); + } + } +} diff --git a/api/src/unraid-api/auth/casbin/index.ts b/api/src/unraid-api/auth/casbin/index.ts new file mode 100644 index 000000000..4a74aafe5 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/index.ts @@ -0,0 +1,2 @@ +export * from './model'; +export * from './policy'; diff --git a/api/src/unraid-api/auth/casbin/model.ts b/api/src/unraid-api/auth/casbin/model.ts new file mode 100644 index 000000000..6583f7121 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/model.ts @@ -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 == '*') +`; diff --git a/api/src/unraid-api/auth/casbin/policy.ts b/api/src/unraid-api/auth/casbin/policy.ts new file mode 100644 index 000000000..bc6831bd2 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/policy.ts @@ -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} +`; diff --git a/api/src/unraid-api/auth/cookie.service.ts b/api/src/unraid-api/auth/cookie.service.ts index 3a4f08a4e..a531824eb 100644 --- a/api/src/unraid-api/auth/cookie.service.ts +++ b/api/src/unraid-api/auth/cookie.service.ts @@ -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', + }; } /** diff --git a/api/src/unraid-api/auth/cookie.strategy.ts b/api/src/unraid-api/auth/cookie.strategy.ts index 35c80618e..598b23470 100644 --- a/api/src/unraid-api/auth/cookie.strategy.ts +++ b/api/src/unraid-api/auth/cookie.strategy.ts @@ -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 => { - return this.authService.validateCookies(req.cookies); + return this.authService.validateCookiesCasbin(req.cookies); }; } diff --git a/api/src/unraid-api/auth/fastify-throttler.guard.ts b/api/src/unraid-api/auth/fastify-throttler.guard.ts new file mode 100644 index 000000000..b8d135438 --- /dev/null +++ b/api/src/unraid-api/auth/fastify-throttler.guard.ts @@ -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): Promise { + 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, + }; + } +} diff --git a/api/src/unraid-api/auth/header.strategy.ts b/api/src/unraid-api/auth/header.strategy.ts index 6fd1adec6..e7c057090 100644 --- a/api/src/unraid-api/auth/header.strategy.ts +++ b/api/src/unraid-api/auth/header.strategy.ts @@ -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 => { - this.logger.debug('Validating API key'); - const user = await this.authService.validateUser(apiKey); + async validate(req: any): Promise { + 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'); + } + } } diff --git a/api/src/unraid-api/auth/users.service.ts b/api/src/unraid-api/auth/users.service.ts deleted file mode 100644 index 5b9927975..000000000 --- a/api/src/unraid-api/auth/users.service.ts +++ /dev/null @@ -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', - }; - } -} diff --git a/api/src/unraid-api/graph/connect/connect.resolver.ts b/api/src/unraid-api/graph/connect/connect.resolver.ts index 7f891a33a..db8b4d413 100644 --- a/api/src/unraid-api/graph/connect/connect.resolver.ts +++ b/api/src/unraid-api/graph/connect/connect.resolver.ts @@ -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, diff --git a/api/src/unraid-api/graph/network/network.resolver.ts b/api/src/unraid-api/graph/network/network.resolver.ts index 4c06a891a..872b793cc 100644 --- a/api/src/unraid-api/graph/network/network.resolver.ts +++ b/api/src/unraid-api/graph/network/network.resolver.ts @@ -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 { return { - id: 'network' + id: 'network', }; } diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 35286117b..4687ecc89 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/auth/auth.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/auth/auth.resolver.spec.ts new file mode 100644 index 000000000..49a84427f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/auth/auth.resolver.spec.ts @@ -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] + ); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/auth/auth.resolver.ts b/api/src/unraid-api/graph/resolvers/auth/auth.resolver.ts new file mode 100644 index 000000000..53f340e11 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/auth/auth.resolver.ts @@ -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 { + return this.apiKeyService.findAll(); + } + + @Query() + @UsePermissions({ + action: AuthActionVerb.READ, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + async apiKey(@Args('id') id: string): Promise { + return this.apiKeyService.findById(id); + } + + @Mutation() + @UsePermissions({ + action: AuthActionVerb.CREATE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + async createApiKey( + @Args('input') + input: CreateApiKeyInput + ): Promise { + 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 { + 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 { + 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 { + return this.authService.removeRoleFromApiKey(input.apiKeyId, Role[input.role]); + } +} diff --git a/api/src/unraid-api/graph/resolvers/cloud/cloud.resolver.ts b/api/src/unraid-api/graph/resolvers/cloud/cloud.resolver.ts index 93f664573..fb5a5c6f9 100644 --- a/api/src/unraid-api/graph/resolvers/cloud/cloud.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/cloud/cloud.resolver.ts @@ -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 { 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 { 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> { 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 { + public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise { /** * @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 { + public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise { await store.dispatch(setupRemoteAccessThunk(input)).unwrap(); return true; } - } diff --git a/api/src/unraid-api/graph/resolvers/config/config.resolver.ts b/api/src/unraid-api/graph/resolvers/config/config.resolver.ts index 9fcd24020..607ac440c 100644 --- a/api/src/unraid-api/graph/resolvers/config/config.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/config/config.resolver.ts @@ -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 { 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)); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts index f06f5b007..fa34723d5 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -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({ diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 6af15212d..1b5e42ee6 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -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 { /** @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 86b8560f4..6a18ee99b 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -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() { diff --git a/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts b/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts index c8f864f64..ce60f08a9 100644 --- a/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts @@ -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(); diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts index 1152387a4..d96dbd8aa 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index b3c1f9b87..647e9672b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/online/online.resolver.ts b/api/src/unraid-api/graph/resolvers/online/online.resolver.ts index 14538bac5..1f8bdb474 100644 --- a/api/src/unraid-api/graph/resolvers/online/online.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/online/online.resolver.ts @@ -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; diff --git a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts index 87a90c928..2a8981abd 100644 --- a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts b/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts index 6ed311dfe..389ff52cc 100644 --- a/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts @@ -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); diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index f3651fd07..5f0d6f8fc 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index ef8fcbb05..0fcac2a93 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -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 { 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 { 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); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts index 5d900a38f..93b17d2ec 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts @@ -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 ?? {}), + }; } } diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts b/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts index ab955a1c4..be266510a 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts @@ -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(); diff --git a/api/src/unraid-api/graph/services/services.resolver.ts b/api/src/unraid-api/graph/services/services.resolver.ts index b78225447..253362c02 100644 --- a/api/src/unraid-api/graph/services/services.resolver.ts +++ b/api/src/unraid-api/graph/services/services.resolver.ts @@ -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] : [])]; } } diff --git a/api/src/unraid-api/graph/shares/shares.resolver.ts b/api/src/unraid-api/graph/shares/shares.resolver.ts index fcfc8973a..6a3d95067 100644 --- a/api/src/unraid-api/graph/shares/shares.resolver.ts +++ b/api/src/unraid-api/graph/shares/shares.resolver.ts @@ -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'); diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index f6163eabb..984ac0005 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -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 { - const server: FastifyInstance = Fastify({ + const server = Fastify({ logger: false, - }); + }) as FastifyInstance; apiLogger.debug('Creating Nest Server'); diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index fe2cda17e..25d84a94b 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -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'); } diff --git a/api/src/unraid-api/types/request.ts b/api/src/unraid-api/types/request.ts index fb578cfaf..d6f6951c4 100644 --- a/api/src/unraid-api/types/request.ts +++ b/api/src/unraid-api/types/request.ts @@ -1,3 +1,3 @@ -import type { FastifyRequest } from 'fastify'; +import type { FastifyRequest } from '@app/types/fastify'; -export interface CustomRequest extends FastifyRequest {} \ No newline at end of file +export interface CustomRequest extends FastifyRequest {} diff --git a/api/src/utils.ts b/api/src/utils.ts index c203c4ef1..38afba200 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -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(value: T): value is NonNullable { 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 +): 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}`); +} diff --git a/api/vite.config.ts b/api/vite.config.ts index 87b7625e2..64e179cc0 100644 --- a/api/vite.config.ts +++ b/api/vite.config.ts @@ -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,