diff --git a/api/src/__test__/core/__snapshots__/permissions.test.ts.snap b/api/src/__test__/core/__snapshots__/permissions.test.ts.snap deleted file mode 100644 index 716ed4b57..000000000 --- a/api/src/__test__/core/__snapshots__/permissions.test.ts.snap +++ /dev/null @@ -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, -} -`; diff --git a/api/src/cli.ts b/api/src/cli.ts index a02bbf360..ffcf5c7ff 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -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', diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index 9166f0a95..1532d9ac2 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -823,7 +823,7 @@ export function PciSchema(): z.ZodObject> { export function PermissionSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Permission').optional(), - actions: z.array(z.string()).nullish(), + actions: z.array(z.string()), resource: ResourceSchema }) } diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 717fb4644..fb6fcb9df 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -66,7 +66,7 @@ export type ApiKey = { description?: Maybe; id: Scalars['ID']['output']; name: Scalars['String']['output']; - permissions?: Maybe>; + permissions: Array; roles: Array; }; @@ -1028,7 +1028,7 @@ export type Pci = { export type Permission = { __typename?: 'Permission'; - actions?: Maybe>; + actions: Array; resource: Resource; }; @@ -2056,7 +2056,7 @@ export type ApiKeyResolvers, ParentType, ContextType>; id?: Resolver; name?: Resolver; - permissions?: Resolver>, ParentType, ContextType>; + permissions?: Resolver, ParentType, ContextType>; roles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -2653,7 +2653,7 @@ export type PciResolvers; export type PermissionResolvers = ResolversObject<{ - actions?: Resolver>, ParentType, ContextType>; + actions?: Resolver, ParentType, ContextType>; resource?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/api/src/graphql/schema/types/auth/auth.graphql b/api/src/graphql/schema/types/auth/auth.graphql index b6142e729..52de60e8a 100644 --- a/api/src/graphql/schema/types/auth/auth.graphql +++ b/api/src/graphql/schema/types/auth/auth.graphql @@ -1,6 +1,6 @@ type Permission { resource: Resource! - actions: [String!] + actions: [String!]! } type ApiKey { diff --git a/api/src/unraid-api/auth/api-key.service.spec.ts b/api/src/unraid-api/auth/api-key.service.spec.ts index 6d595371e..404ae630f 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -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({ diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 93ea26251..89da2ab80 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -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 { + public getAllValidPermissions(): Permission[] { + return Object.values(Resource).map((res) => ({ + resource: res, + actions: Object.values(AuthActionVerb), + })); + } + + public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] { + return permissions.reduce>((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); + } + + 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 { 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 { + 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 { + findByKey(key: string): ApiKeyWithSecret | null { return this.findByField('key', key); } - async findOneByKey(apiKey: string): Promise { - 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 { - return await this.create('Connect', 'API key for Connect user', [Role.CONNECT], true); + public async createLocalConnectApiKey(): Promise { + 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 { diff --git a/api/src/unraid-api/cli/apikey/add-api-key.questions.ts b/api/src/unraid-api/cli/apikey/add-api-key.questions.ts new file mode 100644 index 000000000..bff24ec15 --- /dev/null +++ b/api/src/unraid-api/cli/apikey/add-api-key.questions.ts @@ -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)); + } +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/apikey/api-key.command.ts b/api/src/unraid-api/cli/apikey/api-key.command.ts new file mode 100644 index 000000000..1b9f4e109 --- /dev/null +++ b/api/src/unraid-api/cli/apikey/api-key.command.ts @@ -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 ', + 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 ', + description: `Comma-separated list of roles (${Object.values(Role).join(',')})`, + }) + parseRoles(roles: string): Role[] { + if (!roles) return [Role.GUEST]; + const validRoles: Set = 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 ', + 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 { + return this.apiKeyService.convertPermissionsStringArrayToPermissions( + permissions.split(',').filter(Boolean) + ); + } + + @Option({ + flags: '-d, --description ', + description: 'Description to assign to the key', + }) + parseDescription(description: string): string { + return description; + } + + async run(_: string[], options: KeyOptions = { create: false, name: '' }): Promise { + 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); + } + } +} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 4fd6eb20c..464211d6c 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -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, diff --git a/api/src/unraid-api/cli/key.command.ts b/api/src/unraid-api/cli/key.command.ts deleted file mode 100644 index 5b9e2d8ab..000000000 --- a/api/src/unraid-api/cli/key.command.ts +++ /dev/null @@ -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; - permissions?: Array; -} - -@Command({ name: 'key', arguments: '' }) -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 ', - description: `Comma-separated list of roles (${Object.values(Role).join(',')})`, - }) - parseRoles(roles: string): Role[] { - if (!roles) return [Role.GUEST]; - const validRoles: Set = 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 to assign to the key', - }) - parseDescription(description: string): string { - return description; - } - - @Option({ - flags: '-p, --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 { - 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); - } - } -} diff --git a/api/src/unraid-api/cli/sso/add-sso-user.command.ts b/api/src/unraid-api/cli/sso/add-sso-user.command.ts index a440f2e55..c4fd194f6 100644 --- a/api/src/unraid-api/cli/sso/add-sso-user.command.ts +++ b/api/src/unraid-api/cli/sso/add-sso-user.command.ts @@ -30,7 +30,6 @@ export class AddSSOUserCommand extends CommandRunner { async run(_input: string[], options: AddSSOUserCommandOptions): Promise { 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)); diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index fffdb26da..4e550c637 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -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 { + async run(_: string[], options: StartCommandOptions): Promise { 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(), [