mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: initial setup of permissions on keys (#1068)
* feat: initial setup of permissions on keys * fix: remove API keys * test: update me resolver, findByIdWithSecret, findByKey and saveApiKey tests * test: update and fix the rest of the failing api key tests * fix: add reflect-metadata to test setup in vite config * fix: revert myservers.cfg to original * fix: update User type on me resolver * fix: make permissions nullable and rerun codegen * fix: update import syntax in me resolver * refactor: move create-local-connect-api-key to api key service and handle in onModuleInit * test: add tests for createLocalApiKeyForConnectIfNecessary * refactor: add validation to me resolver * refactor: address code rabbit suggestions * refactor: update me resolver tests and fix hasOwnProperty error * refactor: remove console log * test: add additional coverage for me resolver tests * test: fix failing test * refactor: address review comments, add new api-key service test, and remove deprecated keys * refactor: address review comments --------- Co-authored-by: mdatelle <mike@datelle.net>
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
{
|
||||
"admin": {
|
||||
"extends": "user",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "array",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "cpu",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device/unassigned",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk/settings",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "display",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/container",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "info",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "license-key",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "machine-id",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "memory",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "notifications",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "online",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "os",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "parity-history",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "servers",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/emhttpd",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/unraid-api",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "services",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "share",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "software-versions",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "unraid-version",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "user",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "var",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vars",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/domain",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"extends": "guest",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:own",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guest": {
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "welcome",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"id": "10f356da-1e9e-43b8-9028-a26a645539a6",
|
||||
"key": "73717ca0-8c15-40b9-bcca-8d85656d1438",
|
||||
"name": "Test API Key",
|
||||
"description": "Testing API key creation",
|
||||
"roles": ["guest", "connect"],
|
||||
"createdAt": "2024-10-29T19:59:12.569Z"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"createdAt": "2024-12-20T15:05:55.336Z",
|
||||
"description": "API key for Connect user",
|
||||
"id": "d166bf8b-3615-444a-8932-c460b2132ba3",
|
||||
"key": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"name": "Connect",
|
||||
"roles": [
|
||||
"connect"
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"dev": "vite",
|
||||
"container:build": "./scripts/dc.sh build dev",
|
||||
"container:start": "./scripts/dc.sh run --rm --service-ports dev",
|
||||
"container:stop": "./scripts/dc.sh stop dev",
|
||||
"container:test": "./scripts/dc.sh run --rm builder npm run test",
|
||||
"container:enter": "./scripts/dc.sh exec dev /bin/bash"
|
||||
},
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
import { setupPermissions } from '@app/core/permissions';
|
||||
|
||||
test('Returns default permissions', () => {
|
||||
expect(setupPermissions()).toMatchSnapshot();
|
||||
});
|
||||
@@ -2,6 +2,4 @@ export * as modules from '@app/core/modules';
|
||||
export * as notifiers from '@app/core/notifiers';
|
||||
export * as utils from '@app/core/utils';
|
||||
export * from '@app/core/log';
|
||||
export * from '@app/core/permission-manager';
|
||||
export * from '@app/core/permissions';
|
||||
export * from '@app/core/pubsub';
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { CoreContext, CoreResult } from '@app/core/types';
|
||||
import { getPermissions } from '@app/core/utils/permissions/get-permissions';
|
||||
|
||||
/**
|
||||
* Get current user.
|
||||
*/
|
||||
export const getMe = (context: CoreContext): CoreResult => {
|
||||
const { user } = context;
|
||||
|
||||
const me = {
|
||||
...user,
|
||||
permissions: getPermissions(user.role),
|
||||
};
|
||||
|
||||
return {
|
||||
text: `Me: ${JSON.stringify(me, null, 2)}`,
|
||||
json: me,
|
||||
};
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ac } from '@app/core/permissions';
|
||||
import { getPermissions as getUserPermissions } from '@app/core/utils/permissions/get-permissions';
|
||||
import type { CoreContext, CoreResult } from '@app/core/types';
|
||||
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
|
||||
|
||||
/**
|
||||
* Get all permissions.
|
||||
*/
|
||||
export const getPermissions = async function (context: CoreContext): Promise<CoreResult> {
|
||||
const { user } = context;
|
||||
|
||||
// Bail if the user doesn't have permission
|
||||
ensurePermission(user, {
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
});
|
||||
|
||||
// Get all scopes
|
||||
const scopes = Object.assign({}, ...Object.values(ac.getGrants()).map(grant => {
|
||||
// @ts-expect-error - $extend and grants are any
|
||||
const { $extend, ...grants } = grant;
|
||||
return {
|
||||
...grants,
|
||||
...$extend && getUserPermissions($extend),
|
||||
};
|
||||
}));
|
||||
|
||||
// Get all roles and their scopes
|
||||
const grants = Object.entries(ac.getGrants())
|
||||
.map(([name, grant]) => {
|
||||
// @ts-expect-error - $extend and grants are any
|
||||
|
||||
const { $extend: _, ...grants } = grant;
|
||||
return [name, grants];
|
||||
})
|
||||
.reduce((object, {
|
||||
0: key,
|
||||
1: value,
|
||||
}) => Object.assign(object, {
|
||||
[key.toString()]: value,
|
||||
}), {});
|
||||
|
||||
return {
|
||||
text: `Scopes: ${JSON.stringify(scopes, null, 2)}`,
|
||||
json: {
|
||||
scopes,
|
||||
grants,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -14,9 +14,7 @@ export * from './add-user';
|
||||
export * from './get-apps';
|
||||
export * from './get-devices';
|
||||
export * from './get-disks';
|
||||
export * from './get-me';
|
||||
export * from './get-parity-history';
|
||||
export * from './get-permissions';
|
||||
export * from './get-services';
|
||||
export * from './get-users';
|
||||
export * from './get-welcome';
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { validate as validateArgument } from 'bycontract';
|
||||
import { type LooseObject } from '@app/core/types';
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
|
||||
/**
|
||||
* Permission manager.
|
||||
*/
|
||||
class PermissionManager {
|
||||
private readonly knownScopes: string[];
|
||||
private readonly scopes: LooseObject;
|
||||
|
||||
/**
|
||||
* @hideconstructor
|
||||
*/
|
||||
constructor() {
|
||||
/**
|
||||
* Scopes that've been registered
|
||||
*
|
||||
* @name PermissionManager.knownScopes
|
||||
*/
|
||||
this.knownScopes = [];
|
||||
|
||||
/**
|
||||
* Keys and what scopes are linked to them.
|
||||
*
|
||||
* Note: If this key is linked to a user it'll extend their scopes.
|
||||
*
|
||||
* @name PermissionManager.scopes
|
||||
*/
|
||||
this.scopes = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scopes based on name or fall back to all scopes
|
||||
*
|
||||
* @param apiKey The API key to lookup.
|
||||
* @memberof PermissionManager
|
||||
*/
|
||||
getScopes(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
return this.knownScopes;
|
||||
}
|
||||
|
||||
validateArgument(apiKey, 'string');
|
||||
|
||||
if (!Object.keys(this.scopes).includes(apiKey)) {
|
||||
throw new AppError('Invalid key!');
|
||||
}
|
||||
|
||||
return this.scopes[apiKey];
|
||||
}
|
||||
}
|
||||
|
||||
export const permissionManager = new PermissionManager();
|
||||
@@ -1,215 +0,0 @@
|
||||
import { apiLogger } from '@app/core/log';
|
||||
import { RolesBuilder } from 'nest-access-control';
|
||||
|
||||
export interface Permission {
|
||||
role?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
attributes: string;
|
||||
}
|
||||
export interface Role {
|
||||
permissions: Array<Permission>;
|
||||
extends?: string;
|
||||
}
|
||||
|
||||
// Use built in permissions
|
||||
const roles: Record<string, Role> = {
|
||||
admin: {
|
||||
extends: 'guest',
|
||||
permissions: [
|
||||
{ resource: 'apikey', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'cloud', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'config', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'config', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'connect', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'customizations', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'array', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'cpu', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'crash-reporting-enabled',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'device', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'device/unassigned',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'disk', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'disk/settings', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'display', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'docker/container',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'docker/network', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'flash', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'info', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'license-key', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'logs', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'machine-id', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'memory', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'notifications',
|
||||
action: 'create:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'online', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'os', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'owner', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'parity-history', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'permission', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'registration', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'servers', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'service', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'service/emhttpd',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{
|
||||
resource: 'service/unraid-api',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'services', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'share', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'software-versions',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'unraid-version', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'uptime', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'user', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vars', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vms', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vms/domain', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vms/network', action: 'read:any', attributes: '*' },
|
||||
],
|
||||
},
|
||||
upc: {
|
||||
extends: 'guest',
|
||||
permissions: [
|
||||
{ resource: 'apikey', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'cloud', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'config', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'crash-reporting-enabled',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'customizations', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'disk', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'display', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'flash', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'info', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'logs', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'os', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'owner', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'permission', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'registration', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'servers', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vars', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'config', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'update:any', attributes: '*' },
|
||||
],
|
||||
},
|
||||
my_servers: {
|
||||
extends: 'guest',
|
||||
permissions: [
|
||||
{ resource: 'array', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'config', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'connect', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'connect/dynamic-remote-access',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{
|
||||
resource: 'connect/dynamic-remote-access',
|
||||
action: 'update:own',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'customizations', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'dashboard', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'display', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'docker/container',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'docker', action: 'read:any', attributes: '*' },
|
||||
{
|
||||
resource: 'docker/container',
|
||||
action: 'read:any',
|
||||
attributes: '*',
|
||||
},
|
||||
{ resource: 'info', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'logs', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'network', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'services', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vars', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vms', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'vms/domain', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'unraid-version', action: 'read:any', attributes: '*' },
|
||||
],
|
||||
},
|
||||
notifier: {
|
||||
extends: 'guest',
|
||||
permissions: [
|
||||
{
|
||||
resource: 'notifications',
|
||||
action: 'create:own',
|
||||
attributes: '*',
|
||||
},
|
||||
],
|
||||
},
|
||||
guest: {
|
||||
permissions: [
|
||||
{ resource: 'me', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'welcome', action: 'read:any', attributes: '*' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const setupPermissions = (): RolesBuilder => {
|
||||
// First create an array of permissions that will be used as the base permission set for the app
|
||||
const grantList = Object.entries(roles).reduce<Array<Permission>>(
|
||||
(acc, [roleName, role]) => {
|
||||
if (role.permissions) {
|
||||
role.permissions.forEach((permission) => {
|
||||
acc.push({
|
||||
...permission,
|
||||
role: roleName,
|
||||
});
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const ac = new RolesBuilder(grantList);
|
||||
|
||||
// Next, Extend roles
|
||||
Object.entries(roles).forEach(([roleName, role]) => {
|
||||
if (role.extends) {
|
||||
ac.extendRole(roleName, role.extends);
|
||||
}
|
||||
});
|
||||
|
||||
apiLogger.debug('Possible Roles: %o', ac.getRoles());
|
||||
|
||||
return ac;
|
||||
};
|
||||
|
||||
export const ac = null;
|
||||
@@ -1,33 +1,10 @@
|
||||
import { ParameterMissingError } from '@app/core/errors/param-missing-error';
|
||||
import { ac } from '@app/core/permissions';
|
||||
import { type User } from '@app/core/types/states/user';
|
||||
|
||||
export interface AccessControlOptions {
|
||||
/** Which resource to verify the user's role against. e.g. 'apikeys' */
|
||||
resource: string;
|
||||
/** Which action to verify the user's role against. e.g. 'read' */
|
||||
action: 'create' | 'read' | 'update' | 'delete';
|
||||
/** If the user can access their own or everyone's. */
|
||||
possession: 'own' | 'any';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has the correct permissions.
|
||||
* @param user The user to check permissions on.
|
||||
*/
|
||||
export const checkPermission = (user: User, options: AccessControlOptions) => {
|
||||
if (!user) {
|
||||
throw new ParameterMissingError('user');
|
||||
}
|
||||
|
||||
const { resource, action, possession = 'own' } = options;
|
||||
const permission = ac.permission({
|
||||
role: user.role,
|
||||
resource,
|
||||
action,
|
||||
possession,
|
||||
});
|
||||
|
||||
// Check if user is allowed
|
||||
return permission.granted;
|
||||
export const checkPermission = (user: User, options: any) => {
|
||||
// Stub until this can be removed
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,49 +1,14 @@
|
||||
import { PermissionError } from '@app/core/errors/permission-error';
|
||||
import { type User } from '@app/core/types/states/user';
|
||||
import {
|
||||
checkPermission,
|
||||
type AccessControlOptions,
|
||||
} from '@app/core/utils/permissions/check-permission';
|
||||
import { logger } from '@app/core/log';
|
||||
import { BYPASS_PERMISSION_CHECKS, NODE_ENV } from '@app/environment';
|
||||
|
||||
/**
|
||||
* Ensure the user has the correct permissions.
|
||||
* @param user The user to check permissions on.
|
||||
* @param options A permissions object.
|
||||
* @deprecated Use casbin auth in nest instead
|
||||
*/
|
||||
export const ensurePermission = (
|
||||
user: User | undefined,
|
||||
options: AccessControlOptions
|
||||
options: any
|
||||
) => {
|
||||
const { resource, action, possession = 'own' } = options;
|
||||
|
||||
// Bail if no user was passed
|
||||
if (!user)
|
||||
throw new PermissionError(
|
||||
`No user provided for authentication check when trying to access "${resource}".`
|
||||
);
|
||||
|
||||
const permissionGranted = checkPermission(user, {
|
||||
resource,
|
||||
action,
|
||||
possession,
|
||||
});
|
||||
|
||||
if (
|
||||
NODE_ENV === 'development' &&
|
||||
BYPASS_PERMISSION_CHECKS === true &&
|
||||
!permissionGranted
|
||||
) {
|
||||
logger.warn(
|
||||
`BYPASSING_PERMISSION_CHECK: ${user.name} doesn't have permission to access "${resource}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail if user doesn't have permission
|
||||
if (!permissionGranted)
|
||||
throw new PermissionError(
|
||||
`${user.name} doesn't have permission to access "${resource}".`
|
||||
);
|
||||
// Stub for now
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ac } from '@app/core/permissions';
|
||||
|
||||
/**
|
||||
* Get permissions from an {@link https://onury.io/accesscontrol/?api=ac#AccessControl AccessControl} role.
|
||||
* @param role The {@link https://onury.io/accesscontrol/?api=ac#AccessControl AccessControl} role to be looked up.
|
||||
*/
|
||||
export const getPermissions = (role: string): Record<string, Record<string, string[]>> => {
|
||||
const grants: Record<string, Record<string, string[]>> = ac.getGrants();
|
||||
const { $extend, ...roles } = grants[role] ?? {};
|
||||
const inheritedRoles = Array.isArray($extend) ? $extend.map(role => getPermissions(role))[0] : {};
|
||||
|
||||
return Object.assign({}, roles, inheritedRoles);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Types from '@app/graphql/generated/api/types';
|
||||
|
||||
import { z } from 'zod'
|
||||
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 { 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, Permission, 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<{
|
||||
@@ -128,6 +128,7 @@ export function ApiKeySchema(): z.ZodObject<Properties<ApiKey>> {
|
||||
description: z.string().nullish(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
permissions: z.array(PermissionSchema()),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
@@ -148,6 +149,7 @@ export function ApiKeyWithSecretSchema(): z.ZodObject<Properties<ApiKeyWithSecre
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
permissions: z.array(PermissionSchema()),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
@@ -567,7 +569,7 @@ export function MeSchema(): z.ZodObject<Properties<Me>> {
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
permissions: definedNonNullAnySchema.nullish(),
|
||||
permissions: z.array(PermissionSchema()).nullish(),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
@@ -818,6 +820,14 @@ export function PciSchema(): z.ZodObject<Properties<Pci>> {
|
||||
})
|
||||
}
|
||||
|
||||
export function PermissionSchema(): z.ZodObject<Properties<Permission>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Permission').optional(),
|
||||
actions: z.array(z.string()).nullish(),
|
||||
resource: ResourceSchema
|
||||
})
|
||||
}
|
||||
|
||||
export function ProfileModelSchema(): z.ZodObject<Properties<ProfileModel>> {
|
||||
return z.object({
|
||||
__typename: z.literal('ProfileModel').optional(),
|
||||
@@ -1012,6 +1022,7 @@ export function UserSchema(): z.ZodObject<Properties<User>> {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
password: z.boolean().nullish(),
|
||||
permissions: z.array(PermissionSchema()).nullish(),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
@@ -1021,6 +1032,7 @@ export function UserAccountSchema(): z.ZodObject<Properties<UserAccount>> {
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
permissions: z.array(PermissionSchema()).nullish(),
|
||||
roles: z.array(RoleSchema)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export type ApiKey = {
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions?: Maybe<Array<Permission>>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -82,6 +83,7 @@ export type ApiKeyWithSecret = {
|
||||
id: Scalars['ID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -595,7 +597,7 @@ export type Me = UserAccount & {
|
||||
description: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions?: Maybe<Scalars['JSON']['output']>;
|
||||
permissions?: Maybe<Array<Permission>>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -1024,6 +1026,12 @@ export type Pci = {
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions?: Maybe<Array<Scalars['String']['output']>>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
export type ProfileModel = {
|
||||
__typename?: 'ProfileModel';
|
||||
avatar?: Maybe<Scalars['String']['output']>;
|
||||
@@ -1444,6 +1452,7 @@ export type User = UserAccount & {
|
||||
name: Scalars['String']['output'];
|
||||
/** If the account has a password set */
|
||||
password?: Maybe<Scalars['Boolean']['output']>;
|
||||
permissions?: Maybe<Array<Permission>>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -1451,6 +1460,7 @@ export type UserAccount = {
|
||||
description: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions?: Maybe<Array<Permission>>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -1887,6 +1897,7 @@ export type ResolversTypes = ResolversObject<{
|
||||
ParityCheck: ResolverTypeWrapper<ParityCheck>;
|
||||
Partition: ResolverTypeWrapper<Partition>;
|
||||
Pci: ResolverTypeWrapper<Pci>;
|
||||
Permission: ResolverTypeWrapper<Permission>;
|
||||
Port: ResolverTypeWrapper<Scalars['Port']['output']>;
|
||||
ProfileModel: ResolverTypeWrapper<ProfileModel>;
|
||||
Query: ResolverTypeWrapper<{}>;
|
||||
@@ -1999,6 +2010,7 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
ParityCheck: ParityCheck;
|
||||
Partition: Partition;
|
||||
Pci: Pci;
|
||||
Permission: Permission;
|
||||
Port: Scalars['Port']['output'];
|
||||
ProfileModel: ProfileModel;
|
||||
Query: {};
|
||||
@@ -2044,6 +2056,7 @@ export type ApiKeyResolvers<ContextType = Context, ParentType extends ResolversP
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
permissions?: Resolver<Maybe<Array<ResolversTypes['Permission']>>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2060,6 +2073,7 @@ export type ApiKeyWithSecretResolvers<ContextType = Context, ParentType extends
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
key?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
permissions?: Resolver<Array<ResolversTypes['Permission']>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2400,7 +2414,7 @@ export type MeResolvers<ContextType = Context, ParentType extends ResolversParen
|
||||
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
permissions?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
permissions?: Resolver<Maybe<Array<ResolversTypes['Permission']>>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2638,6 +2652,12 @@ export type PciResolvers<ContextType = Context, ParentType extends ResolversPare
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type PermissionResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Permission'] = ResolversParentTypes['Permission']> = ResolversObject<{
|
||||
actions?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
|
||||
resource?: Resolver<ResolversTypes['Resource'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export interface PortScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['Port'], any> {
|
||||
name: 'Port';
|
||||
}
|
||||
@@ -2870,6 +2890,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>;
|
||||
permissions?: Resolver<Maybe<Array<ResolversTypes['Permission']>>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2879,6 +2900,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>;
|
||||
permissions?: Resolver<Maybe<Array<ResolversTypes['Permission']>>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -3128,6 +3150,7 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
|
||||
ParityCheck?: ParityCheckResolvers<ContextType>;
|
||||
Partition?: PartitionResolvers<ContextType>;
|
||||
Pci?: PciResolvers<ContextType>;
|
||||
Permission?: PermissionResolvers<ContextType>;
|
||||
Port?: GraphQLScalarType;
|
||||
ProfileModel?: ProfileModelResolvers<ContextType>;
|
||||
Query?: QueryResolvers<ContextType>;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
type Permission {
|
||||
resource: Resource!
|
||||
actions: [String!]
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
createdAt: DateTime!
|
||||
permissions: [Permission!]!
|
||||
}
|
||||
|
||||
type ApiKeyWithSecret {
|
||||
@@ -13,6 +19,7 @@ type ApiKeyWithSecret {
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
createdAt: DateTime!
|
||||
permissions: [Permission!]!
|
||||
}
|
||||
|
||||
input CreateApiKeyInput {
|
||||
|
||||
@@ -13,7 +13,7 @@ type Me implements UserAccount {
|
||||
name: String!
|
||||
description: String!
|
||||
roles: [Role!]!
|
||||
permissions: JSON
|
||||
permissions: [Permission!]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
|
||||
@@ -3,6 +3,7 @@ interface UserAccount {
|
||||
name: String!
|
||||
description: String!
|
||||
roles: [Role!]!
|
||||
permissions: [Permission!]
|
||||
}
|
||||
|
||||
input usersInput {
|
||||
@@ -61,4 +62,5 @@ type User implements UserAccount {
|
||||
If the account has a password set
|
||||
"""
|
||||
password: Boolean
|
||||
permissions: [Permission!]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -31,7 +30,6 @@ 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';
|
||||
import { createLocalApiKeyForConnectIfNecessary } from '@app/mothership/utils/create-local-connect-api-key';
|
||||
|
||||
import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership';
|
||||
|
||||
@@ -88,8 +86,6 @@ try {
|
||||
// Start listening to dynamix config file changes
|
||||
setupDynamixConfigWatch();
|
||||
|
||||
await createLocalApiKeyForConnectIfNecessary();
|
||||
|
||||
// Disabled until we need the access token to work
|
||||
// TokenRefresh.init();
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { minigraphLogger } from '@app/core/log';
|
||||
import { getters, store } from '@app/store/index';
|
||||
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 createLocalApiKeyForConnectIfNecessary = async () => {
|
||||
if (getters.config().status !== FileLoadStatus.LOADED) {
|
||||
minigraphLogger.error('Config file not loaded, cannot create local API key');
|
||||
return;
|
||||
}
|
||||
|
||||
const { remote } = getters.config();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
// If the remote API Key is set and the local key is either not set or not found on disk, create a key
|
||||
if (remote.apikey && (!remote.localApiKey || !(await apiKeyService.findByKey(remote.localApiKey)))) {
|
||||
minigraphLogger.debug('Creating local API key for Connect');
|
||||
// Create local API key
|
||||
const localApiKey = await apiKeyService.createLocalConnectApiKey();
|
||||
|
||||
if (localApiKey?.key) {
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: {
|
||||
localApiKey: localApiKey.key,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error('Failed to create local API key - no key returned');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,34 +2,47 @@ import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
|
||||
|
||||
import { ensureDir, ensureDirSync } 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 { Resource, Role } from '@app/graphql/generated/api/types';
|
||||
import { getters, store } from '@app/store';
|
||||
import { updateUserConfig } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store', () => ({
|
||||
getters: {
|
||||
config: vi.fn(),
|
||||
paths: vi.fn(),
|
||||
},
|
||||
store: {
|
||||
dispatch: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/store/modules/config', () => ({
|
||||
updateUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', async () => ({
|
||||
readdir: vi.fn(),
|
||||
readdir: vi.fn().mockResolvedValue(['key1.json', 'key2.json', 'notakey.txt']),
|
||||
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(),
|
||||
ensureDirSync: vi.fn(),
|
||||
@@ -51,6 +64,7 @@ describe('ApiKeyService', () => {
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -60,6 +74,12 @@ describe('ApiKeyService', () => {
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: ['read'],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -87,6 +107,15 @@ describe('ApiKeyService', () => {
|
||||
'auth-keys': mockBasePath,
|
||||
} as any);
|
||||
|
||||
// Set up default config mock
|
||||
vi.mocked(getters.config).mockReturnValue({
|
||||
status: FileLoadStatus.LOADED,
|
||||
remote: {
|
||||
apikey: null,
|
||||
localApiKey: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Mock ensureDir
|
||||
vi.mocked(ensureDir).mockResolvedValue();
|
||||
|
||||
@@ -116,13 +145,11 @@ describe('ApiKeyService', () => {
|
||||
expect(ensureDirSync).toHaveBeenCalledWith(mockBasePath);
|
||||
});
|
||||
|
||||
it('should return correct paths', async () => {
|
||||
it('should return correct base path', 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`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,10 +192,105 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLocalApiKeyForConnectIfNecessary', () => {
|
||||
beforeEach(() => {
|
||||
// Mock config getter
|
||||
vi.mocked(getters.config).mockReturnValue({
|
||||
status: FileLoadStatus.LOADED,
|
||||
remote: {
|
||||
apikey: 'remote-api-key',
|
||||
localApiKey: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Mock store dispatch
|
||||
vi.mocked(store.dispatch).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it('should not create key if config is not loaded', async () => {
|
||||
vi.mocked(getters.config).mockReturnValue({
|
||||
status: FileLoadStatus.UNLOADED,
|
||||
} as any);
|
||||
|
||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Config file not loaded, cannot create local API key'
|
||||
);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create key if remote apikey is not set', async () => {
|
||||
vi.mocked(getters.config).mockReturnValue({
|
||||
status: FileLoadStatus.LOADED,
|
||||
remote: {
|
||||
apikey: null,
|
||||
localApiKey: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create key if Connect key already exists', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(mockApiKeyWithSecret);
|
||||
|
||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new Connect key and update config', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
|
||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith(
|
||||
'Connect',
|
||||
'API key for Connect user',
|
||||
[Role.CONNECT],
|
||||
true
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
updateUserConfig({
|
||||
remote: {
|
||||
localApiKey: mockApiKeyWithSecret.key,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if key creation fails', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue({
|
||||
...mockApiKeyWithSecret,
|
||||
key: '', // Empty string instead of undefined/null
|
||||
} as ApiKeyWithSecret);
|
||||
|
||||
await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).rejects.toThrow(
|
||||
'Failed to create local API key'
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to create local API key - no key returned'
|
||||
);
|
||||
expect(store.dispatch).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));
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
mockApiKeyWithSecret,
|
||||
{ ...mockApiKeyWithSecret, id: 'second-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
vi.mocked(ApiKeySchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKey),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findAll();
|
||||
|
||||
@@ -178,65 +300,71 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockRejectedValue(new Error('Read error'));
|
||||
await expect(apiKeyService.onModuleInit()).rejects.toThrow('Read error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return API key by id', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
it('should return API key by id when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
vi.mocked(ApiKeySchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKey),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
const result = await apiKeyService.findById(mockApiKeyWithSecret.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);
|
||||
it('should return null if API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, id: 'different-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
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');
|
||||
it('should throw error if schema validation fails', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
await expect(apiKeyService.findById(mockApiKey.id)).rejects.toThrow(
|
||||
'Failed to read API key'
|
||||
vi.mocked(ApiKeySchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new ZodError([
|
||||
{
|
||||
code: 'custom',
|
||||
path: ['roles'],
|
||||
message: 'Invalid role',
|
||||
},
|
||||
]);
|
||||
}),
|
||||
} as any);
|
||||
|
||||
expect(() => apiKeyService.findById(mockApiKeyWithSecret.id)).toThrow(
|
||||
'Invalid API key structure'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
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' });
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret('non-existent-id');
|
||||
|
||||
@@ -244,33 +372,28 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockImplementation(async () => {
|
||||
throw new Error('Invalid API key structure');
|
||||
});
|
||||
|
||||
await expect(apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id)).rejects.toThrow(
|
||||
'Invalid API key data structure'
|
||||
);
|
||||
await expect(apiKeyService.onModuleInit()).rejects.toThrow('Invalid API key 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'
|
||||
);
|
||||
it('should throw error on file read error', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockRejectedValue(new Error('Read failed'));
|
||||
await expect(apiKeyService.onModuleInit()).rejects.toThrow('Read failed');
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
differentKey,
|
||||
mockApiKeyWithSecret,
|
||||
]);
|
||||
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
@@ -279,18 +402,14 @@ describe('ApiKeyService', () => {
|
||||
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.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-1' },
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-2' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation((data) => data),
|
||||
@@ -299,27 +418,14 @@ describe('ApiKeyService', () => {
|
||||
const result = await apiKeyService.findByKey('non-existent-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should return null if an API key is invalid', async () => {
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('Read error'));
|
||||
it('Should throw error when API key is corrupted', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockRejectedValue(
|
||||
new Error('Authentication system error: Corrupted key file')
|
||||
);
|
||||
|
||||
await expect(apiKeyService.findByKey(mockApiKeyWithSecret.key)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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(
|
||||
await expect(apiKeyService.onModuleInit()).rejects.toThrow(
|
||||
'Authentication system error: Corrupted key file'
|
||||
);
|
||||
});
|
||||
@@ -465,4 +571,89 @@ describe('ApiKeyService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAllFromDisk', () => {
|
||||
it('should load and parse all JSON files', async () => {
|
||||
const mockFiles = ['key1.json', 'key2.json', 'notakey.txt'];
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(mockFiles as any);
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKeyWithSecret),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(mockApiKeyWithSecret);
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw error when directory read fails', async () => {
|
||||
vi.mocked(readdir).mockRejectedValue(new Error('Directory read failed'));
|
||||
|
||||
await expect(apiKeyService.loadAllFromDisk()).rejects.toThrow('Failed to list API keys');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to read API key directory')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadApiKeyFile', () => {
|
||||
it('should load and parse a valid API key file', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockReturnValue(mockApiKeyWithSecret),
|
||||
} as any);
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('test.json');
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8');
|
||||
});
|
||||
|
||||
it('should return null when file read fails', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('File read failed'));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('test.json');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error reading API key file test.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on corrupted JSON', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await expect(apiKeyService['loadApiKeyFile']('test.json')).rejects.toThrow(
|
||||
'Authentication system error: Corrupted key file'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on invalid API key structure', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ invalid: 'structure' }));
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
parse: vi.fn().mockImplementation(() => {
|
||||
throw new ZodError([
|
||||
{
|
||||
code: 'custom',
|
||||
path: [],
|
||||
message: 'Invalid structure',
|
||||
},
|
||||
]);
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await expect(apiKeyService['loadApiKeyFile']('test.json')).rejects.toThrow(
|
||||
'Invalid API key structure'
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid API key structure in file test.json'),
|
||||
expect.any(Array)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { watch } from 'chokidar';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -10,22 +11,40 @@ 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';
|
||||
import { getters, store } from '@app/store';
|
||||
import { updateUserConfig } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
protected readonly basePath: string;
|
||||
protected readonly keyFile: (id: string) => string;
|
||||
protected memoryApiKeys = new Map<string, ApiKeyWithSecret>();
|
||||
protected memoryApiKeys: Array<ApiKeyWithSecret> = [];
|
||||
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`);
|
||||
ensureDirSync(this.basePath);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.memoryApiKeys = await this.loadAllFromDisk();
|
||||
await this.createLocalApiKeyForConnectIfNecessary();
|
||||
this.setupWatch();
|
||||
}
|
||||
|
||||
public findAll(): ApiKey[] {
|
||||
return this.memoryApiKeys.map((key) => ApiKeySchema().parse(key));
|
||||
}
|
||||
|
||||
private setupWatch() {
|
||||
watch(this.basePath, { ignoreInitial: false }).on('change', async (path) => {
|
||||
this.logger.debug(`API key changed: ${path}`);
|
||||
this.memoryApiKeys = [];
|
||||
this.memoryApiKeys = await this.loadAllFromDisk();
|
||||
});
|
||||
}
|
||||
|
||||
private sanitizeName(name: string): string {
|
||||
if (/^[\p{L}\p{N} ]+$/u.test(name)) {
|
||||
return name;
|
||||
@@ -70,6 +89,7 @@ export class ApiKeyService {
|
||||
|
||||
apiKey.description = description;
|
||||
apiKey.roles = roles;
|
||||
apiKey.permissions = [];
|
||||
// Update createdAt date
|
||||
apiKey.createdAt = new Date().toISOString();
|
||||
|
||||
@@ -78,134 +98,111 @@ export class ApiKeyService {
|
||||
return apiKey as ApiKeyWithSecret;
|
||||
}
|
||||
|
||||
async findAll(): Promise<ApiKey[]> {
|
||||
try {
|
||||
const files = await readdir(this.basePath);
|
||||
const apiKeys: ApiKey[] = [];
|
||||
private async createLocalApiKeyForConnectIfNecessary() {
|
||||
if (getters.config().status !== FileLoadStatus.LOADED) {
|
||||
this.logger.error('Config file not loaded, cannot create local API key');
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const content = await readFile(this.keyFile(id), 'utf8');
|
||||
const { remote } = getters.config();
|
||||
// If the remote API Key is set and the local key is either not set or not found on disk, create a key
|
||||
if (remote.apikey && (!remote.localApiKey || !(await this.findByKey(remote.localApiKey)))) {
|
||||
const hasExistingKey = this.findByField('name', 'Connect');
|
||||
|
||||
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;
|
||||
if (hasExistingKey) {
|
||||
return;
|
||||
}
|
||||
} 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}`);
|
||||
// Create local API key
|
||||
const localApiKey = await this.create(
|
||||
'Connect',
|
||||
'API key for Connect user',
|
||||
[Role.CONNECT],
|
||||
true
|
||||
);
|
||||
|
||||
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)}`
|
||||
if (localApiKey?.key) {
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: {
|
||||
localApiKey: localApiKey.key,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.logger.error('Failed to create local API key - no key returned');
|
||||
throw new Error('Failed to create local API key');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async findByIdWithSecret(id: string): Promise<ApiKeyWithSecret | null> {
|
||||
try {
|
||||
const content = await readFile(this.keyFile(id), 'utf8');
|
||||
const apiKey = JSON.parse(content);
|
||||
async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
|
||||
const files = await readdir(this.basePath).catch((error) => {
|
||||
this.logger.error(`Failed to read API key directory: ${error}`);
|
||||
throw new Error('Failed to list API keys');
|
||||
});
|
||||
|
||||
return ApiKeyWithSecretSchema().parse(apiKey);
|
||||
const apiKeys: ApiKeyWithSecret[] = [];
|
||||
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const apiKey = await this.loadApiKeyFile(file);
|
||||
|
||||
if (apiKey) {
|
||||
apiKeys.push(apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKeyWithSecret | null> {
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
|
||||
return ApiKeyWithSecretSchema().parse(JSON.parse(content));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error('Authentication system error: Corrupted key file');
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error(`Invalid API key structure in file ${file}`, error.errors);
|
||||
throw new Error('Invalid API key structure');
|
||||
}
|
||||
|
||||
this.logger.warn(`Error reading API key file ${file}: ${error}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
findById(id: string): ApiKey | null {
|
||||
try {
|
||||
const key = this.findByField('id', id);
|
||||
|
||||
if (key) {
|
||||
return ApiKeySchema().parse(key);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error('Invalid API key data structure', error);
|
||||
throw new GraphQLError('Invalid API key data structure');
|
||||
this.logger.error('Invalid API key structure', error.errors);
|
||||
throw new Error('Invalid API key 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');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByField(field: keyof ApiKeyWithSecret, value: string): Promise<ApiKeyWithSecret | null> {
|
||||
public findByIdWithSecret(id: string): ApiKeyWithSecret | null {
|
||||
return this.findByField('id', id);
|
||||
}
|
||||
|
||||
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||
if (!value) return null;
|
||||
|
||||
try {
|
||||
const files = await readdir(this.basePath);
|
||||
|
||||
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);
|
||||
|
||||
if (field === 'key') {
|
||||
if (crypto.timingSafeEqual(Buffer.from(apiKey[field]), Buffer.from(value))) {
|
||||
apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST);
|
||||
return apiKey;
|
||||
}
|
||||
} else if (apiKey[field] === value) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.memoryApiKeys.find((key) => key[field] === value) ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
@@ -264,7 +261,10 @@ export class ApiKeyService {
|
||||
return acc;
|
||||
}, {} as ApiKeyWithSecret);
|
||||
|
||||
await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(sortedApiKey, null, 2));
|
||||
await writeFile(
|
||||
join(this.basePath, `${validatedApiKey.id}.json`),
|
||||
JSON.stringify(sortedApiKey, null, 2)
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error('Invalid API key structure', error.errors);
|
||||
@@ -280,7 +280,6 @@ export class ApiKeyService {
|
||||
public getPaths() {
|
||||
return {
|
||||
basePath: this.basePath,
|
||||
keyFile: this.keyFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { Resource, Role } from '@app/graphql/generated/api/types';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -32,6 +32,12 @@ describe('AuthService', () => {
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: ['read'],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import type { UserAccount } from '@app/graphql/generated/api/types';
|
||||
import type { Permission, UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store';
|
||||
import { handleAuthError } from '@app/utils';
|
||||
import { batchProcess, handleAuthError } from '@app/utils';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { CookieService } from './cookie.service';
|
||||
@@ -29,8 +29,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
apiKeyEntity.roles ??= [];
|
||||
|
||||
await this.syncApiKeyRoles(apiKeyEntity.id, apiKeyEntity.roles);
|
||||
await this.syncApiKeyPermissions(apiKeyEntity.id, apiKeyEntity.permissions);
|
||||
this.logger.debug(
|
||||
`Validating API key with roles: ${JSON.stringify(
|
||||
await this.authzService.getRolesForUser(apiKeyEntity.id)
|
||||
@@ -42,6 +42,7 @@ export class AuthService {
|
||||
name: apiKeyEntity.name,
|
||||
description: apiKeyEntity.description ?? `API Key ${apiKeyEntity.name}`,
|
||||
roles: apiKeyEntity.roles,
|
||||
permissions: apiKeyEntity.permissions,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to validate API key', error);
|
||||
@@ -95,23 +96,30 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public async addRoleToUser(userId: string, role: Role): Promise<boolean> {
|
||||
if (!userId || !role) {
|
||||
throw new UnauthorizedException('User ID and role are required');
|
||||
}
|
||||
|
||||
public async syncApiKeyPermissions(apiKeyId: string, permissions: Array<Permission>): Promise<void> {
|
||||
try {
|
||||
const hasRole = await this.authzService.hasRoleForUser(userId, role);
|
||||
// Clear existing permissions first
|
||||
await this.authzService.deletePermissionsForUser(apiKeyId);
|
||||
|
||||
if (hasRole) {
|
||||
return true;
|
||||
// Create array of permission-action pairs for processing
|
||||
const permissionActions = permissions.flatMap((permission) =>
|
||||
(permission.actions || []).map((action) => ({
|
||||
resource: permission.resource,
|
||||
action,
|
||||
}))
|
||||
);
|
||||
|
||||
const { errors, errorOccured } = await batchProcess(
|
||||
permissionActions,
|
||||
({ resource, action }) =>
|
||||
this.authzService.addPermissionForUser(apiKeyId, resource, action)
|
||||
);
|
||||
|
||||
if (errorOccured) {
|
||||
this.logger.warn(`Some permissions failed to sync for API key ${apiKeyId}:`, errors);
|
||||
}
|
||||
|
||||
await this.authzService.addRoleForUser(userId, role);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to add role to user', error, { userId, role });
|
||||
handleAuthError(this.logger, 'Failed to sync permissions for API key', error, { apiKeyId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +231,7 @@ export class AuthService {
|
||||
description: 'Session receives administrator permissions',
|
||||
name: 'admin',
|
||||
roles: [Role.ADMIN],
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { Model as CasbinModel, Enforcer, newEnforcer, StringAdapter } from 'casbin';
|
||||
|
||||
@Injectable()
|
||||
export class CasbinService {
|
||||
private readonly logger = new Logger(CasbinService.name);
|
||||
private enforcer: Enforcer | null = null;
|
||||
|
||||
/**
|
||||
* Initializes a Casbin enforcer with the given model and policies.
|
||||
@@ -15,10 +16,9 @@ export class CasbinService {
|
||||
const casbinModel = new CasbinModel();
|
||||
casbinModel.loadModelFromText(model);
|
||||
const casbinPolicy = new StringAdapter(policy);
|
||||
|
||||
try {
|
||||
const enforcer = await newEnforcer(casbinModel, casbinPolicy);
|
||||
// enforcer.enableLog(true);
|
||||
enforcer.enableLog(true);
|
||||
|
||||
return enforcer;
|
||||
} catch (error: unknown) {
|
||||
|
||||
24
api/src/unraid-api/auth/user.decorator.ts
Normal file
24
api/src/unraid-api/auth/user.decorator.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { UserSchema } from '@app/graphql/generated/api/operations';
|
||||
import { UserAccount } from '@app/graphql/generated/api/types';
|
||||
|
||||
export const GraphqlUser = createParamDecorator<null, any, UserAccount>(
|
||||
(data: null, context: ExecutionContext): UserAccount => {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const user = ctx.getContext().req.user;
|
||||
|
||||
const result = UserSchema().safeParse(user);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Invalid user account structure');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} else {
|
||||
return context.switchToHttp().getRequest().user;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -101,22 +101,6 @@ describe('AuthResolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -67,19 +67,6 @@ export class AuthResolver {
|
||||
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,
|
||||
|
||||
59
api/src/unraid-api/graph/resolvers/me/me.resolver.spec.ts
Normal file
59
api/src/unraid-api/graph/resolvers/me/me.resolver.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Me, Resource, Role, UserAccount } from '@app/graphql/generated/api/types';
|
||||
|
||||
import { MeResolver } from './me.resolver';
|
||||
|
||||
describe('MeResolver', () => {
|
||||
let resolver: MeResolver;
|
||||
let authzService: AuthZService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MeResolver,
|
||||
{
|
||||
provide: AuthZService,
|
||||
useValue: {
|
||||
checkPermission: vi.fn().mockImplementation((action, resource, possession) => {
|
||||
// Return false for specific test scenarios
|
||||
if (action === 'write' && resource === Resource.ME) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<MeResolver>(MeResolver);
|
||||
authzService = module.get<AuthZService>(AuthZService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return user information', async () => {
|
||||
const mockUser: UserAccount = {
|
||||
id: 'test-id',
|
||||
name: 'Test User',
|
||||
description: 'Test Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.ME,
|
||||
actions: ['read'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await resolver.me(mockUser);
|
||||
|
||||
expect(result).toBe(mockUser);
|
||||
});
|
||||
});
|
||||
22
api/src/unraid-api/graph/resolvers/me/me.resolver.ts
Normal file
22
api/src/unraid-api/graph/resolvers/me/me.resolver.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { UserAccount } from '@app/graphql/generated/api/types';
|
||||
import { Me, Resource } from '@app/graphql/generated/api/types';
|
||||
import { GraphqlUser } from '@app/unraid-api/auth/user.decorator';
|
||||
|
||||
@Resolver()
|
||||
export class MeResolver {
|
||||
constructor() {}
|
||||
|
||||
@Query()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.ME,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async me(@GraphqlUser() user: UserAccount): Promise<Me> {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { RegistrationResolver } from './registration/registration.resolver';
|
||||
import { ServerResolver } from './servers/server.resolver';
|
||||
import { VarsResolver } from './vars/vars.resolver';
|
||||
import { VmsResolver } from './vms/vms.resolver';
|
||||
import { MeResolver } from './me/me.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
@@ -40,6 +41,7 @@ import { VmsResolver } from './vms/vms.resolver';
|
||||
VarsResolver,
|
||||
VmsResolver,
|
||||
NotificationsService,
|
||||
MeResolver,
|
||||
],
|
||||
exports: [AuthModule, AuthResolver],
|
||||
})
|
||||
|
||||
@@ -64,6 +64,7 @@ async function renderSandboxPage(service: GraphQLServerContext) {
|
||||
|
||||
if (!serverListener) return preconditionFailed('serverListener');
|
||||
if (!serverListener.renderLandingPage) return preconditionFailed('renderLandingPage');
|
||||
|
||||
return serverListener.renderLandingPage();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import strftime from 'strftime';
|
||||
|
||||
import { UserSchema } from '@app/graphql/generated/api/operations';
|
||||
|
||||
import { UserAccount } from './graphql/generated/api/types';
|
||||
import { FastifyRequest } from './types/fastify';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import nodeExternals from 'rollup-plugin-node-externals';
|
||||
@@ -29,17 +28,17 @@ export default defineConfig(({ mode }) => {
|
||||
appPath: 'src/index.ts',
|
||||
tsCompiler: 'swc',
|
||||
swcOptions: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2024',
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
},
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2024',
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initAppOnBoot: true,
|
||||
})
|
||||
@@ -147,7 +146,11 @@ export default defineConfig(({ mode }) => {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
clearMocks: true,
|
||||
setupFiles: ['src/__test__/setup/env-setup.ts', 'src/__test__/setup/keyserver-mock.ts'],
|
||||
setupFiles: [
|
||||
'reflect-metadata',
|
||||
'src/__test__/setup/env-setup.ts',
|
||||
'src/__test__/setup/keyserver-mock.ts',
|
||||
],
|
||||
exclude: ['**/deploy/**', '**/node_modules/**'],
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
|
||||
Reference in New Issue
Block a user