refactor: permissions system rewrite (#942)

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