mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: add api key creation logic
This commit is contained in:
@@ -1,457 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Returns default permissions 1`] = `
|
||||
RolesBuilder {
|
||||
"_grants": {
|
||||
"admin": {
|
||||
"$extend": [
|
||||
"guest",
|
||||
],
|
||||
"apikey": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"array": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"cloud": {
|
||||
"read:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"config": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
"update:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"connect": {
|
||||
"read:own": [
|
||||
"*",
|
||||
],
|
||||
"update:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"cpu": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"crash-reporting-enabled": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"customizations": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"device": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"device/unassigned": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"disk": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"disk/settings": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"display": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"docker/container": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"docker/network": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"flash": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"info": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"license-key": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"logs": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"machine-id": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"memory": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"notifications": {
|
||||
"create:any": [
|
||||
"*",
|
||||
],
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"online": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"os": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"owner": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"parity-history": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"permission": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"registration": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"servers": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"service": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"service/emhttpd": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"service/unraid-api": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"services": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"share": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"software-versions": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"unraid-version": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"uptime": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"user": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vars": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vms": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vms/domain": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vms/network": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
},
|
||||
"guest": {
|
||||
"me": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"welcome": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
},
|
||||
"my_servers": {
|
||||
"$extend": [
|
||||
"guest",
|
||||
],
|
||||
"array": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"config": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"connect": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"connect/dynamic-remote-access": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
"update:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"customizations": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"dashboard": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"display": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"docker": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"docker/container": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"info": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"logs": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"network": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"notifications": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"services": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"unraid-version": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vars": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vms": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vms/domain": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
},
|
||||
"notifier": {
|
||||
"$extend": [
|
||||
"guest",
|
||||
],
|
||||
"notifications": {
|
||||
"create:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
},
|
||||
"upc": {
|
||||
"$extend": [
|
||||
"guest",
|
||||
],
|
||||
"apikey": {
|
||||
"read:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"cloud": {
|
||||
"read:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"config": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
"update:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"connect": {
|
||||
"read:own": [
|
||||
"*",
|
||||
],
|
||||
"update:own": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"crash-reporting-enabled": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"customizations": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"disk": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"display": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"flash": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"info": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"logs": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"notifications": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
"update:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"os": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"owner": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"permission": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"registration": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"servers": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
"vars": {
|
||||
"read:any": [
|
||||
"*",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"_isLocked": false,
|
||||
}
|
||||
`;
|
||||
@@ -12,7 +12,7 @@ try {
|
||||
const shellToUse = execSync('which bash').toString().trim();
|
||||
await CommandFactory.run(CliModule, {
|
||||
cliName: 'unraid-api',
|
||||
logger: false,
|
||||
logger: false, // new LogService(), - enable this to see nest initialization issues
|
||||
completion: {
|
||||
fig: true,
|
||||
cmd: 'unraid-api',
|
||||
|
||||
@@ -823,7 +823,7 @@ 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(),
|
||||
actions: z.array(z.string()),
|
||||
resource: ResourceSchema
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export type ApiKey = {
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions?: Maybe<Array<Permission>>;
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export type Pci = {
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions?: Maybe<Array<Scalars['String']['output']>>;
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -2056,7 +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>;
|
||||
permissions?: Resolver<Array<ResolversTypes['Permission']>, ParentType, ContextType>;
|
||||
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2653,7 +2653,7 @@ export type PciResolvers<ContextType = Context, ParentType extends ResolversPare
|
||||
}>;
|
||||
|
||||
export type PermissionResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Permission'] = ResolversParentTypes['Permission']> = ResolversObject<{
|
||||
actions?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
|
||||
actions?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
resource?: Resolver<ResolversTypes['Resource'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type Permission {
|
||||
resource: Resource!
|
||||
actions: [String!]
|
||||
actions: [String!]!
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { updateUserConfig } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { environment } from '@app/environment';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store', () => ({
|
||||
@@ -84,6 +85,7 @@ describe('ApiKeyService', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
environment.IS_MAIN_PROCESS = true;
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Create mock logger methods
|
||||
@@ -159,7 +161,7 @@ describe('ApiKeyService', () => {
|
||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||
const name = 'Test API Key';
|
||||
|
||||
const result = await apiKeyService.create(name, description ?? '', roles);
|
||||
const result = await apiKeyService.create({ name, description: description ?? '', roles });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id,
|
||||
@@ -176,17 +178,23 @@ describe('ApiKeyService', () => {
|
||||
it('should validate input parameters', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey');
|
||||
|
||||
await expect(apiKeyService.create('', 'desc', [Role.GUEST])).rejects.toThrow(
|
||||
await expect(
|
||||
apiKeyService.create({ name: '', description: 'desc', roles: [Role.GUEST] })
|
||||
).rejects.toThrow(
|
||||
'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)'
|
||||
);
|
||||
|
||||
await expect(apiKeyService.create('name', 'desc', [])).rejects.toThrow(
|
||||
'At least one role must be specified'
|
||||
);
|
||||
await expect(
|
||||
apiKeyService.create({ name: 'name', description: 'desc', roles: [] })
|
||||
).rejects.toThrow('At least one role must be specified');
|
||||
|
||||
await expect(apiKeyService.create('name', 'desc', ['invalid_role' as Role])).rejects.toThrow(
|
||||
'Invalid role specified'
|
||||
);
|
||||
await expect(
|
||||
apiKeyService.create({
|
||||
name: 'name',
|
||||
description: 'desc',
|
||||
roles: ['invalid_role' as Role],
|
||||
})
|
||||
).rejects.toThrow('Invalid role specified');
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -248,12 +256,12 @@ describe('ApiKeyService', () => {
|
||||
|
||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith(
|
||||
'Connect',
|
||||
'API key for Connect user',
|
||||
[Role.CONNECT],
|
||||
true
|
||||
);
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: 'Connect',
|
||||
description: 'API key for Connect user',
|
||||
roles: [Role.CONNECT],
|
||||
overwrite: true,
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
updateUserConfig({
|
||||
remote: {
|
||||
@@ -263,15 +271,12 @@ describe('ApiKeyService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if key creation fails', async () => {
|
||||
it('should log an 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);
|
||||
vi.spyOn(apiKeyService, 'createLocalConnectApiKey').mockResolvedValue(null);
|
||||
|
||||
await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).rejects.toThrow(
|
||||
'Failed to create local API key'
|
||||
await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).resolves.toBe(
|
||||
undefined
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to create local API key - no key returned'
|
||||
@@ -431,60 +436,6 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByKey', () => {
|
||||
it('should return UserAccount when API key exists', async () => {
|
||||
const findByKeySpy = vi
|
||||
.spyOn(apiKeyService, 'findByKey')
|
||||
.mockResolvedValue(mockApiKeyWithSecret);
|
||||
const result = await apiKeyService.findOneByKey('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockApiKeyWithSecret.id,
|
||||
name: mockApiKeyWithSecret.name,
|
||||
description: mockApiKeyWithSecret.description,
|
||||
roles: mockApiKeyWithSecret.roles,
|
||||
});
|
||||
expect(findByKeySpy).toHaveBeenCalledWith('test-api-key');
|
||||
});
|
||||
|
||||
it('should use default description when none provided', async () => {
|
||||
const keyWithoutDesc = { ...mockApiKeyWithSecret, description: null };
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(keyWithoutDesc);
|
||||
const result = await apiKeyService.findOneByKey('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: keyWithoutDesc.id,
|
||||
name: keyWithoutDesc.name,
|
||||
description: `API Key ${keyWithoutDesc.name}`,
|
||||
roles: keyWithoutDesc.roles,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(null);
|
||||
|
||||
await expect(apiKeyService.findOneByKey('non-existent-key')).rejects.toThrow(
|
||||
'API key not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(null);
|
||||
|
||||
await expect(apiKeyService.findOneByKey('non-existent-key')).rejects.toThrow(
|
||||
'API key not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when unexpected error occurs', async () => {
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(apiKeyService.findOneByKey('test-api-key')).rejects.toThrow(
|
||||
'Failed to retrieve user account'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveApiKey', () => {
|
||||
it('should save API key to file', async () => {
|
||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||
|
||||
@@ -6,11 +6,20 @@ import { join } from 'path';
|
||||
import { watch } from 'chokidar';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { environment } from '@app/environment';
|
||||
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
||||
import { ApiKey, ApiKeyWithSecret, Role, UserAccount } from '@app/graphql/generated/api/types';
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
Resource,
|
||||
Role,
|
||||
UserAccount,
|
||||
} 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';
|
||||
@@ -55,12 +64,52 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
roles: Role[],
|
||||
overwrite: boolean = false
|
||||
): Promise<ApiKeyWithSecret> {
|
||||
public getAllValidPermissions(): Permission[] {
|
||||
return Object.values(Resource).map((res) => ({
|
||||
resource: res,
|
||||
actions: Object.values(AuthActionVerb),
|
||||
}));
|
||||
}
|
||||
|
||||
public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] {
|
||||
return permissions.reduce<Array<Permission>>((acc, permission) => {
|
||||
const [resource, action] = permission.split(':');
|
||||
const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null;
|
||||
const validatedAction =
|
||||
AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null;
|
||||
if (validatedAction && validatedResource) {
|
||||
const existingEntry = acc.find((p) => p.resource === validatedResource);
|
||||
if (existingEntry) {
|
||||
existingEntry.actions.push(validatedAction);
|
||||
} else {
|
||||
acc.push({ resource: validatedResource, actions: [validatedAction] });
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Invalid permission / action specified: ${permission}:${action}`);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Array<Permission>);
|
||||
}
|
||||
|
||||
public convertRolesStringArrayToRoles(roles: string[]): Role[] {
|
||||
return roles
|
||||
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async create({
|
||||
name,
|
||||
description,
|
||||
roles,
|
||||
permissions,
|
||||
overwrite = false,
|
||||
}: {
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
const trimmedName = name?.trim();
|
||||
const sanitizedName = this.sanitizeName(trimmedName);
|
||||
|
||||
@@ -89,7 +138,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
|
||||
apiKey.description = description;
|
||||
apiKey.roles = roles;
|
||||
apiKey.permissions = [];
|
||||
apiKey.permissions = permissions ?? [];
|
||||
// Update createdAt date
|
||||
apiKey.createdAt = new Date().toISOString();
|
||||
|
||||
@@ -98,28 +147,26 @@ export class ApiKeyService implements OnModuleInit {
|
||||
return apiKey as ApiKeyWithSecret;
|
||||
}
|
||||
|
||||
private async createLocalApiKeyForConnectIfNecessary() {
|
||||
private async createLocalApiKeyForConnectIfNecessary(): Promise<void> {
|
||||
if (!environment.IS_MAIN_PROCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getters.config().status !== FileLoadStatus.LOADED) {
|
||||
this.logger.error('Config file not loaded, cannot create local API key');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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)))) {
|
||||
if (remote.apikey && (!remote.localApiKey || !this.findByKey(remote.localApiKey))) {
|
||||
const hasExistingKey = this.findByField('name', 'Connect');
|
||||
|
||||
if (hasExistingKey) {
|
||||
return;
|
||||
}
|
||||
// Create local API key
|
||||
const localApiKey = await this.create(
|
||||
'Connect',
|
||||
'API key for Connect user',
|
||||
[Role.CONNECT],
|
||||
true
|
||||
);
|
||||
const localApiKey = await this.createLocalConnectApiKey();
|
||||
|
||||
if (localApiKey?.key) {
|
||||
store.dispatch(
|
||||
@@ -131,7 +178,6 @@ export class ApiKeyService implements OnModuleInit {
|
||||
);
|
||||
} else {
|
||||
this.logger.error('Failed to create local API key - no key returned');
|
||||
throw new Error('Failed to create local API key');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,10 +192,14 @@ export class ApiKeyService implements OnModuleInit {
|
||||
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const apiKey = await this.loadApiKeyFile(file);
|
||||
try {
|
||||
const apiKey = await this.loadApiKeyFile(file);
|
||||
|
||||
if (apiKey) {
|
||||
apiKeys.push(apiKey);
|
||||
if (apiKey) {
|
||||
apiKeys.push(apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error loading API key from file ${file}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +213,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
return ApiKeyWithSecretSchema().parse(JSON.parse(content));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
this.logger.error(`Corrupted key file: ${file}`);
|
||||
throw new Error('Authentication system error: Corrupted key file');
|
||||
}
|
||||
|
||||
@@ -201,53 +252,29 @@ export class ApiKeyService implements OnModuleInit {
|
||||
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||
if (!value) return null;
|
||||
|
||||
try {
|
||||
return this.memoryApiKeys.find((key) => key[field] === value) ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to read API key storage: ${error}`);
|
||||
throw new GraphQLError('Authentication system unavailable - please see logs');
|
||||
}
|
||||
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
|
||||
}
|
||||
|
||||
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
|
||||
findByKey(key: string): ApiKeyWithSecret | null {
|
||||
return this.findByField('key', key);
|
||||
}
|
||||
|
||||
async findOneByKey(apiKey: string): Promise<UserAccount | null> {
|
||||
try {
|
||||
const key = await this.findByKey(apiKey);
|
||||
|
||||
if (!key) {
|
||||
throw new GraphQLError('API key not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
description: key.description ?? `API Key ${key.name}`,
|
||||
name: key.name,
|
||||
roles: key.roles,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user by key: ${error}`);
|
||||
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new GraphQLError('Failed to retrieve user account');
|
||||
}
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret> {
|
||||
return await this.create('Connect', 'API key for Connect user', [Role.CONNECT], true);
|
||||
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
|
||||
try {
|
||||
return await this.create({
|
||||
name: 'Connect',
|
||||
description: 'API key for Connect user',
|
||||
roles: [Role.CONNECT],
|
||||
overwrite: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create local API key for Connect user: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||
|
||||
82
api/src/unraid-api/cli/apikey/add-api-key.questions.ts
Normal file
82
api/src/unraid-api/cli/apikey/add-api-key.questions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ChoicesFor, Question, QuestionSet, WhenFor } from 'nest-commander';
|
||||
|
||||
|
||||
|
||||
import { Permission, Role } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@QuestionSet({ name: 'add-api-key' })
|
||||
export class AddApiKeyQuestionSet {
|
||||
constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly logger: LogService
|
||||
) {}
|
||||
|
||||
static name = 'add-api-key';
|
||||
|
||||
@Question({
|
||||
name: 'name',
|
||||
message: 'What is the name of the API key?',
|
||||
})
|
||||
parseName(val: string) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Question({
|
||||
message: 'Enter a description for your key, this will help you identify the key later',
|
||||
name: 'description',
|
||||
})
|
||||
parseDescription(val: string) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Question({
|
||||
name: 'roles',
|
||||
type: 'checkbox',
|
||||
message: 'Choose the roles for the API key',
|
||||
})
|
||||
parseRoles(val: string[]): Role[] {
|
||||
return this.apiKeyService.convertRolesStringArrayToRoles(val);
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'roles' })
|
||||
async getRoles() {
|
||||
return Object.values(Role);
|
||||
}
|
||||
|
||||
@Question({
|
||||
name: 'permissions',
|
||||
type: 'checkbox',
|
||||
message: 'Choose the permissions for the API key',
|
||||
})
|
||||
parsePermissions(val: string[]): Permission[] {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(val);
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'permissions' })
|
||||
async getPermissions() {
|
||||
return this.apiKeyService
|
||||
.getAllValidPermissions()
|
||||
.map((permission) => permission.actions.map((action) => `${permission.resource}:${action}`))
|
||||
.flat();
|
||||
}
|
||||
|
||||
@Question({
|
||||
name: 'overwrite',
|
||||
type: 'confirm',
|
||||
message: 'An API key with this name already exists, do you want to overwrite it?',
|
||||
})
|
||||
parseOverwrite(val: string) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@WhenFor({ name: 'overwrite' })
|
||||
shouldAskOverwrite(options: { name: string }): boolean {
|
||||
return Boolean(this.apiKeyService.findByKey(options.name));
|
||||
}
|
||||
}
|
||||
129
api/src/unraid-api/cli/apikey/api-key.command.ts
Normal file
129
api/src/unraid-api/cli/apikey/api-key.command.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
|
||||
|
||||
import { Permission, Resource, Role } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||
|
||||
interface KeyOptions {
|
||||
name: string;
|
||||
create: boolean;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[];
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'apikey',
|
||||
description: `Create / Fetch Connect API Keys - use --create with no arguments for a creation wizard`,
|
||||
})
|
||||
export class ApiKeyCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly inquirerService: InquirerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--name <name>',
|
||||
description: 'Name of the key',
|
||||
})
|
||||
parseName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--create',
|
||||
description: 'Create a key if not found',
|
||||
})
|
||||
parseCreate(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-r, --roles <roles>',
|
||||
description: `Comma-separated list of roles (${Object.values(Role).join(',')})`,
|
||||
})
|
||||
parseRoles(roles: string): Role[] {
|
||||
if (!roles) return [Role.GUEST];
|
||||
const validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role);
|
||||
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
|
||||
|
||||
if (validRequestedRoles.length === 0) {
|
||||
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
|
||||
}
|
||||
|
||||
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
|
||||
|
||||
if (invalidRoles.length > 0) {
|
||||
this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validRequestedRoles;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-p, --permissions <permissions>',
|
||||
description: `Comma separated list of permissions to assign to the key (in the form of "resource:action")
|
||||
RESOURCES: ${Object.values(Resource).join(', ')}
|
||||
ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
})
|
||||
parsePermissions(permissions: string): Array<Permission> {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(
|
||||
permissions.split(',').filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --description <description>',
|
||||
description: 'Description to assign to the key',
|
||||
})
|
||||
parseDescription(description: string): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
async run(_: string[], options: KeyOptions = { create: false, name: '' }): Promise<void> {
|
||||
try {
|
||||
const key = this.apiKeyService.findByField('name', options.name);
|
||||
if (key) {
|
||||
this.logger.log(key.key);
|
||||
} else if (options.create) {
|
||||
options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options);
|
||||
this.logger.log('Creating API Key...' + JSON.stringify(options));
|
||||
|
||||
if (!options.roles && !options.permissions) {
|
||||
this.logger.error('Please add at least one role or permission to the key.');
|
||||
return;
|
||||
}
|
||||
if (options.roles?.length === 0 && options.permissions?.length === 0) {
|
||||
this.logger.error('Please add at least one role or permission to the key.');
|
||||
return;
|
||||
}
|
||||
const key = await this.apiKeyService.create({
|
||||
name: options.name,
|
||||
description: options.description || `CLI generated key: ${options.name}`,
|
||||
roles: options.roles,
|
||||
permissions: options.permissions,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
this.logger.log(key.key);
|
||||
} else {
|
||||
this.logger.log('No Key Found');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.error('API-Key Error: ' + error.message);
|
||||
} else {
|
||||
this.logger.error('Unexpected Error: ' + error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
|
||||
import { ConfigCommand } from '@app/unraid-api/cli/config.command';
|
||||
import { KeyCommand } from '@app/unraid-api/cli/key.command';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
|
||||
import { ReportCommand } from '@app/unraid-api/cli/report.command';
|
||||
@@ -20,6 +18,8 @@ import { VersionCommand } from '@app/unraid-api/cli/version.command';
|
||||
import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command';
|
||||
import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions';
|
||||
import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -33,7 +33,9 @@ import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.comman
|
||||
StopCommand,
|
||||
RestartCommand,
|
||||
ReportCommand,
|
||||
KeyCommand,
|
||||
ApiKeyService,
|
||||
ApiKeyCommand,
|
||||
AddApiKeyQuestionSet,
|
||||
SwitchEnvCommand,
|
||||
VersionCommand,
|
||||
StatusCommand,
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { cliLogger } from '@app/core/log';
|
||||
import { Role } from '@app/graphql/generated/api/types';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||
|
||||
interface KeyOptions {
|
||||
create: boolean;
|
||||
description?: string;
|
||||
roles?: Array<Role>;
|
||||
permissions?: Array<unknown>;
|
||||
}
|
||||
|
||||
@Command({ name: 'key', arguments: '<name>' })
|
||||
export class KeyCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(KeyCommand.name);
|
||||
|
||||
@Option({
|
||||
flags: '--create',
|
||||
description: 'Create a key if not found',
|
||||
})
|
||||
parseCreate(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-r, --roles <roles>',
|
||||
description: `Comma-separated list of roles (${Object.values(Role).join(',')})`,
|
||||
})
|
||||
parseRoles(roles: string): Role[] {
|
||||
if (!roles) return [Role.GUEST];
|
||||
const validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role);
|
||||
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
|
||||
|
||||
if (validRequestedRoles.length === 0) {
|
||||
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
|
||||
}
|
||||
|
||||
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
|
||||
|
||||
if (invalidRoles.length > 0) {
|
||||
cliLogger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validRequestedRoles;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --description <description>',
|
||||
description: 'Description to assign to the key',
|
||||
})
|
||||
parseDescription(description: string): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-p, --permissions <permissions>',
|
||||
description: 'Comma separated list of permissions to assign to the key',
|
||||
})
|
||||
parsePermissions(permissions: string) {
|
||||
throw new Error('Stub Method Until Permissions PR is merged');
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options?: KeyOptions): Promise<void> {
|
||||
console.log(options, passedParams);
|
||||
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
const name = passedParams[0];
|
||||
const create = options?.create ?? false;
|
||||
const key = await apiKeyService.findByField('name', name);
|
||||
if (key) {
|
||||
this.logger.log(key);
|
||||
} else if (create) {
|
||||
if (!options) {
|
||||
this.logger.error('Invalid Options for Create Flag');
|
||||
return;
|
||||
}
|
||||
if (options.roles?.length === 0 && options.permissions?.length === 0) {
|
||||
this.logger.error(
|
||||
'Please add at least one role or permission with --roles or --permissions'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = await apiKeyService.create(
|
||||
name,
|
||||
options.description || `CLI generated key: ${name}`,
|
||||
options.roles ?? [],
|
||||
true
|
||||
);
|
||||
|
||||
this.logger.log(key);
|
||||
} else {
|
||||
this.logger.log('No Key Found');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export class AddSSOUserCommand extends CommandRunner {
|
||||
async run(_input: string[], options: AddSSOUserCommandOptions): Promise<void> {
|
||||
try {
|
||||
options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options);
|
||||
console.log(options);
|
||||
if (options.disclaimer === 'y' && options.username) {
|
||||
await store.dispatch(loadConfigFile());
|
||||
store.dispatch(addSsoUser(options.username));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execa } from 'execa';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts';
|
||||
import { levels, LogLevel } from '@app/core/log';
|
||||
import { levels, type LogLevel } from '@app/core/log';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||
|
||||
interface StartCommandOptions {
|
||||
@@ -15,7 +15,7 @@ export class StartCommand extends CommandRunner {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_, options: StartCommandOptions): Promise<void> {
|
||||
async run(_: string[], options: StartCommandOptions): Promise<void> {
|
||||
this.logger.info('Starting the Unraid API');
|
||||
const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : '';
|
||||
const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [
|
||||
|
||||
Reference in New Issue
Block a user