diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 0e907c8967..e76722fdd1 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -9,6 +9,7 @@ wanaccess="yes" wanport="8443" upnpEnabled="no" apikey="_______________________BIG_API_KEY_HERE_________________________" +localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" email="test@example.com" username="zspearmint" avatar="https://via.placeholder.com/200" diff --git a/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json b/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json index 8eb8ab7d93..b9ce6ca0d0 100644 --- a/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json +++ b/api/dev/keys/10f356da-1e9e-43b8-9028-a26a645539a6.json @@ -3,6 +3,6 @@ "key": "73717ca0-8c15-40b9-bcca-8d85656d1438", "name": "Test API Key", "description": "Testing API key creation", - "roles": ["guest", "upc"], + "roles": ["guest", "connect"], "createdAt": "2024-10-29T19:59:12.569Z" } diff --git a/api/dev/keys/d166bf8b-3615-444a-8932-c460b2132ba3.json b/api/dev/keys/d166bf8b-3615-444a-8932-c460b2132ba3.json new file mode 100644 index 0000000000..926875cec0 --- /dev/null +++ b/api/dev/keys/d166bf8b-3615-444a-8932-c460b2132ba3.json @@ -0,0 +1,10 @@ +{ + "createdAt": "2024-12-20T15:05:55.336Z", + "description": "API key for Connect user", + "id": "d166bf8b-3615-444a-8932-c460b2132ba3", + "key": "_______________________LOCAL_API_KEY_HERE_________________________", + "name": "Connect", + "roles": [ + "connect" + ] +} \ No newline at end of file diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index a505dc733f..621129eaa1 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -9,6 +9,7 @@ wanaccess="yes" wanport="8443" upnpEnabled="no" apikey="_______________________BIG_API_KEY_HERE_________________________" +localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" email="test@example.com" username="zspearmint" avatar="https://via.placeholder.com/200" @@ -21,4 +22,4 @@ dynamicRemoteAccessType="DISABLED" [upc] apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810" [connectionStatus] -minigraph="ERROR_RETRYING" +minigraph="PRE_INIT" diff --git a/api/src/__test__/graphql/resolvers/subscription/network.test.ts b/api/src/__test__/graphql/resolvers/subscription/network.test.ts index 0422009e96..5795cd9bce 100644 --- a/api/src/__test__/graphql/resolvers/subscription/network.test.ts +++ b/api/src/__test__/graphql/resolvers/subscription/network.test.ts @@ -58,15 +58,15 @@ test('getUrlForServer - field exists, ssl yes, port empty', () => { expect(result).toMatchInlineSnapshot('"https://192.168.1.1/"'); }); -test('getUrlForServer - field exists, ssl auto', () => { +test('getUrlForServer - field exists, ssl auto', async () => { const getResult = async () => getUrlForServer({ nginx: { lanIp: '192.168.1.1', sslEnabled: true, sslMode: 'auto', httpPort: 123, httpsPort: 445 } as const as Nginx, field: 'lanIp', }); - void expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Cannot get IP Based URL for field: "lanIp" SSL mode auto]`); + await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Cannot get IP Based URL for field: "lanIp" SSL mode auto]`); }); -test('getUrlForServer - field does not exist, ssl disabled', () => { +test('getUrlForServer - field does not exist, ssl disabled', async () => { const getResult = async () => getUrlForServer( { nginx: { lanIp: '192.168.1.1', sslEnabled: false, sslMode: 'no' } as const as Nginx, @@ -76,7 +76,7 @@ test('getUrlForServer - field does not exist, ssl disabled', () => { // @ts-expect-error Field doesn't exist field: 'idontexist', }); - void expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`); + await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`); }); test('getUrlForServer - FQDN - field exists, port non-empty', () => { @@ -104,13 +104,13 @@ test.each([ expect(result.toString()).toBe('https://my-fqdn.unraid.net/'); }); -test('getUrlForServer - field does not exist, ssl disabled', () => { +test('getUrlForServer - field does not exist, ssl disabled', async () => { const getResult = async () => getUrlForServer({ nginx: { lanFqdn: 'my-fqdn.unraid.net' } as const as Nginx, ports: { portSsl: '', port: '', defaultUrl: new URL('https://my-default-url.unraid.net') }, // @ts-expect-error Field doesn't exist field: 'idontexist' }); - void expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`); + await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`); }); test('integration test, loading nginx ini and generating all URLs', async () => { diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index 1243c8e0ab..c179a20bf6 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -82,7 +82,7 @@ test('After init returns values from cfg file for all fields', async () => { dynamicRemoteAccessType: 'DISABLED', email: 'test@example.com', idtoken: '', - localApiKey: '', + localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', upnpEnabled: 'no', @@ -139,7 +139,7 @@ test('updateUserConfig merges in changes to current state', async () => { dynamicRemoteAccessType: 'DISABLED', email: 'test@example.com', idtoken: '', - localApiKey: '', + localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', upnpEnabled: 'no', diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 30095d50b4..ee709df14d 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -1192,7 +1192,7 @@ export enum Resource { CLOUD = 'cloud', CONFIG = 'config', CONNECT = 'connect', - CRASH_REPORTING_ENABLED = 'crash_reporting_enabled', + CONNECT__REMOTE_ACCESS = 'connect__remote_access', CUSTOMIZATIONS = 'customizations', DASHBOARD = 'dashboard', DISK = 'disk', @@ -1220,10 +1220,8 @@ export enum Resource { /** Available roles for API keys and users */ export enum Role { ADMIN = 'admin', - GUEST = 'guest', - MY_SERVERS = 'my_servers', - NOTIFIER = 'notifier', - UPC = 'upc' + CONNECT = 'connect', + GUEST = 'guest' } export type Server = { diff --git a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts index bbf4340a6f..206d89f303 100644 --- a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts +++ b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts @@ -1,7 +1,6 @@ import { decodeJwt } from 'jose'; import type { ConnectSignInInput } from '@app/graphql/generated/api/types'; -import { Role } from '@app/graphql/generated/api/types'; import { getters, store } from '@app/store/index'; import { loginUser } from '@app/store/modules/config'; import { FileLoadStatus } from '@app/store/types'; @@ -30,11 +29,7 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise if (localApiKeyFromConfig == '') { const apiKeyService = new ApiKeyService(); // Create local API key - const localApiKey = await apiKeyService.create( - `LOCAL_KEY_${userInfo.preferred_username.toUpperCase()}`, - `Local API key for Connect user ${userInfo.email}`, - [Role.ADMIN] - ); + const localApiKey = await apiKeyService.createLocalConnectApiKey(); if (!localApiKey?.key) { throw new Error('Failed to create local API key'); @@ -60,4 +55,4 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise } else { return false; } -}; +}; \ No newline at end of file diff --git a/api/src/graphql/schema/types/auth/auth.graphql b/api/src/graphql/schema/types/auth/auth.graphql index 19f129e9c7..93d7e0251e 100644 --- a/api/src/graphql/schema/types/auth/auth.graphql +++ b/api/src/graphql/schema/types/auth/auth.graphql @@ -1,48 +1,3 @@ -""" -Available resources for permissions -""" -enum Resource { - api_key - cloud - config - crash_reporting_enabled - customizations - disk - display - flash - info - logs - online - os - owner - permission - registration - servers - share - vars - connect - notifications - array - dashboard - docker - network - services - vms - me - welcome -} - -""" -Available roles for API keys and users -""" -enum Role { - admin - upc - my_servers - notifier - guest -} - type ApiKey { id: ID! name: String! diff --git a/api/src/graphql/schema/types/auth/roles.graphql b/api/src/graphql/schema/types/auth/roles.graphql new file mode 100644 index 0000000000..9c96edfedf --- /dev/null +++ b/api/src/graphql/schema/types/auth/roles.graphql @@ -0,0 +1,42 @@ +""" +Available resources for permissions +""" +enum Resource { + api_key + array + cloud + config + connect + connect__remote_access + customizations + dashboard + disk + display + docker + flash + info + logs + me + network + notifications + online + os + owner + permission + registration + servers + services + share + vars + vms + welcome +} + +""" +Available roles for API keys and users +""" +enum Role { + admin + connect + guest +} diff --git a/api/src/index.ts b/api/src/index.ts index 20ca9a6f80..477be1533f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -31,6 +31,7 @@ import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch'; import { StateManager } from '@app/store/watch/state-watch'; import { setupVarRunWatch } from '@app/store/watch/var-run-watch'; import { bootstrapNestServer } from '@app/unraid-api/main'; +import { createLocalApiKeyForConnectIfNecessary } from '@app/mothership/utils/create-local-connect-api-key'; import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership'; @@ -87,6 +88,8 @@ try { // Start listening to dynamix config file changes setupDynamixConfigWatch(); + await createLocalApiKeyForConnectIfNecessary(); + // Disabled until we need the access token to work // TokenRefresh.init(); diff --git a/api/src/mothership/utils/create-local-connect-api-key.ts b/api/src/mothership/utils/create-local-connect-api-key.ts new file mode 100644 index 0000000000..2f771c7136 --- /dev/null +++ b/api/src/mothership/utils/create-local-connect-api-key.ts @@ -0,0 +1,33 @@ +import { minigraphLogger } from '@app/core/log'; +import { getters, store } from '@app/store/index'; +import { updateUserConfig } from '@app/store/modules/config'; +import { FileLoadStatus } from '@app/store/types'; +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; + +export const createLocalApiKeyForConnectIfNecessary = async () => { + if (getters.config().status !== FileLoadStatus.LOADED) { + minigraphLogger.error('Config file not loaded, cannot create local API key'); + return; + } + + const { remote } = getters.config(); + const apiKeyService = new ApiKeyService(); + // If the remote API Key is set and the local key is either not set or not found on disk, create a key + if (remote.apikey && (!remote.localApiKey || !(await apiKeyService.findByKey(remote.localApiKey)))) { + minigraphLogger.debug('Creating local API key for Connect'); + // Create local API key + const localApiKey = await apiKeyService.createLocalConnectApiKey(); + + if (localApiKey?.key) { + store.dispatch( + updateUserConfig({ + remote: { + localApiKey: localApiKey.key, + }, + }) + ); + } else { + throw new Error('Failed to create local API key - no key returned'); + } + } +}; diff --git a/api/src/store/listeners/listener-middleware.ts b/api/src/store/listeners/listener-middleware.ts index a4bdc3ac1a..229046928b 100644 --- a/api/src/store/listeners/listener-middleware.ts +++ b/api/src/store/listeners/listener-middleware.ts @@ -1,3 +1,5 @@ +import 'reflect-metadata'; + import type { TypedAddListener, TypedStartListening } from '@reduxjs/toolkit'; import { addListener, createListenerMiddleware } from '@reduxjs/toolkit'; @@ -6,17 +8,12 @@ import { enableArrayEventListener } from '@app/store/listeners/array-event-liste import { enableConfigFileListener } from '@app/store/listeners/config-listener'; import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener'; import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener'; +import { enableNotificationPathListener } from '@app/store/listeners/notification-path-listener'; import { enableServerStateListener } from '@app/store/listeners/server-state-listener'; import { enableUpnpListener } from '@app/store/listeners/upnp-listener'; import { enableVersionListener } from '@app/store/listeners/version-listener'; import { enableWanAccessChangeListener } from '@app/store/listeners/wan-access-change-listener'; -import 'reflect-metadata'; - -import { enableNotificationPathListener } from '@app/store/listeners/notification-path-listener'; - -import { enableLocalApiKeyListener } from './local-api-key-listener'; - export const listenerMiddleware = createListenerMiddleware(); export type AppStartListening = TypedStartListening; @@ -29,7 +26,6 @@ export const addAppListener = addListener as TypedAddListener { // Begin listening for events - enableLocalApiKeyListener(); enableMothershipJobsListener(); enableConfigFileListener('flash')(); enableConfigFileListener('memory')(); diff --git a/api/src/store/listeners/local-api-key-listener.ts b/api/src/store/listeners/local-api-key-listener.ts deleted file mode 100644 index 9349f72d41..0000000000 --- a/api/src/store/listeners/local-api-key-listener.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { logger } from '@app/core/log'; -import { Role } from '@app/graphql/generated/api/types'; -import { getters } from '@app/store/index'; -import { startAppListening } from '@app/store/listeners/listener-middleware'; -import { updateUserConfig } from '@app/store/modules/config'; -import { FileLoadStatus } from '@app/store/types'; -import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; - -export const enableLocalApiKeyListener = () => - startAppListening({ - predicate(_, currentState) { - return ( - currentState.config.status === FileLoadStatus.LOADED && - currentState.config.remote.apikey !== '' && - currentState.config.remote.localApiKey === '' - ); - }, - async effect(_, { dispatch }) { - try { - const { remote } = getters.config(); - const { apikey, username } = remote; - // Validate the API key with the key server - const apiKeyService = new ApiKeyService(); - // Create local API key - const localApiKey = await apiKeyService.create( - `LOCAL_KEY_${(username as string).toUpperCase()}`, - `Local API key for Connect user ${username}`, - [Role.ADMIN] - ); - - if (localApiKey?.key) { - dispatch( - updateUserConfig({ - remote: { - localApiKey: localApiKey.key, - }, - }) - ); - } else { - throw new Error('Failed to create local API key - no key returned'); - } - } catch (error) { - logger.error('Failed to create local API key', error); - } - }, - }); 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 9826aa1c17..cffd670a9a 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -2,17 +2,24 @@ import { Logger } from '@nestjs/common'; import { readdir, readFile, writeFile } from 'fs/promises'; import { join } from 'path'; + + import { ensureDir } from 'fs-extra'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; + + import type { ApiKey, ApiKeyWithSecret } from '@app/graphql/generated/api/types'; import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations'; import { Role } from '@app/graphql/generated/api/types'; import { getters } from '@app/store'; + + import { ApiKeyService } from './api-key.service'; + vi.mock('fs/promises', async () => ({ readdir: vi.fn(), readFile: vi.fn(), @@ -127,15 +134,14 @@ describe('ApiKeyService', () => { it('should create ApiKeyWithSecret with generated key', async () => { const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue(); const { key, id, description, roles } = mockApiKeyWithSecret; - const inputName = 'Test API Key'; - const expectedName = 'TEST_API_KEY'; + const name = 'Test API Key'; - const result = await apiKeyService.create(inputName, description ?? '', roles); + const result = await apiKeyService.create(name, description ?? '', roles); expect(result).toMatchObject({ id, key, - name: expectedName, + name: name, description, roles, createdAt: expect.any(String), @@ -148,7 +154,7 @@ describe('ApiKeyService', () => { const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey'); await expect(apiKeyService.create('', 'desc', [Role.GUEST])).rejects.toThrow( - 'API key name is required' + 'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)' ); await expect(apiKeyService.create('name', 'desc', [])).rejects.toThrow( @@ -300,13 +306,11 @@ describe('ApiKeyService', () => { expect(readFile).toHaveBeenCalledTimes(2); }); - it('should throw authentication error when file read fails', async () => { + it('Should return null if an API key is invalid', async () => { vi.mocked(readdir).mockResolvedValue(['key1.json'] as any); vi.mocked(readFile).mockRejectedValue(new Error('Read error')); - await expect(apiKeyService.findByKey(mockApiKeyWithSecret.key)).rejects.toThrow( - 'Authentication system error' - ); + await expect(apiKeyService.findByKey(mockApiKeyWithSecret.key)).resolves.toBeNull(); }); it('should throw specific error for corrupted JSON', async () => { @@ -465,4 +469,4 @@ describe('ApiKeyService', () => { ); }); }); -}); +}); \ No newline at end of file diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 37b473f900..d94ed41128 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -17,6 +17,7 @@ export class ApiKeyService implements OnModuleInit { private readonly logger = new Logger(ApiKeyService.name); protected readonly basePath: string; protected readonly keyFile: (id: string) => string; + protected memoryApiKeys = new Map(); private static readonly validRoles: Set = new Set(Object.values(Role)); constructor() { @@ -34,6 +35,8 @@ export class ApiKeyService implements OnModuleInit { throw new GraphQLError('Failed to initialize API key storage'); } this.logger.verbose(`Using API key base path: ${this.basePath}`); + + // @todo setup file watch to reload keys } async onModuleInit() { @@ -41,13 +44,20 @@ export class ApiKeyService implements OnModuleInit { } private sanitizeName(name: string): string { - return name.replace(/[^a-zA-Z0-9-_]/g, '_').toUpperCase(); + if (/^[\p{L}\p{N} ]+$/u.test(name)) { + return name; + } else { + throw new GraphQLError( + 'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)' + ); + } } async create( name: string, description: string | undefined, - roles: Role[] + roles: Role[], + overwrite: boolean = false ): Promise { const trimmedName = name?.trim(); const sanitizedName = this.sanitizeName(trimmedName); @@ -64,18 +74,25 @@ export class ApiKeyService implements OnModuleInit { throw new GraphQLError('Invalid role specified'); } - const apiKey: ApiKeyWithSecret = { + const existingKey = await this.findByField('name', sanitizedName); + if (!overwrite && existingKey) { + throw new GraphQLError('API key name already exists, use overwrite flag to update'); + } + const apiKey: Partial = { id: uuidv4(), key: this.generateApiKey(), name: sanitizedName, - description, - roles, - createdAt: new Date().toISOString(), + ...(existingKey ?? {}), }; - await this.saveApiKey(apiKey); + apiKey.description = description; + apiKey.roles = roles; + // Update createdAt date + apiKey.createdAt = new Date().toISOString(); + + await this.saveApiKey(apiKey as ApiKeyWithSecret); - return apiKey; + return apiKey as ApiKeyWithSecret; } async findAll(): Promise { @@ -162,14 +179,13 @@ export class ApiKeyService implements OnModuleInit { } } - async findByKey(key: string): Promise { - if (!key) return null; + async findByField(field: keyof ApiKeyWithSecret, value: string): Promise { + if (!value) return null; try { const files = await readdir(this.basePath); - const keyBuffer1 = Buffer.from(key); - for (const file of files) { + for (const file of files ?? []) { if (!file.endsWith('.json')) continue; try { @@ -187,14 +203,14 @@ export class ApiKeyService implements OnModuleInit { } const apiKey = ApiKeyWithSecretSchema().parse(parsedContent); - const keyBuffer2 = Buffer.from(apiKey.key); - if ( - keyBuffer1.length === keyBuffer2.length && - crypto.timingSafeEqual(keyBuffer1, keyBuffer2) - ) { + if (field === 'key') { + if (crypto.timingSafeEqual(Buffer.from(apiKey[field]), Buffer.from(value))) { + apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST); + return apiKey; + } + } else if (apiKey[field] === value) { apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST); - return apiKey; } } catch (error) { @@ -203,7 +219,6 @@ export class ApiKeyService implements OnModuleInit { } this.logger.error(`Error processing API key file ${file}: ${error}`); - throw new GraphQLError('Authentication system error'); } } @@ -214,10 +229,14 @@ export class ApiKeyService implements OnModuleInit { } this.logger.error(`Failed to read API key storage: ${error}`); - throw new GraphQLError('Authentication system unavailable'); + throw new GraphQLError('Authentication system unavailable - please see logs'); } } + async findByKey(key: string): Promise { + return this.findByField('key', key); + } + async findOneByKey(apiKey: string): Promise { try { const key = await this.findByKey(apiKey); @@ -247,11 +266,22 @@ export class ApiKeyService implements OnModuleInit { 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 saveApiKey(apiKey: ApiKeyWithSecret): Promise { try { const validatedApiKey = ApiKeyWithSecretSchema().parse(apiKey); - await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(validatedApiKey, null, 2)); + const sortedApiKey = Object.keys(validatedApiKey) + .sort() + .reduce((acc, key) => { + acc[key] = validatedApiKey[key]; + return acc; + }, {} as ApiKeyWithSecret); + + await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(sortedApiKey, null, 2)); } catch (error: unknown) { if (error instanceof ZodError) { this.logger.error('Invalid API key structure', error.errors); diff --git a/api/src/unraid-api/auth/auth.service.spec.ts b/api/src/unraid-api/auth/auth.service.spec.ts index 70a92a77ff..fb8664aa93 100644 --- a/api/src/unraid-api/auth/auth.service.spec.ts +++ b/api/src/unraid-api/auth/auth.service.spec.ts @@ -22,7 +22,7 @@ describe('AuthService', () => { id: '10f356da-1e9e-43b8-9028-a26a645539a6', name: 'Test API Key', description: 'Test API Key Description', - roles: [Role.GUEST, Role.UPC], + roles: [Role.GUEST, Role.CONNECT], createdAt: new Date().toISOString(), }; @@ -39,7 +39,7 @@ describe('AuthService', () => { id: '-1', description: 'Test User', name: 'test_user', - roles: [Role.GUEST, Role.UPC], + roles: [Role.GUEST, Role.CONNECT], }; beforeEach(async () => { @@ -134,13 +134,13 @@ describe('AuthService', () => { const mockApiKeyWithoutRole = { ...mockApiKey, - roles: [Role.UPC], + roles: [Role.ADMIN], }; vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole); vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({ ...mockApiKeyWithSecret, - roles: [Role.UPC], + roles: [Role.ADMIN], }); vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue(); vi.spyOn(authzService, 'addRoleForUser').mockResolvedValue(true); @@ -152,7 +152,7 @@ describe('AuthService', () => { expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId); expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({ ...mockApiKeyWithSecret, - roles: [Role.UPC, role], + roles: [Role.ADMIN, role], }); expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role); }); diff --git a/api/src/unraid-api/auth/auth.service.ts b/api/src/unraid-api/auth/auth.service.ts index 7744ba66ef..6d9955d114 100644 --- a/api/src/unraid-api/auth/auth.service.ts +++ b/api/src/unraid-api/auth/auth.service.ts @@ -215,9 +215,9 @@ export class AuthService { this.logger.debug('getSessionUser called!'); return { id: '-1', - description: 'UPC service account', - name: 'upc', - roles: [Role.UPC], + description: 'Session receives administrator permissions', + name: 'admin', + roles: [Role.ADMIN], }; } } diff --git a/api/src/unraid-api/auth/casbin/policy.ts b/api/src/unraid-api/auth/casbin/policy.ts index bc6831bd27..d2a0e6c773 100644 --- a/api/src/unraid-api/auth/casbin/policy.ts +++ b/api/src/unraid-api/auth/casbin/policy.ts @@ -6,63 +6,14 @@ export const BASE_POLICY = ` # Admin permissions p, ${Role.ADMIN}, *, *, * -# UPC permissions for API keys -p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.CREATE_ANY} -p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.UPDATE_ANY} - -# UPC permissions -p, ${Role.UPC}, ${Resource.CLOUD}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, crash-reporting-enabled, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.DISK}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.FLASH}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.INFO}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.LOGS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.OS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.OWNER}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.REGISTRATION}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.SERVERS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.VARS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.UPDATE_ANY} -p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_ANY} -p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_OWN} -p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY} -p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.UPDATE_ANY} - -# My Servers permissions -p, ${Role.MY_SERVERS}, ${Resource.ARRAY}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.CONFIG}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.CONNECT}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.UPDATE_ANY} -p, ${Role.MY_SERVERS}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.DASHBOARD}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, docker/container, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.DOCKER}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.INFO}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.LOGS}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.NETWORK}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.SERVICES}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.VARS}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, ${Resource.VMS}, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, vms/domain, ${AuthAction.READ_ANY} -p, ${Role.MY_SERVERS}, unraid-version, ${AuthAction.READ_ANY} - -# Notifier permissions -p, ${Role.NOTIFIER}, ${Resource.NOTIFICATIONS}, ${AuthAction.CREATE_OWN} +# Connect Permissions +p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY} +p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY} # Guest permissions p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY} -p, ${Role.GUEST}, ${Resource.WELCOME}, ${AuthAction.READ_ANY} # Role inheritance g, ${Role.ADMIN}, ${Role.GUEST} -g, ${Role.UPC}, ${Role.GUEST} -g, ${Role.MY_SERVERS}, ${Role.GUEST} -g, ${Role.NOTIFIER}, ${Role.GUEST} +g, ${Role.CONNECT}, ${Role.GUEST} `; diff --git a/api/src/unraid-api/graph/connect/connect.resolver.ts b/api/src/unraid-api/graph/connect/connect.resolver.ts index db8b4d4132..fc1e2b642d 100644 --- a/api/src/unraid-api/graph/connect/connect.resolver.ts +++ b/api/src/unraid-api/graph/connect/connect.resolver.ts @@ -8,7 +8,7 @@ import type { DynamicRemoteAccessStatus, EnableDynamicRemoteAccessInput, } from '@app/graphql/generated/api/types'; -import { ConnectResolvers, DynamicRemoteAccessType } from '@app/graphql/generated/api/types'; +import { ConnectResolvers, DynamicRemoteAccessType, Resource } from '@app/graphql/generated/api/types'; import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller'; import { store } from '@app/store/index'; import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access'; @@ -20,7 +20,7 @@ export class ConnectResolver implements ConnectResolvers { @Query('connect') @UsePermissions({ action: AuthActionVerb.READ, - resource: 'connect/dynamic-remote-access', + resource: Resource.CONNECT, possession: AuthPossession.ANY, }) public connect() { @@ -46,7 +46,7 @@ export class ConnectResolver implements ConnectResolvers { @Mutation() @UsePermissions({ action: AuthActionVerb.UPDATE, - resource: 'connect/dynamic-remote-access', + resource: Resource.CONNECT__REMOTE_ACCESS, possession: AuthPossession.ANY, }) public async enableDynamicRemoteAccess(