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:
Eli Bosley
2025-01-23 15:37:15 -05:00
committed by GitHub
parent 3acc0dc9c0
commit a554bde5c2
35 changed files with 613 additions and 947 deletions

View File

@@ -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": "*"
}
]
}
}

View File

@@ -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"
}

View File

@@ -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"
]
}

View File

@@ -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"
},

View File

@@ -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();
});

View File

@@ -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';

View File

@@ -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,
};
};

View File

@@ -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,
},
};
};

View File

@@ -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';

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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);
};

View File

@@ -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)
})
}

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -13,7 +13,7 @@ type Me implements UserAccount {
name: String!
description: String!
roles: [Role!]!
permissions: JSON
permissions: [Permission!]
}
type Subscription {

View File

@@ -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!]
}

View File

@@ -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();

View File

@@ -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');
}
}
};

View File

@@ -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)
);
});
});
});

View File

@@ -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,
};
}
}

View File

@@ -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(),
};

View File

@@ -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: [],
};
}
}

View File

@@ -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) {

View 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;
}
}
);

View File

@@ -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 = {

View File

@@ -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,

View 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);
});
});

View 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;
}
}

View File

@@ -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],
})

View File

@@ -64,6 +64,7 @@ async function renderSandboxPage(service: GraphQLServerContext) {
if (!serverListener) return preconditionFailed('serverListener');
if (!serverListener.renderLandingPage) return preconditionFailed('renderLandingPage');
return serverListener.renderLandingPage();
}

View File

@@ -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';

View File

@@ -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',