feat: add api key creation logic

This commit is contained in:
Eli Bosley
2025-01-24 13:32:50 -05:00
parent f0395bdf47
commit a1351b0469
13 changed files with 338 additions and 707 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
type Permission {
resource: Resource!
actions: [String!]
actions: [String!]!
}
type ApiKey {

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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