mirror of
https://github.com/unraid/api.git
synced 2026-01-03 15:09:48 -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();
|
const shellToUse = execSync('which bash').toString().trim();
|
||||||
await CommandFactory.run(CliModule, {
|
await CommandFactory.run(CliModule, {
|
||||||
cliName: 'unraid-api',
|
cliName: 'unraid-api',
|
||||||
logger: false,
|
logger: false, // new LogService(), - enable this to see nest initialization issues
|
||||||
completion: {
|
completion: {
|
||||||
fig: true,
|
fig: true,
|
||||||
cmd: 'unraid-api',
|
cmd: 'unraid-api',
|
||||||
|
|||||||
@@ -823,7 +823,7 @@ export function PciSchema(): z.ZodObject<Properties<Pci>> {
|
|||||||
export function PermissionSchema(): z.ZodObject<Properties<Permission>> {
|
export function PermissionSchema(): z.ZodObject<Properties<Permission>> {
|
||||||
return z.object({
|
return z.object({
|
||||||
__typename: z.literal('Permission').optional(),
|
__typename: z.literal('Permission').optional(),
|
||||||
actions: z.array(z.string()).nullish(),
|
actions: z.array(z.string()),
|
||||||
resource: ResourceSchema
|
resource: ResourceSchema
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export type ApiKey = {
|
|||||||
description?: Maybe<Scalars['String']['output']>;
|
description?: Maybe<Scalars['String']['output']>;
|
||||||
id: Scalars['ID']['output'];
|
id: Scalars['ID']['output'];
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
permissions?: Maybe<Array<Permission>>;
|
permissions: Array<Permission>;
|
||||||
roles: Array<Role>;
|
roles: Array<Role>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1028,7 +1028,7 @@ export type Pci = {
|
|||||||
|
|
||||||
export type Permission = {
|
export type Permission = {
|
||||||
__typename?: 'Permission';
|
__typename?: 'Permission';
|
||||||
actions?: Maybe<Array<Scalars['String']['output']>>;
|
actions: Array<Scalars['String']['output']>;
|
||||||
resource: Resource;
|
resource: Resource;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2056,7 +2056,7 @@ export type ApiKeyResolvers<ContextType = Context, ParentType extends ResolversP
|
|||||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||||
name?: Resolver<ResolversTypes['String'], 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>;
|
roles?: Resolver<Array<ResolversTypes['Role']>, ParentType, ContextType>;
|
||||||
__isTypeOf?: IsTypeOfResolverFn<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<{
|
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>;
|
resource?: Resolver<ResolversTypes['Resource'], ParentType, ContextType>;
|
||||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
type Permission {
|
type Permission {
|
||||||
resource: Resource!
|
resource: Resource!
|
||||||
actions: [String!]
|
actions: [String!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKey {
|
type ApiKey {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { updateUserConfig } from '@app/store/modules/config';
|
|||||||
import { FileLoadStatus } from '@app/store/types';
|
import { FileLoadStatus } from '@app/store/types';
|
||||||
|
|
||||||
import { ApiKeyService } from './api-key.service';
|
import { ApiKeyService } from './api-key.service';
|
||||||
|
import { environment } from '@app/environment';
|
||||||
|
|
||||||
// Mock the store and its modules
|
// Mock the store and its modules
|
||||||
vi.mock('@app/store', () => ({
|
vi.mock('@app/store', () => ({
|
||||||
@@ -84,6 +85,7 @@ describe('ApiKeyService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
environment.IS_MAIN_PROCESS = true;
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
// Create mock logger methods
|
// Create mock logger methods
|
||||||
@@ -159,7 +161,7 @@ describe('ApiKeyService', () => {
|
|||||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||||
const name = 'Test API Key';
|
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({
|
expect(result).toMatchObject({
|
||||||
id,
|
id,
|
||||||
@@ -176,17 +178,23 @@ describe('ApiKeyService', () => {
|
|||||||
it('should validate input parameters', async () => {
|
it('should validate input parameters', async () => {
|
||||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey');
|
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)'
|
'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)'
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(apiKeyService.create('name', 'desc', [])).rejects.toThrow(
|
await expect(
|
||||||
'At least one role must be specified'
|
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(
|
await expect(
|
||||||
'Invalid role specified'
|
apiKeyService.create({
|
||||||
);
|
name: 'name',
|
||||||
|
description: 'desc',
|
||||||
|
roles: ['invalid_role' as Role],
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Invalid role specified');
|
||||||
|
|
||||||
expect(saveSpy).not.toHaveBeenCalled();
|
expect(saveSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -248,12 +256,12 @@ describe('ApiKeyService', () => {
|
|||||||
|
|
||||||
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
await apiKeyService['createLocalApiKeyForConnectIfNecessary']();
|
||||||
|
|
||||||
expect(apiKeyService.create).toHaveBeenCalledWith(
|
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||||
'Connect',
|
name: 'Connect',
|
||||||
'API key for Connect user',
|
description: 'API key for Connect user',
|
||||||
[Role.CONNECT],
|
roles: [Role.CONNECT],
|
||||||
true
|
overwrite: true,
|
||||||
);
|
});
|
||||||
expect(store.dispatch).toHaveBeenCalledWith(
|
expect(store.dispatch).toHaveBeenCalledWith(
|
||||||
updateUserConfig({
|
updateUserConfig({
|
||||||
remote: {
|
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, 'findByField').mockReturnValue(null);
|
||||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue({
|
vi.spyOn(apiKeyService, 'createLocalConnectApiKey').mockResolvedValue(null);
|
||||||
...mockApiKeyWithSecret,
|
|
||||||
key: '', // Empty string instead of undefined/null
|
|
||||||
} as ApiKeyWithSecret);
|
|
||||||
|
|
||||||
await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).rejects.toThrow(
|
await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).resolves.toBe(
|
||||||
'Failed to create local API key'
|
undefined
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
'Failed to create local API key - no key returned'
|
'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', () => {
|
describe('saveApiKey', () => {
|
||||||
it('should save API key to file', async () => {
|
it('should save API key to file', async () => {
|
||||||
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
vi.mocked(ApiKeyWithSecretSchema).mockReturnValue({
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import { join } from 'path';
|
|||||||
import { watch } from 'chokidar';
|
import { watch } from 'chokidar';
|
||||||
import { ensureDirSync } from 'fs-extra';
|
import { ensureDirSync } from 'fs-extra';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
|
import { AuthActionVerb } from 'nest-authz';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
import { environment } from '@app/environment';
|
||||||
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
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 { getters, store } from '@app/store';
|
||||||
import { updateUserConfig } from '@app/store/modules/config';
|
import { updateUserConfig } from '@app/store/modules/config';
|
||||||
import { FileLoadStatus } from '@app/store/types';
|
import { FileLoadStatus } from '@app/store/types';
|
||||||
@@ -55,12 +64,52 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
public getAllValidPermissions(): Permission[] {
|
||||||
name: string,
|
return Object.values(Resource).map((res) => ({
|
||||||
description: string | undefined,
|
resource: res,
|
||||||
roles: Role[],
|
actions: Object.values(AuthActionVerb),
|
||||||
overwrite: boolean = false
|
}));
|
||||||
): Promise<ApiKeyWithSecret> {
|
}
|
||||||
|
|
||||||
|
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 trimmedName = name?.trim();
|
||||||
const sanitizedName = this.sanitizeName(trimmedName);
|
const sanitizedName = this.sanitizeName(trimmedName);
|
||||||
|
|
||||||
@@ -89,7 +138,7 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
|
|
||||||
apiKey.description = description;
|
apiKey.description = description;
|
||||||
apiKey.roles = roles;
|
apiKey.roles = roles;
|
||||||
apiKey.permissions = [];
|
apiKey.permissions = permissions ?? [];
|
||||||
// Update createdAt date
|
// Update createdAt date
|
||||||
apiKey.createdAt = new Date().toISOString();
|
apiKey.createdAt = new Date().toISOString();
|
||||||
|
|
||||||
@@ -98,28 +147,26 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
return apiKey as ApiKeyWithSecret;
|
return apiKey as ApiKeyWithSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createLocalApiKeyForConnectIfNecessary() {
|
private async createLocalApiKeyForConnectIfNecessary(): Promise<void> {
|
||||||
|
if (!environment.IS_MAIN_PROCESS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (getters.config().status !== FileLoadStatus.LOADED) {
|
if (getters.config().status !== FileLoadStatus.LOADED) {
|
||||||
this.logger.error('Config file not loaded, cannot create local API key');
|
this.logger.error('Config file not loaded, cannot create local API key');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { remote } = getters.config();
|
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 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');
|
const hasExistingKey = this.findByField('name', 'Connect');
|
||||||
|
|
||||||
if (hasExistingKey) {
|
if (hasExistingKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Create local API key
|
// Create local API key
|
||||||
const localApiKey = await this.create(
|
const localApiKey = await this.createLocalConnectApiKey();
|
||||||
'Connect',
|
|
||||||
'API key for Connect user',
|
|
||||||
[Role.CONNECT],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (localApiKey?.key) {
|
if (localApiKey?.key) {
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
@@ -131,7 +178,6 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error('Failed to create local API key - no key returned');
|
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'));
|
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||||
|
|
||||||
for (const file of jsonFiles) {
|
for (const file of jsonFiles) {
|
||||||
const apiKey = await this.loadApiKeyFile(file);
|
try {
|
||||||
|
const apiKey = await this.loadApiKeyFile(file);
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
apiKeys.push(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));
|
return ApiKeyWithSecretSchema().parse(JSON.parse(content));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
|
this.logger.error(`Corrupted key file: ${file}`);
|
||||||
throw new Error('Authentication system error: Corrupted key 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 {
|
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
try {
|
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
|
findByKey(key: string): ApiKeyWithSecret | null {
|
||||||
return this.findByField('key', key);
|
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 {
|
private generateApiKey(): string {
|
||||||
return crypto.randomBytes(32).toString('hex');
|
return crypto.randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret> {
|
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
|
||||||
return await this.create('Connect', 'API key for Connect user', [Role.CONNECT], true);
|
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> {
|
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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { InquirerService } from 'nest-commander';
|
|
||||||
|
|
||||||
import { ConfigCommand } from '@app/unraid-api/cli/config.command';
|
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 { LogService } from '@app/unraid-api/cli/log.service';
|
||||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
|
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
|
||||||
import { ReportCommand } from '@app/unraid-api/cli/report.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 { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command';
|
||||||
import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions';
|
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 { 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({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -33,7 +33,9 @@ import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.comman
|
|||||||
StopCommand,
|
StopCommand,
|
||||||
RestartCommand,
|
RestartCommand,
|
||||||
ReportCommand,
|
ReportCommand,
|
||||||
KeyCommand,
|
ApiKeyService,
|
||||||
|
ApiKeyCommand,
|
||||||
|
AddApiKeyQuestionSet,
|
||||||
SwitchEnvCommand,
|
SwitchEnvCommand,
|
||||||
VersionCommand,
|
VersionCommand,
|
||||||
StatusCommand,
|
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> {
|
async run(_input: string[], options: AddSSOUserCommandOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options);
|
options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options);
|
||||||
console.log(options);
|
|
||||||
if (options.disclaimer === 'y' && options.username) {
|
if (options.disclaimer === 'y' && options.username) {
|
||||||
await store.dispatch(loadConfigFile());
|
await store.dispatch(loadConfigFile());
|
||||||
store.dispatch(addSsoUser(options.username));
|
store.dispatch(addSsoUser(options.username));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { execa } from 'execa';
|
|||||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||||
|
|
||||||
import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts';
|
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';
|
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||||
|
|
||||||
interface StartCommandOptions {
|
interface StartCommandOptions {
|
||||||
@@ -15,7 +15,7 @@ export class StartCommand extends CommandRunner {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(_, options: StartCommandOptions): Promise<void> {
|
async run(_: string[], options: StartCommandOptions): Promise<void> {
|
||||||
this.logger.info('Starting the Unraid API');
|
this.logger.info('Starting the Unraid API');
|
||||||
const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : '';
|
const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : '';
|
||||||
const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [
|
const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [
|
||||||
|
|||||||
Reference in New Issue
Block a user