From 674323fd87bbcc55932e6b28f6433a2de79b7ab0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 27 Aug 2025 12:37:39 -0400 Subject: [PATCH] feat: generated UI API key management + OAuth-like API Key Flows (#1609) ## Summary by CodeRabbit * **New Features** * API Key Authorization flow with consent screen, callback support, and a Tools page. * Schema-driven API Key creation UI with permission presets, templates, and Developer Authorization Link. * Effective Permissions preview and a new multi-select permission control. * **UI Improvements** * Mask/toggle API keys, copy-to-clipboard with toasts, improved select labels, new label styles, tab wrapping, and accordionized color controls. * **Documentation** * Public guide for the API Key authorization flow and scopes added. --- CLAUDE.md | 3 +- ...pi-key-app-developer-authorization-flow.md | 100 +++ api/generated-schema.graphql | 211 +++--- .../unraid-api/auth/api-key.service.spec.ts | 190 ++++-- api/src/unraid-api/auth/api-key.service.ts | 79 ++- api/src/unraid-api/auth/auth.service.spec.ts | 285 +++++++- api/src/unraid-api/auth/auth.service.ts | 101 ++- api/src/unraid-api/auth/casbin/model.ts | 6 +- .../casbin/permissions-comprehensive.spec.ts | 566 ++++++++++++++++ api/src/unraid-api/auth/casbin/policy.spec.ts | 147 ++++ api/src/unraid-api/auth/casbin/policy.ts | 22 +- .../unraid-api/cli/apikey/api-key.command.ts | 5 +- api/src/unraid-api/cli/generated/graphql.ts | 116 +++- .../unraid-api/graph/auth/auth-action.enum.ts | 3 + api/src/unraid-api/graph/auth/auth.enums.ts | 55 +- api/src/unraid-api/graph/graph.module.ts | 4 + .../api-key/api-key-form.resolver.ts | 25 + .../api-key/api-key-form.service.spec.ts | 247 +++++++ .../resolvers/api-key/api-key-form.service.ts | 374 +++++++++++ .../api-key/api-key-permissions.resolver.ts | 120 ++++ .../graph/resolvers/api-key/api-key.model.ts | 27 +- .../graph/resolvers/api-key/api-key.module.ts | 15 +- .../api-key/api-key.mutation.spec.ts | 18 +- .../resolvers/api-key/api-key.mutation.ts | 34 +- .../api-key/api-key.resolver.spec.ts | 15 +- .../resolvers/api-key/api-key.resolver.ts | 30 +- .../array/array.mutations.resolver.ts | 26 +- .../graph/resolvers/array/array.resolver.ts | 14 +- .../array/parity.mutations.resolver.ts | 20 +- .../graph/resolvers/array/parity.resolver.ts | 14 +- .../graph/resolvers/config/config.resolver.ts | 11 +- .../customization/customization.resolver.ts | 14 +- .../graph/resolvers/disks/disks.resolver.ts | 14 +- .../resolvers/display/display.resolver.ts | 14 +- .../docker/docker.mutations.resolver.ts | 14 +- .../graph/resolvers/docker/docker.resolver.ts | 32 +- .../graph/resolvers/flash/flash.resolver.ts | 11 +- .../graph/resolvers/info/info.resolver.ts | 11 +- .../graph/resolvers/logs/logs.resolver.ts | 17 +- .../resolvers/metrics/metrics.resolver.ts | 17 +- .../notifications/notifications.resolver.ts | 17 +- .../graph/resolvers/online/online.resolver.ts | 11 +- .../graph/resolvers/owner/owner.resolver.ts | 14 +- .../jsonforms/rclone-jsonforms-config.ts | 1 + .../rclone/rclone.mutation.resolver.ts | 14 +- .../graph/resolvers/rclone/rclone.resolver.ts | 11 +- .../registration/registration.resolver.ts | 11 +- .../resolvers/servers/server.resolver.ts | 17 +- .../resolvers/settings/settings.model.ts | 38 +- .../resolvers/settings/settings.resolver.ts | 11 +- .../graph/resolvers/sso/sso.resolver.ts | 22 +- .../graph/resolvers/vars/vars.resolver.ts | 11 +- .../resolvers/vms/vms.mutations.resolver.ts | 29 +- .../graph/resolvers/vms/vms.resolver.ts | 11 +- .../graph/services/services.resolver.ts | 11 +- .../graph/shares/shares.resolver.ts | 11 +- .../graph/user/user.resolver.spec.ts | 4 +- .../unraid-api/graph/user/user.resolver.ts | 11 +- api/src/unraid-api/graph/utils/form-utils.ts | 46 +- api/src/unraid-api/plugin/plugin.resolver.ts | 15 +- api/src/unraid-api/rest/rest.controller.ts | 10 +- .../src/authn/connect-api-key.service.ts | 17 +- .../src/connection-status/cloud.resolver.ts | 7 +- .../src/network/network.resolver.ts | 7 +- .../connect-settings.resolver.ts | 21 +- .../src/unraid-connect/connect.resolver.ts | 7 +- packages/unraid-shared/src/graphql-enums.ts | 107 +++ packages/unraid-shared/src/graphql.model.ts | 110 ++- packages/unraid-shared/src/index.ts | 1 + .../unraid-shared/src/services/api-key.ts | 23 +- .../src/use-permissions.directive.spec.ts | 314 +++++++++ .../src/use-permissions.directive.ts | 159 +++-- .../src/util/__tests__/permissions.test.ts | 221 ++++++ .../src/util/permissions-scopes.spec.ts | 84 +++ .../src/util/permissions.spec.ts | 234 +++++++ .../unraid-shared/src/util/permissions.ts | 378 +++++++++++ .../dynamix.my.servers/ApiKeyAuthorize.page | 7 + pnpm-lock.yaml | 8 +- unraid-ui/src/composables/useToast.ts | 99 +++ unraid-ui/src/forms/LabelRenderer.vue | 4 + unraid-ui/src/forms/MultiSelect.vue | 219 ++++++ unraid-ui/src/forms/ObjectArrayField.vue | 4 +- unraid-ui/src/forms/Select.vue | 7 +- unraid-ui/src/forms/renderers.ts | 17 + unraid-ui/src/index.ts | 2 + web/__test__/authorizationScopes.test.ts | 205 ++++++ web/__test__/components/ColorSwitcher.test.ts | 78 ++- web/__test__/components/UserProfile.test.ts | 18 + .../useApiKeyAuthorization.test.ts | 175 +++++ .../composables/useAuthorizationLink.test.ts | 342 ++++++++++ web/components/ApiKey/ApiKeyCreate.vue | 635 ++++++++++++------ web/components/ApiKey/ApiKeyManager.vue | 307 ++++++--- .../ApiKey/DeveloperAuthorizationLink.vue | 219 ++++++ .../ApiKey/EffectivePermissions.vue | 131 ++++ web/components/ApiKey/PermissionCounter.vue | 5 +- web/components/ApiKey/api-key-form.query.ts | 13 + web/components/ApiKey/apikey.query.ts | 29 +- .../ApiKey/permissions-preview.query.ts | 19 + web/components/ApiKeyAuthorize.ce.vue | 299 +++++++++ web/components/ColorSwitcher.ce.vue | 43 +- web/components/Modals.ce.vue | 4 +- web/components/sso/SsoProviderButton.vue | 113 +--- web/composables/gql/gql.ts | 36 +- web/composables/gql/graphql.ts | 158 +++-- web/composables/useApiKeyAuthorization.ts | 211 ++++++ web/composables/useApiKeyPermissionPresets.ts | 110 +++ web/composables/useApiKeyScopeGroups.ts | 248 +++++++ web/composables/useAuthorizationLink.ts | 139 ++++ web/composables/useClipboardWithToast.ts | 42 ++ web/layouts/default.vue | 82 +-- web/nuxt.config.ts | 1 + web/package.json | 2 +- web/pages/{apikey.vue => apikeys.vue} | 0 web/pages/changelog.vue | 2 - web/pages/index.vue | 13 +- web/pages/tools/apikeyauthorize.vue | 7 + web/store/apiKey.ts | 47 +- web/utils/authorizationLink.ts | 2 + web/utils/authorizationScopes.ts | 310 +++++++++ 119 files changed, 7996 insertions(+), 1459 deletions(-) create mode 100644 api/docs/public/api-key-app-developer-authorization-flow.md create mode 100644 api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts create mode 100644 api/src/unraid-api/auth/casbin/policy.spec.ts create mode 100644 api/src/unraid-api/graph/auth/auth-action.enum.ts create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key-form.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.ts create mode 100644 packages/unraid-shared/src/graphql-enums.ts create mode 100644 packages/unraid-shared/src/use-permissions.directive.spec.ts create mode 100644 packages/unraid-shared/src/util/__tests__/permissions.test.ts create mode 100644 packages/unraid-shared/src/util/permissions-scopes.spec.ts create mode 100644 packages/unraid-shared/src/util/permissions.spec.ts create mode 100644 packages/unraid-shared/src/util/permissions.ts create mode 100644 plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeyAuthorize.page create mode 100644 unraid-ui/src/composables/useToast.ts create mode 100644 unraid-ui/src/forms/MultiSelect.vue create mode 100644 web/__test__/authorizationScopes.test.ts create mode 100644 web/__test__/composables/useApiKeyAuthorization.test.ts create mode 100644 web/__test__/composables/useAuthorizationLink.test.ts create mode 100644 web/components/ApiKey/DeveloperAuthorizationLink.vue create mode 100644 web/components/ApiKey/EffectivePermissions.vue create mode 100644 web/components/ApiKey/api-key-form.query.ts create mode 100644 web/components/ApiKey/permissions-preview.query.ts create mode 100644 web/components/ApiKeyAuthorize.ce.vue create mode 100644 web/composables/useApiKeyAuthorization.ts create mode 100644 web/composables/useApiKeyPermissionPresets.ts create mode 100644 web/composables/useApiKeyScopeGroups.ts create mode 100644 web/composables/useAuthorizationLink.ts create mode 100644 web/composables/useClipboardWithToast.ts rename web/pages/{apikey.vue => apikeys.vue} (100%) create mode 100644 web/pages/tools/apikeyauthorize.vue create mode 100644 web/utils/authorizationLink.ts create mode 100644 web/utils/authorizationScopes.ts diff --git a/CLAUDE.md b/CLAUDE.md index c68ccb93a..5187c01e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,4 +156,5 @@ Enables GraphQL playground at `http://tower.local/graphql` ## Development Memories - We are using tailwind v4 we do not need a tailwind config anymore -- always search the internet for tailwind v4 documentation when making tailwind related style changes \ No newline at end of file +- always search the internet for tailwind v4 documentation when making tailwind related style changes +- never run or restart the API server or web server. I will handle the lifecylce, simply wait and ask me to do this for you \ No newline at end of file diff --git a/api/docs/public/api-key-app-developer-authorization-flow.md b/api/docs/public/api-key-app-developer-authorization-flow.md new file mode 100644 index 000000000..5ba049651 --- /dev/null +++ b/api/docs/public/api-key-app-developer-authorization-flow.md @@ -0,0 +1,100 @@ +# API Key Authorization Flow + +This document describes the self-service API key creation flow for third-party applications. + +## Overview + +Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click. + +## Flow + +1. **Application initiates request**: The app redirects the user to: + + ``` + https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123 + ``` + +2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth) + +3. **Consent screen**: User sees: + - Application name and description + - Requested permissions (with checkboxes to approve/deny specific scopes) + - API key name field (pre-filled) + - Authorize & Cancel buttons + +4. **API key creation**: Upon authorization: + - API key is created with approved scopes + - Key is displayed to the user + - If `redirect_uri` is provided, user is redirected back with the key + +5. **Callback**: App receives the API key: + ``` + https://myapp.com/callback?api_key=xxx&state=abc123 + ``` + +## Query Parameters + +- `name` (required): Name of the requesting application +- `description` (optional): Description of the application +- `scopes` (required): Comma-separated list of requested scopes +- `redirect_uri` (optional): URL to redirect after authorization +- `state` (optional): Opaque value for maintaining state + +## Scope Format + +Scopes follow the pattern: `resource:action` + +### Examples: + +- `docker:read` - Read access to Docker +- `vm:*` - Full access to VMs +- `system:update` - Update access to system +- `role:viewer` - Viewer role access +- `role:admin` - Admin role access + +### Available Resources: + +- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc. + +### Available Actions: + +- `create`, `read`, `update`, `delete` or `*` for all + +## Security Considerations + +1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development) +2. **User consent**: Users explicitly approve each permission +3. **Session-based**: Uses existing Unraid authentication session +4. **One-time display**: API keys are shown once and must be saved securely + +## Example Integration + +```javascript +// JavaScript example +const unraidServer = 'tower.local'; +const appName = 'My Docker Manager'; +const scopes = 'docker:*,system:read'; +const redirectUri = 'https://myapp.com/unraid/callback'; +const state = generateRandomState(); + +// Store state for verification +sessionStorage.setItem('oauth_state', state); + +// Redirect user to authorization page +window.location.href = + `https://${unraidServer}/ApiKeyAuthorize?` + + `name=${encodeURIComponent(appName)}&` + + `scopes=${encodeURIComponent(scopes)}&` + + `redirect_uri=${encodeURIComponent(redirectUri)}&` + + `state=${encodeURIComponent(state)}`; + +// Handle callback +const urlParams = new URLSearchParams(window.location.search); +const apiKey = urlParams.get('api_key'); +const returnedState = urlParams.get('state'); + +if (returnedState === sessionStorage.getItem('oauth_state')) { + // Save API key securely + saveApiKey(apiKey); +} +``` diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 5ed0cb6af..b996b8ffc 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -4,14 +4,11 @@ """Directive to document required permissions for fields""" directive @usePermissions( - """The action verb required for access""" - action: AuthActionVerb + """The action required for access (must be a valid AuthAction enum value)""" + action: String - """The resource required for access""" + """The resource required for access (must be a valid Resource enum value)""" resource: String - - """The possession type required for access""" - possession: AuthPossession ) on FIELD_DEFINITION type ParityCheck { @@ -615,7 +612,9 @@ enum ConfigErrorState { type Permission { resource: Resource! - actions: [String!]! + + """Actions allowed on this resource""" + actions: [AuthAction!]! } """Available resources for permissions""" @@ -651,8 +650,36 @@ enum Resource { WELCOME } +"""Authentication actions with possession (e.g., create:any, read:own)""" +enum AuthAction { + """Create any resource""" + CREATE_ANY + + """Create own resource""" + CREATE_OWN + + """Read any resource""" + READ_ANY + + """Read own resource""" + READ_OWN + + """Update any resource""" + UPDATE_ANY + + """Update own resource""" + UPDATE_OWN + + """Delete any resource""" + DELETE_ANY + + """Delete own resource""" + DELETE_OWN +} + type ApiKey implements Node { id: PrefixedID! + key: String! name: String! description: String roles: [Role!]! @@ -662,20 +689,90 @@ type ApiKey implements Node { """Available roles for API keys and users""" enum Role { + """Full administrative access to all resources""" ADMIN - USER + + """Internal Role for Unraid Connect""" CONNECT + + """Basic read access to user profile only""" GUEST + + """Read-only access to all resources""" + VIEWER } -type ApiKeyWithSecret implements Node { +type SsoSettings implements Node { id: PrefixedID! - name: String! - description: String - roles: [Role!]! - createdAt: String! - permissions: [Permission!]! - key: String! + + """List of configured OIDC providers""" + oidcProviders: [OidcProvider!]! +} + +type UnifiedSettings implements Node & FormSchema { + id: PrefixedID! + + """The data schema for the settings""" + dataSchema: JSON! + + """The UI schema for the settings""" + uiSchema: JSON! + + """The current values of the settings""" + values: JSON! +} + +interface FormSchema { + """The data schema for the form""" + dataSchema: JSON! + + """The UI schema for the form""" + uiSchema: JSON! + + """The current values of the form""" + values: JSON! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type ApiKeyFormSettings implements Node & FormSchema { + id: PrefixedID! + + """The data schema for the API key form""" + dataSchema: JSON! + + """The UI schema for the API key form""" + uiSchema: JSON! + + """The current values of the API key form""" + values: JSON! +} + +type UpdateSettingsResponse { + """Whether a restart is required for the changes to take effect""" + restartRequired: Boolean! + + """The updated settings values""" + values: JSON! + + """Warning messages about configuration issues found during validation""" + warnings: [String!] +} + +type Settings implements Node { + id: PrefixedID! + + """A view of all settings""" + unified: UnifiedSettings! + + """SSO settings""" + sso: SsoSettings! + + """The API setting values""" + api: ApiConfig! } type RCloneDrive { @@ -686,11 +783,6 @@ type RCloneDrive { options: JSON! } -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - type RCloneBackupConfigForm { id: ID! dataSchema: JSON! @@ -792,7 +884,7 @@ type VmMutations { """API Key related mutations""" type ApiKeyMutations { """Create an API key""" - create(input: CreateApiKeyInput!): ApiKeyWithSecret! + create(input: CreateApiKeyInput!): ApiKey! """Add a role to an API key""" addRole(input: AddRoleForApiKeyInput!): Boolean! @@ -804,7 +896,7 @@ type ApiKeyMutations { delete(input: DeleteApiKeyInput!): Boolean! """Update an API key""" - update(input: UpdateApiKeyInput!): ApiKeyWithSecret! + update(input: UpdateApiKeyInput!): ApiKey! } input CreateApiKeyInput { @@ -821,7 +913,7 @@ input CreateApiKeyInput { input AddPermissionInput { resource: Resource! - actions: [String!]! + actions: [AuthAction!]! } input AddRoleForApiKeyInput { @@ -1727,50 +1819,6 @@ type ApiConfig { plugins: [String!]! } -type SsoSettings implements Node { - id: PrefixedID! - - """List of configured OIDC providers""" - oidcProviders: [OidcProvider!]! -} - -type UnifiedSettings implements Node { - id: PrefixedID! - - """The data schema for the settings""" - dataSchema: JSON! - - """The UI schema for the settings""" - uiSchema: JSON! - - """The current values of the settings""" - values: JSON! -} - -type UpdateSettingsResponse { - """Whether a restart is required for the changes to take effect""" - restartRequired: Boolean! - - """The updated settings values""" - values: JSON! - - """Warning messages about configuration issues found during validation""" - warnings: [String!] -} - -type Settings implements Node { - id: PrefixedID! - - """A view of all settings""" - unified: UnifiedSettings! - - """SSO settings""" - sso: SsoSettings! - - """The API setting values""" - api: ApiConfig! -} - type OidcAuthorizationRule { """The claim to check (e.g., email, sub, groups, hd)""" claim: String! @@ -2243,6 +2291,20 @@ type Query { """All possible permissions for API keys""" apiKeyPossiblePermissions: [Permission!]! + + """Get the actual permissions that would be granted by a set of roles""" + getPermissionsForRoles(roles: [Role!]!): [Permission!]! + + """ + Preview the effective permissions for a combination of roles and explicit permissions + """ + previewEffectivePermissions(roles: [Role!], permissions: [AddPermissionInput!]): [Permission!]! + + """Get all available authentication actions with possession""" + getAvailableAuthActions: [AuthAction!]! + + """Get JSON Schema for API key creation form""" + getApiKeyCreationFormSchema: ApiKeyFormSettings! config: Config! flash: Flash! logFiles: [LogFile!]! @@ -2538,19 +2600,4 @@ type Subscription { systemMetricsCpu: CpuUtilization! systemMetricsMemory: MemoryUtilization! upsUpdates: UPSDevice! -} - -"""Available authentication action verbs""" -enum AuthActionVerb { - CREATE - UPDATE - DELETE - READ -} - -"""Available authentication possession types""" -enum AuthPossession { - ANY - OWN - OWN_ANY } \ No newline at end of file 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 3af805849..109635c9f 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -2,15 +2,14 @@ import { Logger } from '@nestjs/common'; import { readdir, readFile, writeFile } from 'fs/promises'; import { join } from 'path'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; import { ensureDir, ensureDirSync } from 'fs-extra'; -import { AuthActionVerb } from 'nest-authz'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { environment } from '@app/environment.js'; import { getters } from '@app/store/index.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; -import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; // Mock the store and its modules vi.mock('@app/store/index.js', () => ({ @@ -48,28 +47,14 @@ describe('ApiKeyService', () => { const mockApiKey: ApiKey = { id: 'test-api-id', + key: 'test-secret-key', name: 'Test API Key', description: 'Test API Key Description', roles: [Role.GUEST], permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], - }, - ], - createdAt: new Date().toISOString(), - }; - - const mockApiKeyWithSecret: ApiKeyWithSecret = { - id: 'test-api-id', - key: 'test-api-key', - name: 'Test API Key', - description: 'Test API Key Description', - roles: [Role.GUEST], - permissions: [ - { - resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], + actions: [AuthAction.READ_ANY], }, ], createdAt: new Date().toISOString(), @@ -130,21 +115,23 @@ describe('ApiKeyService', () => { }); describe('create', () => { - it('should create ApiKeyWithSecret with generated key', async () => { + it('should create ApiKey with generated key', async () => { const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue(); - const { key, id, description, roles } = mockApiKeyWithSecret; + const { id, description, roles } = mockApiKey; const name = 'Test API Key'; const result = await apiKeyService.create({ name, description: description ?? '', roles }); expect(result).toMatchObject({ id, - key, name: name, description, roles, createdAt: expect.any(String), }); + expect(result.key).toBeDefined(); + expect(typeof result.key).toBe('string'); + expect(result.key.length).toBeGreaterThan(0); expect(saveSpy).toHaveBeenCalledWith(result); }); @@ -177,8 +164,8 @@ describe('ApiKeyService', () => { describe('findAll', () => { it('should return all API keys', async () => { vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([ - mockApiKeyWithSecret, - { ...mockApiKeyWithSecret, id: 'second-id' }, + mockApiKey, + { ...mockApiKey, id: 'second-id' }, ]); await apiKeyService.onModuleInit(); @@ -191,7 +178,7 @@ describe('ApiKeyService', () => { permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], + actions: [AuthAction.READ_ANY], }, ], }; @@ -202,7 +189,7 @@ describe('ApiKeyService', () => { permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], + actions: [AuthAction.READ_ANY], }, ], }; @@ -219,17 +206,17 @@ describe('ApiKeyService', () => { describe('findById', () => { it('should return API key by id when found', async () => { - vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]); + vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]); await apiKeyService.onModuleInit(); - const result = await apiKeyService.findById(mockApiKeyWithSecret.id); + const result = await apiKeyService.findById(mockApiKey.id); expect(result).toMatchObject({ ...mockApiKey, createdAt: expect.any(String) }); }); it('should return null if API key not found', async () => { vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([ - { ...mockApiKeyWithSecret, id: 'different-id' }, + { ...mockApiKey, id: 'different-id' }, ]); await apiKeyService.onModuleInit(); @@ -241,12 +228,12 @@ describe('ApiKeyService', () => { describe('findByIdWithSecret', () => { it('should return API key with secret when found', async () => { - vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]); + vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]); await apiKeyService.onModuleInit(); - const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id); + const result = await apiKeyService.findByIdWithSecret(mockApiKey.id); - expect(result).toEqual(mockApiKeyWithSecret); + expect(result).toEqual(mockApiKey); }); it('should return null when API key not found', async () => { @@ -274,23 +261,20 @@ describe('ApiKeyService', () => { describe('findByKey', () => { it('should return API key by key value when multiple keys exist', async () => { - const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' }; - vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([ - differentKey, - mockApiKeyWithSecret, - ]); + const differentKey = { ...mockApiKey, key: 'different-key' }; + vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([differentKey, mockApiKey]); await apiKeyService.onModuleInit(); - const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key); + const result = await apiKeyService.findByKey(mockApiKey.key); - expect(result).toEqual(mockApiKeyWithSecret); + expect(result).toEqual(mockApiKey); }); it('should return null if key not found in any file', async () => { vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([ - { ...mockApiKeyWithSecret, key: 'different-key-1' }, - { ...mockApiKeyWithSecret, key: 'different-key-2' }, + { ...mockApiKey, key: 'different-key-1' }, + { ...mockApiKey, key: 'different-key-2' }, ]); await apiKeyService.onModuleInit(); @@ -314,21 +298,21 @@ describe('ApiKeyService', () => { it('should save API key to file', async () => { vi.mocked(writeFile).mockResolvedValue(undefined); - await apiKeyService.saveApiKey(mockApiKeyWithSecret); + await apiKeyService.saveApiKey(mockApiKey); const writeFileCalls = vi.mocked(writeFile).mock.calls; expect(writeFileCalls.length).toBe(1); const [filePath, fileContent] = writeFileCalls[0] ?? []; - const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`); + const expectedPath = join(mockBasePath, `${mockApiKey.id}.json`); expect(filePath).toBe(expectedPath); if (typeof fileContent === 'string') { const savedApiKey = JSON.parse(fileContent); - expect(savedApiKey).toEqual(mockApiKeyWithSecret); + expect(savedApiKey).toEqual(mockApiKey); } else { throw new Error('File content should be a string'); } @@ -337,16 +321,16 @@ describe('ApiKeyService', () => { it('should throw GraphQLError on write error', async () => { vi.mocked(writeFile).mockRejectedValue(new Error('Write failed')); - await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow( + await expect(apiKeyService.saveApiKey(mockApiKey)).rejects.toThrow( 'Failed to save API key: Write failed' ); }); it('should throw GraphQLError on invalid API key structure', async () => { const invalidApiKey = { - ...mockApiKeyWithSecret, + ...mockApiKey, name: '', // Invalid: name cannot be empty - } as ApiKeyWithSecret; + } as ApiKey; await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow( 'Failed to save API key: Invalid data structure' @@ -355,10 +339,10 @@ describe('ApiKeyService', () => { it('should throw GraphQLError when roles and permissions array is empty', async () => { const invalidApiKey = { - ...mockApiKeyWithSecret, + ...mockApiKey, permissions: [], roles: [], - } as ApiKeyWithSecret; + } as ApiKey; await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow( 'At least one of permissions or roles must be specified' @@ -367,7 +351,7 @@ describe('ApiKeyService', () => { }); describe('update', () => { - let updateMockApiKey: ApiKeyWithSecret; + let updateMockApiKey: ApiKey; beforeEach(() => { // Create a fresh copy of the mock data for update tests @@ -380,7 +364,7 @@ describe('ApiKeyService', () => { permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], + actions: [AuthAction.READ_ANY], }, ], createdAt: new Date().toISOString(), @@ -427,7 +411,7 @@ describe('ApiKeyService', () => { const updatedPermissions = [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE], + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY], }, ]; @@ -474,7 +458,7 @@ describe('ApiKeyService', () => { }); describe('loadAllFromDisk', () => { - let loadMockApiKey: ApiKeyWithSecret; + let loadMockApiKey: ApiKey; beforeEach(() => { // Create a fresh copy of the mock data for loadAllFromDisk tests @@ -487,7 +471,7 @@ describe('ApiKeyService', () => { permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ], + actions: [AuthAction.READ_ANY], }, ], createdAt: new Date().toISOString(), @@ -550,15 +534,62 @@ describe('ApiKeyService', () => { key: 'unique-key', }); }); + + it('should normalize permission actions to lowercase when loading from disk', async () => { + const apiKeyWithMixedCaseActions = { + ...loadMockApiKey, + permissions: [ + { + resource: Resource.DOCKER, + actions: ['READ:ANY', 'Update:Any', 'create:any', 'DELETE:ANY'], // Mixed case actions + }, + { + resource: Resource.ARRAY, + actions: ['Read:Any'], // Mixed case + }, + ], + }; + + vi.mocked(readdir).mockResolvedValue(['key1.json'] as any); + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseActions)); + + const result = await apiKeyService.loadAllFromDisk(); + + expect(result).toHaveLength(1); + // All actions should be normalized to lowercase + expect(result[0].permissions[0].actions).toEqual([ + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.CREATE_ANY, + AuthAction.DELETE_ANY, + ]); + expect(result[0].permissions[1].actions).toEqual([AuthAction.READ_ANY]); + }); + + it('should normalize roles to uppercase when loading from disk', async () => { + const apiKeyWithMixedCaseRoles = { + ...loadMockApiKey, + roles: ['admin', 'Viewer', 'CONNECT'], // Mixed case roles + }; + + vi.mocked(readdir).mockResolvedValue(['key1.json'] as any); + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseRoles)); + + const result = await apiKeyService.loadAllFromDisk(); + + expect(result).toHaveLength(1); + // All roles should be normalized to uppercase + expect(result[0].roles).toEqual(['ADMIN', 'VIEWER', 'CONNECT']); + }); }); describe('loadApiKeyFile', () => { it('should load and parse a valid API key file', async () => { - vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret)); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey)); const result = await apiKeyService['loadApiKeyFile']('test.json'); - expect(result).toEqual(mockApiKeyWithSecret); + expect(result).toEqual(mockApiKey); expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8'); }); @@ -592,7 +623,7 @@ describe('ApiKeyService', () => { expect.stringContaining('Error validating API key file test.json') ); expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('An instance of ApiKeyWithSecret has failed the validation') + expect.stringContaining('An instance of ApiKey has failed the validation') ); expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property key')); expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property id')); @@ -603,5 +634,50 @@ describe('ApiKeyService', () => { expect.stringContaining('property permissions') ); }); + + it('should normalize legacy action formats when loading API keys', async () => { + const legacyApiKey = { + ...mockApiKey, + permissions: [ + { + resource: Resource.DOCKER, + actions: ['create', 'READ', 'Update', 'DELETE'], // Mixed case legacy verbs + }, + { + resource: Resource.VMS, + actions: ['READ_ANY', 'UPDATE_OWN'], // GraphQL enum style + }, + { + resource: Resource.CONNECT, + actions: ['read:own', 'update:any'], // Casbin colon format + }, + ], + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(legacyApiKey)); + + const result = await apiKeyService['loadApiKeyFile']('legacy.json'); + + expect(result).not.toBeNull(); + expect(result?.permissions).toEqual([ + { + resource: Resource.DOCKER, + actions: [ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ], + }, + { + resource: Resource.VMS, + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN], + }, + { + resource: Resource.CONNECT, + actions: [AuthAction.READ_OWN, AuthAction.UPDATE_ANY], + }, + ]); + }); }); }); diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index c0a90cbd6..1108485b2 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -3,12 +3,12 @@ import crypto from 'crypto'; import { readdir, readFile, unlink, writeFile } from 'fs/promises'; import { join } from 'path'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { normalizeLegacyActions } from '@unraid/shared/util/permissions.js'; import { watch } from 'chokidar'; import { ValidationError } from 'class-validator'; import { ensureDirSync } from 'fs-extra'; import { GraphQLError } from 'graphql'; -import { AuthActionVerb } from 'nest-authz'; import { v4 as uuidv4 } from 'uuid'; import { environment } from '@app/environment.js'; @@ -16,7 +16,6 @@ import { getters } from '@app/store/index.js'; import { AddPermissionInput, ApiKey, - ApiKeyWithSecret, Permission, } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; @@ -26,7 +25,7 @@ import { batchProcess } from '@app/utils.js'; export class ApiKeyService implements OnModuleInit { private readonly logger = new Logger(ApiKeyService.name); protected readonly basePath: string; - protected memoryApiKeys: Array = []; + protected memoryApiKeys: Array = []; private static readonly validRoles: Set = new Set(Object.values(Role)); constructor() { @@ -41,18 +40,8 @@ export class ApiKeyService implements OnModuleInit { } } - public convertApiKeyWithSecretToApiKey(key: ApiKeyWithSecret): ApiKey { - const { key: _, ...rest } = key; - return rest; - } - public async findAll(): Promise { - return Promise.all( - this.memoryApiKeys.map(async (key) => { - const keyWithoutSecret = this.convertApiKeyWithSecretToApiKey(key); - return keyWithoutSecret; - }) - ); + return this.memoryApiKeys; } private setupWatch() { @@ -76,17 +65,18 @@ export class ApiKeyService implements OnModuleInit { public getAllValidPermissions(): Permission[] { return Object.values(Resource).map((res) => ({ resource: res, - actions: Object.values(AuthActionVerb), + actions: Object.values(AuthAction), })); } public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] { return permissions.reduce>((acc, permission) => { - const [resource, action] = permission.split(':'); + const [resource, ...actionParts] = permission.split(':'); + const action = actionParts.join(':'); // Handle actions like "read:any" const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null; // Pull the actual enum value from the graphql schema const validatedAction = - AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null; + AuthAction[action.toUpperCase().replace(':', '_') as keyof typeof AuthAction] ?? null; if (validatedAction && validatedResource) { const existingEntry = acc.find((p) => p.resource === validatedResource); if (existingEntry) { @@ -119,7 +109,7 @@ export class ApiKeyService implements OnModuleInit { roles?: Role[]; permissions?: Permission[] | AddPermissionInput[]; overwrite?: boolean; - }): Promise { + }): Promise { const trimmedName = name?.trim(); const sanitizedName = this.sanitizeName(trimmedName); @@ -139,7 +129,7 @@ export class ApiKeyService implements OnModuleInit { if (!overwrite && existingKey) { return existingKey; } - const apiKey: Partial = { + const apiKey: Partial = { id: uuidv4(), key: this.generateApiKey(), name: sanitizedName, @@ -152,18 +142,18 @@ export class ApiKeyService implements OnModuleInit { // Update createdAt date apiKey.createdAt = new Date().toISOString(); - await this.saveApiKey(apiKey as ApiKeyWithSecret); + await this.saveApiKey(apiKey as ApiKey); - return apiKey as ApiKeyWithSecret; + return apiKey as ApiKey; } - async loadAllFromDisk(): Promise { + async loadAllFromDisk(): Promise { const files = await readdir(this.basePath).catch((error) => { this.logger.error(`Failed to read API key directory: ${error}`); throw new Error('Failed to list API keys'); }); - const apiKeys: ApiKeyWithSecret[] = []; + const apiKeys: ApiKey[] = []; const jsonFiles = files.filter((file) => file.includes('.json')); for (const file of jsonFiles) { @@ -186,7 +176,7 @@ export class ApiKeyService implements OnModuleInit { * @param file The file to load * @returns The API key with secret */ - private async loadApiKeyFile(file: string): Promise { + private async loadApiKeyFile(file: string): Promise { try { const content = await readFile(join(this.basePath, file), 'utf8'); @@ -196,7 +186,17 @@ export class ApiKeyService implements OnModuleInit { if (parsedContent.roles) { parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase()); } - return await validateObject(ApiKeyWithSecret, parsedContent); + + // Normalize permission actions to AuthAction enum values + // Uses shared helper to handle all legacy formats + if (parsedContent.permissions) { + parsedContent.permissions = parsedContent.permissions.map((permission: any) => ({ + ...permission, + actions: normalizeLegacyActions(permission.actions || []), + })); + } + + return await validateObject(ApiKey, parsedContent); } catch (error) { if (error instanceof SyntaxError) { this.logger.error(`Corrupted key file: ${file}`); @@ -216,12 +216,7 @@ export class ApiKeyService implements OnModuleInit { async findById(id: string): Promise { try { - const key = this.findByField('id', id); - - if (key) { - return this.convertApiKeyWithSecretToApiKey(key); - } - return null; + return this.findByField('id', id); } catch (error) { if (error instanceof ValidationError) { this.logApiKeyValidationError(id, error); @@ -231,17 +226,17 @@ export class ApiKeyService implements OnModuleInit { } } - public findByIdWithSecret(id: string): ApiKeyWithSecret | null { + public findByIdWithSecret(id: string): ApiKey | null { return this.findByField('id', id); } - public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null { + public findByField(field: keyof ApiKey, value: string): ApiKey | null { if (!value) return null; return this.memoryApiKeys.find((k) => k[field] === value) ?? null; } - findByKey(key: string): ApiKeyWithSecret | null { + findByKey(key: string): ApiKey | null { return this.findByField('key', key); } @@ -254,9 +249,9 @@ export class ApiKeyService implements OnModuleInit { Errors: ${JSON.stringify(error.constraints, null, 2)}`); } - public async saveApiKey(apiKey: ApiKeyWithSecret): Promise { + public async saveApiKey(apiKey: ApiKey): Promise { try { - const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey); + const validatedApiKey = await validateObject(ApiKey, apiKey); if (!validatedApiKey.permissions?.length && !validatedApiKey.roles?.length) { throw new GraphQLError('At least one of permissions or roles must be specified'); } @@ -266,7 +261,7 @@ export class ApiKeyService implements OnModuleInit { .reduce((acc, key) => { acc[key] = validatedApiKey[key]; return acc; - }, {} as ApiKeyWithSecret); + }, {} as ApiKey); await writeFile( join(this.basePath, `${validatedApiKey.id}.json`), @@ -334,7 +329,7 @@ export class ApiKeyService implements OnModuleInit { description?: string; roles?: Role[]; permissions?: Permission[] | AddPermissionInput[]; - }): Promise { + }): Promise { const apiKey = this.findByIdWithSecret(id); if (!apiKey) { throw new GraphQLError('API key not found'); @@ -345,13 +340,15 @@ export class ApiKeyService implements OnModuleInit { if (description !== undefined) { apiKey.description = description; } - if (roles) { + if (roles !== undefined) { + // Handle both empty array (to clear roles) and populated array if (roles.some((role) => !ApiKeyService.validRoles.has(role))) { throw new GraphQLError('Invalid role specified'); } apiKey.roles = roles; } - if (permissions) { + if (permissions !== undefined) { + // Handle both empty array (to clear permissions) and populated array apiKey.permissions = permissions; } await this.saveApiKey(apiKey); diff --git a/api/src/unraid-api/auth/auth.service.spec.ts b/api/src/unraid-api/auth/auth.service.spec.ts index ff61465a4..62178b470 100644 --- a/api/src/unraid-api/auth/auth.service.spec.ts +++ b/api/src/unraid-api/auth/auth.service.spec.ts @@ -1,14 +1,14 @@ import { UnauthorizedException } from '@nestjs/common'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; import { newEnforcer } from 'casbin'; -import { AuthActionVerb, AuthZService } from 'nest-authz'; +import { AuthZService } from 'nest-authz'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { CookieService } from '@app/unraid-api/auth/cookie.service.js'; -import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { UserAccount } from '@app/unraid-api/graph/user/user.model.js'; import { FastifyRequest } from '@app/unraid-api/types/fastify.js'; @@ -19,15 +19,6 @@ describe('AuthService', () => { let cookieService: CookieService; const mockApiKey: ApiKey = { - id: '10f356da-1e9e-43b8-9028-a26a645539a6', - name: 'Test API Key', - description: 'Test API Key Description', - roles: [Role.GUEST, Role.CONNECT], - createdAt: new Date().toISOString(), - permissions: [], - }; - - const mockApiKeyWithSecret: ApiKeyWithSecret = { id: 'test-api-id', key: 'test-api-key', name: 'Test API Key', @@ -36,7 +27,7 @@ describe('AuthService', () => { permissions: [ { resource: Resource.CONNECT, - actions: [AuthActionVerb.READ.toUpperCase()], + actions: [AuthAction.READ_ANY], }, ], createdAt: new Date().toISOString(), @@ -98,6 +89,43 @@ describe('AuthService', () => { ); }); + it('should validate API key with only permissions (no roles)', async () => { + const apiKeyWithOnlyPermissions: ApiKey = { + ...mockApiKey, + roles: [], // No roles, only permissions + permissions: [ + { + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY], + }, + { + resource: Resource.VMS, + actions: [AuthAction.READ_ANY], + }, + ], + }; + + vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(apiKeyWithOnlyPermissions); + vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(undefined); + vi.spyOn(authService, 'syncApiKeyPermissions').mockResolvedValue(undefined); + vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]); + + const result = await authService.validateApiKeyCasbin('test-api-key'); + + expect(result).toEqual({ + id: apiKeyWithOnlyPermissions.id, + name: apiKeyWithOnlyPermissions.name, + description: apiKeyWithOnlyPermissions.description, + roles: [], + permissions: apiKeyWithOnlyPermissions.permissions, + }); + expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(apiKeyWithOnlyPermissions.id, []); + expect(authService.syncApiKeyPermissions).toHaveBeenCalledWith( + apiKeyWithOnlyPermissions.id, + apiKeyWithOnlyPermissions.permissions + ); + }); + it('should throw UnauthorizedException when session user is missing', async () => { vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true); vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount); @@ -196,7 +224,7 @@ describe('AuthService', () => { vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole); vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({ - ...mockApiKeyWithSecret, + ...mockApiKey, roles: [Role.ADMIN], }); vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue(); @@ -208,7 +236,7 @@ describe('AuthService', () => { expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId); expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId); expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({ - ...mockApiKeyWithSecret, + ...mockApiKey, roles: [Role.ADMIN, role], }); expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role); @@ -227,7 +255,7 @@ describe('AuthService', () => { it('should remove role from API key', async () => { const apiKey = { ...mockApiKey, roles: [Role.ADMIN, Role.GUEST] }; const apiKeyWithSecret = { - ...mockApiKeyWithSecret, + ...mockApiKey, roles: [Role.ADMIN, Role.GUEST], }; @@ -256,4 +284,229 @@ describe('AuthService', () => { ); }); }); + + describe('VIEWER role API_KEY access restriction', () => { + it('should deny VIEWER role access to API_KEY resource', async () => { + // Test that VIEWER role cannot access API_KEY resource + const mockCasbinPermissions = Object.values(Resource) + .filter((resource) => resource !== Resource.API_KEY) + .map((resource) => ['VIEWER', resource, AuthAction.READ_ANY]); + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.VIEWER); + + // VIEWER should have read access to all resources EXCEPT API_KEY + expect(result).toBeInstanceOf(Map); + expect(result.size).toBeGreaterThan(0); + + // Should NOT have API_KEY in the permissions + expect(result.has(Resource.API_KEY)).toBe(false); + + // Should have read access to other resources + expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]); + expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]); + expect(result.get(Resource.CONFIG)).toEqual([AuthAction.READ_ANY]); + expect(result.get(Resource.ME)).toEqual([AuthAction.READ_ANY]); + }); + + it('should allow ADMIN role access to API_KEY resource', async () => { + // Test that ADMIN role CAN access API_KEY resource + const mockCasbinPermissions = [ + ['ADMIN', '*', '*'], // Admin has wildcard access + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + // ADMIN should have access to API_KEY through wildcard + expect(result).toBeInstanceOf(Map); + expect(result.has(Resource.API_KEY)).toBe(true); + expect(result.get(Resource.API_KEY)).toContain(AuthAction.CREATE_ANY); + expect(result.get(Resource.API_KEY)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.API_KEY)).toContain(AuthAction.UPDATE_ANY); + expect(result.get(Resource.API_KEY)).toContain(AuthAction.DELETE_ANY); + }); + }); + + describe('getImplicitPermissionsForRole', () => { + it('should return permissions for a role', async () => { + const mockCasbinPermissions = [ + ['ADMIN', 'DOCKER', 'READ'], + ['ADMIN', 'DOCKER', 'UPDATE'], + ['ADMIN', 'VMS', 'READ'], + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]); + expect(result.get(Resource.VMS)).toEqual([AuthAction.READ_ANY]); + }); + + it('should handle wildcard permissions for admin role', async () => { + const mockCasbinPermissions = [ + ['ADMIN', '*', '*'], + ['ADMIN', 'ME', 'READ'], // Inherited from GUEST + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBeGreaterThan(0); + // Should have expanded CRUD actions with proper format for all resources + expect(result.get(Resource.DOCKER)).toContain(AuthAction.CREATE_ANY); + expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.DOCKER)).toContain(AuthAction.UPDATE_ANY); + expect(result.get(Resource.DOCKER)).toContain(AuthAction.DELETE_ANY); + expect(result.get(Resource.VMS)).toContain(AuthAction.CREATE_ANY); + expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.VMS)).toContain(AuthAction.UPDATE_ANY); + expect(result.get(Resource.VMS)).toContain(AuthAction.DELETE_ANY); + expect(result.get(Resource.ME)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.ME)).toContain(AuthAction.CREATE_ANY); // Also gets CRUD from wildcard + expect(result.has('*' as any)).toBe(false); // Still shouldn't have literal wildcard + }); + + it('should handle connect role with wildcard resource and specific action', async () => { + const mockCasbinPermissions = [ + ['CONNECT', '*', 'READ'], + ['CONNECT', 'CONNECT__REMOTE_ACCESS', 'UPDATE'], + ['CONNECT', 'ME', 'READ'], // Inherited from GUEST + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.CONNECT); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBeGreaterThan(0); + // All resources should have READ + expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.ARRAY)).toContain(AuthAction.READ_ANY); + // CONNECT__REMOTE_ACCESS should have both READ and UPDATE + expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.READ_ANY); + expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.UPDATE_ANY); + }); + + it('should expand resource-specific wildcard actions to CRUD', async () => { + const mockCasbinPermissions = [ + ['DOCKER_MANAGER', 'DOCKER', '*'], + ['DOCKER_MANAGER', 'ARRAY', 'READ'], + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + // Docker should have all CRUD actions with proper format + expect(result.get(Resource.DOCKER)).toEqual( + expect.arrayContaining([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ]) + ); + // Array should only have READ + expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]); + }); + + it('should skip invalid resources', async () => { + const mockCasbinPermissions = [ + ['ADMIN', 'INVALID_RESOURCE', 'READ'], + ['ADMIN', 'DOCKER', 'UPDATE'], + ['ADMIN', '', 'READ'], + ] as string[][]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(1); + expect(result.get(Resource.DOCKER)).toEqual([AuthAction.UPDATE_ANY]); + }); + + it('should handle empty permissions', async () => { + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue([]); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it('should handle malformed permission entries', async () => { + const mockCasbinPermissions = [ + ['ADMIN'], // Too short + ['ADMIN', 'DOCKER'], // Missing action + ['ADMIN', 'DOCKER', 'READ', 'EXTRA'], // Extra fields are ok + ['ADMIN', 'VMS', 'UPDATE'], + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]); + expect(result.get(Resource.VMS)).toEqual([AuthAction.UPDATE_ANY]); + }); + + it('should not duplicate actions for the same resource', async () => { + const mockCasbinPermissions = [ + ['ADMIN', 'DOCKER', 'READ'], + ['ADMIN', 'DOCKER', 'READ'], + ['ADMIN', 'DOCKER', 'UPDATE'], + ['ADMIN', 'DOCKER', 'UPDATE'], + ]; + + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue( + mockCasbinPermissions + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(1); + expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]); + }); + + it('should handle errors gracefully', async () => { + vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockRejectedValue( + new Error('Casbin error') + ); + + const result = await authService.getImplicitPermissionsForRole(Role.ADMIN); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + }); }); diff --git a/api/src/unraid-api/auth/auth.service.ts b/api/src/unraid-api/auth/auth.service.ts index 1d2d2ce55..5ba0ef549 100644 --- a/api/src/unraid-api/auth/auth.service.ts +++ b/api/src/unraid-api/auth/auth.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; -import { Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { + convertPermissionSetsToArrays, + expandWildcardAction, + parseActionToAuthAction, + reconcileWildcardPermissions, +} from '@unraid/shared/util/permissions.js'; import { AuthZService } from 'nest-authz'; import { getters } from '@app/store/index.js'; @@ -111,12 +117,36 @@ export class AuthService { await this.authzService.deletePermissionsForUser(apiKeyId); // Create array of permission-action pairs for processing - const permissionActions = permissions.flatMap((permission) => - (permission.actions || []).map((action) => ({ - resource: permission.resource, - action, - })) - ); + // Filter out any permissions with empty or undefined resources + const permissionActions = permissions + .filter((permission) => permission.resource && permission.resource.trim() !== '') + .flatMap((permission) => + (permission.actions || []) + .filter((action) => action && String(action).trim() !== '') + .flatMap((action) => { + const actionStr = String(action); + // Handle wildcard - expand to all CRUD actions + if (actionStr === '*' || actionStr.toLowerCase() === '*') { + return expandWildcardAction().map((expandedAction) => ({ + resource: permission.resource, + action: expandedAction, + })); + } + + // Use the shared helper to parse and validate the action + const parsedAction = parseActionToAuthAction(actionStr); + + // Only include valid AuthAction values + return parsedAction + ? [ + { + resource: permission.resource, + action: parsedAction, + }, + ] + : []; + }) + ); const { errors, errorOccurred: errorOccured } = await batchProcess( permissionActions, @@ -227,6 +257,63 @@ export class AuthService { return Boolean(token) && token === getters.emhttp().var.csrfToken; } + /** + * Get implicit permissions for a role (including inherited permissions) + */ + public async getImplicitPermissionsForRole(role: Role): Promise> { + // Use Set internally for efficient deduplication, with '*' as a special key for wildcards + const permissionsWithSets = new Map>(); + + // Load permissions from Casbin, defaulting to empty array on error + let casbinPermissions: string[][] = []; + try { + casbinPermissions = await this.authzService.getImplicitPermissionsForUser(role); + } catch (error) { + this.logger.error(`Failed to get permissions for role ${role}:`, error); + } + + // Parse the Casbin permissions format: [["role", "resource", "action"], ...] + for (const perm of casbinPermissions) { + if (perm.length < 3) continue; + + const resourceStr = perm[1]; + const action = perm[2]; + + if (!resourceStr) continue; + + // Skip invalid resources (except wildcard) + if (resourceStr !== '*' && !Object.values(Resource).includes(resourceStr as Resource)) { + this.logger.debug(`Skipping invalid resource from Casbin: ${resourceStr}`); + continue; + } + + // Initialize Set if needed + if (!permissionsWithSets.has(resourceStr as Resource | '*')) { + permissionsWithSets.set(resourceStr as Resource | '*', new Set()); + } + + const actionsSet = permissionsWithSets.get(resourceStr as Resource | '*')!; + + // Handle wildcard or parse to valid AuthAction + if (action === '*') { + // Expand wildcard action to CRUD operations + expandWildcardAction().forEach((a) => actionsSet.add(a)); + } else { + // Use shared helper to parse and validate action + const parsedAction = parseActionToAuthAction(action); + if (parsedAction) { + actionsSet.add(parsedAction); + } else { + this.logger.debug(`Skipping invalid action from Casbin: ${action}`); + } + } + } + + // Reconcile wildcard permissions and convert to final format + reconcileWildcardPermissions(permissionsWithSets); + return convertPermissionSetsToArrays(permissionsWithSets); + } + /** * Returns a user object representing a session. * Note: Does NOT perform validation. diff --git a/api/src/unraid-api/auth/casbin/model.ts b/api/src/unraid-api/auth/casbin/model.ts index be8017c2c..c71eb69c1 100644 --- a/api/src/unraid-api/auth/casbin/model.ts +++ b/api/src/unraid-api/auth/casbin/model.ts @@ -12,7 +12,7 @@ g = _, _ e = some(where (p.eft == allow)) [matchers] -m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \ - regexMatch(lower(r.obj), lower(p.obj)) && \ - (regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*')))) +m = (r.sub == p.sub || g(r.sub, p.sub)) && \ + (r.obj == p.obj || p.obj == '*') && \ + (r.act == p.act || p.act == '*') `; diff --git a/api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts b/api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts new file mode 100644 index 000000000..f900ddda4 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts @@ -0,0 +1,566 @@ +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js'; +import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js'; + +describe('Comprehensive Casbin Permissions Tests', () => { + describe('All UsePermissions decorator combinations', () => { + // Test all resource/action combinations used in the codebase + const testCases = [ + // API_KEY permissions + { + resource: Resource.API_KEY, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + { + resource: Resource.API_KEY, + action: AuthAction.CREATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + { + resource: Resource.API_KEY, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + { + resource: Resource.API_KEY, + action: AuthAction.DELETE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + + // PERMISSION resource (for listing possible permissions) + { + resource: Resource.PERMISSION, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + + // ARRAY permissions + { + resource: Resource.ARRAY, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.ARRAY, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST], + }, + + // CONFIG permissions + { + resource: Resource.CONFIG, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.CONFIG, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + + // DOCKER permissions + { + resource: Resource.DOCKER, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.DOCKER, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST], + }, + + // VMS permissions + { + resource: Resource.VMS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.VMS, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST], + }, + + // FLASH permissions (includes rclone operations) + { + resource: Resource.FLASH, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.FLASH, + action: AuthAction.CREATE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + { + resource: Resource.FLASH, + action: AuthAction.DELETE_ANY, + allowedRoles: [Role.ADMIN], + deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT], + }, + + // INFO permissions (system information) + { + resource: Resource.INFO, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + + // LOGS permissions + { + resource: Resource.LOGS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + + // ME permissions (current user info) + { + resource: Resource.ME, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST], + deniedRoles: [], + }, + + // NOTIFICATIONS permissions + { + resource: Resource.NOTIFICATIONS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + + // Other read-only resources for VIEWER + { + resource: Resource.DISK, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.DISPLAY, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.ONLINE, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.OWNER, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.REGISTRATION, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.SERVERS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.SERVICES, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.SHARE, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.VARS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.CUSTOMIZATIONS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.ACTIVATION_CODE, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + + // CONNECT special permission for remote access + { + resource: Resource.CONNECT__REMOTE_ACCESS, + action: AuthAction.READ_ANY, + allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT], + deniedRoles: [Role.GUEST], + }, + { + resource: Resource.CONNECT__REMOTE_ACCESS, + action: AuthAction.UPDATE_ANY, + allowedRoles: [Role.ADMIN, Role.CONNECT], + deniedRoles: [Role.VIEWER, Role.GUEST], + }, + ]; + + testCases.forEach(({ resource, action, allowedRoles, deniedRoles }) => { + describe(`${resource} - ${action}`, () => { + let enforcer: any; + + beforeEach(async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + enforcer = await newEnforcer(model, adapter); + }); + + allowedRoles.forEach((role) => { + it(`should allow ${role} to ${action} ${resource}`, async () => { + const result = await enforcer.enforce(role, resource, action); + expect(result).toBe(true); + }); + }); + + deniedRoles.forEach((role) => { + it(`should deny ${role} to ${action} ${resource}`, async () => { + const result = await enforcer.enforce(role, resource, action); + expect(result).toBe(false); + }); + }); + }); + }); + }); + + describe('Action matching and normalization', () => { + let enforcer: any; + + beforeEach(async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + enforcer = await newEnforcer(model, adapter); + }); + + it('should match actions exactly as stored (uppercase)', async () => { + // Our policies store actions as uppercase (e.g., 'READ_ANY') + // The matcher now requires exact matching for security + + // Uppercase actions should work + const adminUpperResult = await enforcer.enforce( + Role.ADMIN, + Resource.DOCKER, + AuthAction.READ_ANY + ); + expect(adminUpperResult).toBe(true); + + const viewerUpperResult = await enforcer.enforce( + Role.VIEWER, + Resource.DOCKER, + AuthAction.READ_ANY + ); + expect(viewerUpperResult).toBe(true); + + // For non-wildcard roles, lowercase actions won't match + const viewerLowerResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'read:any'); + expect(viewerLowerResult).toBe(false); + + // Mixed case won't match for VIEWER either + const viewerMixedResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'Read_Any'); + expect(viewerMixedResult).toBe(false); + + // GUEST also requires exact lowercase + const guestUpperResult = await enforcer.enforce(Role.GUEST, Resource.ME, 'READ:ANY'); + expect(guestUpperResult).toBe(false); + + const guestLowerResult = await enforcer.enforce( + Role.GUEST, + Resource.ME, + AuthAction.READ_ANY + ); + expect(guestLowerResult).toBe(true); + }); + + it('should allow wildcard actions for ADMIN regardless of case', async () => { + // ADMIN has wildcard permissions (*, *, *) which match any action + const adminWildcardActions = [ + 'read:any', + 'create:any', + 'update:any', + 'delete:any', + 'READ:ANY', // Even uppercase works due to wildcard + 'ANYTHING', // Any action works due to wildcard + ]; + + for (const action of adminWildcardActions) { + const result = await enforcer.enforce(Role.ADMIN, Resource.DOCKER, action); + expect(result).toBe(true); + } + }); + + it('should NOT match different actions even with correct case', async () => { + // VIEWER should not be able to UPDATE even with correct lowercase + const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.UPDATE_ANY); + expect(result).toBe(false); + + // VIEWER should not be able to DELETE + const deleteResult = await enforcer.enforce( + Role.VIEWER, + Resource.DOCKER, + AuthAction.DELETE_ANY + ); + expect(deleteResult).toBe(false); + + // VIEWER should not be able to CREATE + const createResult = await enforcer.enforce( + Role.VIEWER, + Resource.DOCKER, + AuthAction.CREATE_ANY + ); + expect(createResult).toBe(false); + }); + + it('should ensure actions are normalized when stored', async () => { + // This test documents that our auth service normalizes actions to uppercase + // when syncing permissions, ensuring consistency + + // The BASE_POLICY uses AuthAction.READ_ANY which is 'READ_ANY' (uppercase) + expect(BASE_POLICY).toContain('READ_ANY'); + expect(BASE_POLICY).not.toContain('read:any'); + + // All our stored policies should be uppercase + const policies = await enforcer.getPolicy(); + for (const policy of policies) { + const action = policy[2]; // Third element is the action + if (action && action !== '*') { + // All non-wildcard actions should be uppercase + expect(action).toBe(action.toUpperCase()); + } + } + }); + }); + + describe('Wildcard permissions', () => { + let enforcer: any; + + beforeEach(async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + enforcer = await newEnforcer(model, adapter); + }); + + it('should allow ADMIN wildcard access to all resources and actions', async () => { + const resources = Object.values(Resource); + const actions = [ + AuthAction.READ_ANY, + AuthAction.CREATE_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ]; + + for (const resource of resources) { + for (const action of actions) { + const result = await enforcer.enforce(Role.ADMIN, resource, action); + expect(result).toBe(true); + } + } + }); + + it('should allow CONNECT read access to most resources but NOT API_KEY', async () => { + const resources = Object.values(Resource).filter( + (r) => r !== Resource.CONNECT__REMOTE_ACCESS && r !== Resource.API_KEY + ); + + for (const resource of resources) { + // Should be able to read most resources + const readResult = await enforcer.enforce(Role.CONNECT, resource, AuthAction.READ_ANY); + expect(readResult).toBe(true); + + // Should NOT be able to write (except CONNECT__REMOTE_ACCESS) + const updateResult = await enforcer.enforce( + Role.CONNECT, + resource, + AuthAction.UPDATE_ANY + ); + expect(updateResult).toBe(false); + } + + // CONNECT should NOT be able to read API_KEY + const apiKeyRead = await enforcer.enforce( + Role.CONNECT, + Resource.API_KEY, + AuthAction.READ_ANY + ); + expect(apiKeyRead).toBe(false); + + // CONNECT should NOT be able to perform any action on API_KEY + const apiKeyCreate = await enforcer.enforce( + Role.CONNECT, + Resource.API_KEY, + AuthAction.CREATE_ANY + ); + expect(apiKeyCreate).toBe(false); + const apiKeyUpdate = await enforcer.enforce( + Role.CONNECT, + Resource.API_KEY, + AuthAction.UPDATE_ANY + ); + expect(apiKeyUpdate).toBe(false); + const apiKeyDelete = await enforcer.enforce( + Role.CONNECT, + Resource.API_KEY, + AuthAction.DELETE_ANY + ); + expect(apiKeyDelete).toBe(false); + + // Special case: CONNECT can update CONNECT__REMOTE_ACCESS + const remoteAccessUpdate = await enforcer.enforce( + Role.CONNECT, + Resource.CONNECT__REMOTE_ACCESS, + AuthAction.UPDATE_ANY + ); + expect(remoteAccessUpdate).toBe(true); + }); + + it('should explicitly deny CONNECT role from accessing API_KEY to prevent secret exposure', async () => { + // CONNECT should NOT be able to read API_KEY (which would expose secrets) + const apiKeyRead = await enforcer.enforce( + Role.CONNECT, + Resource.API_KEY, + AuthAction.READ_ANY + ); + expect(apiKeyRead).toBe(false); + + // Verify all API_KEY operations are denied for CONNECT + const actions = ['create:any', 'read:any', 'update:any', 'delete:any']; + for (const action of actions) { + const result = await enforcer.enforce(Role.CONNECT, Resource.API_KEY, action); + expect(result).toBe(false); + } + + // Verify ADMIN can still access API_KEY + const adminApiKeyRead = await enforcer.enforce( + Role.ADMIN, + Resource.API_KEY, + AuthAction.READ_ANY + ); + expect(adminApiKeyRead).toBe(true); + }); + }); + + describe('Role inheritance', () => { + let enforcer: any; + + beforeEach(async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + enforcer = await newEnforcer(model, adapter); + }); + + it('should inherit GUEST permissions for VIEWER', async () => { + // VIEWER inherits from GUEST, so should have ME access + const result = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY); + expect(result).toBe(true); + }); + + it('should inherit GUEST permissions for CONNECT', async () => { + // CONNECT inherits from GUEST, so should have ME access + const result = await enforcer.enforce(Role.CONNECT, Resource.ME, AuthAction.READ_ANY); + expect(result).toBe(true); + }); + + it('should inherit GUEST permissions for ADMIN', async () => { + // ADMIN inherits from GUEST, so should have ME access + const result = await enforcer.enforce(Role.ADMIN, Resource.ME, AuthAction.READ_ANY); + expect(result).toBe(true); + }); + }); + + describe('Edge cases and security', () => { + it('should deny access with empty action', async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, ''); + expect(result).toBe(false); + }); + + it('should deny access with empty resource', async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + const result = await enforcer.enforce(Role.VIEWER, '', AuthAction.READ_ANY); + expect(result).toBe(false); + }); + + it('should deny access with undefined role', async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + const result = await enforcer.enforce( + 'UNDEFINED_ROLE', + Resource.DOCKER, + AuthAction.READ_ANY + ); + expect(result).toBe(false); + }); + + it('should deny access with malformed action', async () => { + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + const malformedActions = [ + 'read', // Missing possession + ':any', // Missing verb + 'read:', // Empty possession + 'read:own', // Different possession format + 'READ', // Uppercase without possession + ]; + + for (const action of malformedActions) { + const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, action); + expect(result).toBe(false); + } + }); + }); +}); diff --git a/api/src/unraid-api/auth/casbin/policy.spec.ts b/api/src/unraid-api/auth/casbin/policy.spec.ts new file mode 100644 index 000000000..3bf80f980 --- /dev/null +++ b/api/src/unraid-api/auth/casbin/policy.spec.ts @@ -0,0 +1,147 @@ +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin'; +import { describe, expect, it } from 'vitest'; + +import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js'; +import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js'; + +describe('Casbin Policy - VIEWER role restrictions', () => { + it('should validate matcher does not allow empty policies', async () => { + // Test that empty policies don't match everything + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + + // Test with a policy that has an empty object + const emptyPolicy = `p, VIEWER, , ${AuthAction.READ_ANY}`; + const adapter = new StringAdapter(emptyPolicy); + const enforcer = await newEnforcer(model, adapter); + + // Empty policy should not match a real resource + const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY); + expect(canReadApiKey).toBe(false); + }); + + it('should deny VIEWER role access to API_KEY resource', async () => { + // Create enforcer with actual policy + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + // Test that VIEWER cannot access API_KEY with any action + const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY); + const canCreateApiKey = await enforcer.enforce( + Role.VIEWER, + Resource.API_KEY, + AuthAction.CREATE_ANY + ); + const canUpdateApiKey = await enforcer.enforce( + Role.VIEWER, + Resource.API_KEY, + AuthAction.UPDATE_ANY + ); + const canDeleteApiKey = await enforcer.enforce( + Role.VIEWER, + Resource.API_KEY, + AuthAction.DELETE_ANY + ); + + expect(canReadApiKey).toBe(false); + expect(canCreateApiKey).toBe(false); + expect(canUpdateApiKey).toBe(false); + expect(canDeleteApiKey).toBe(false); + }); + + it('should allow VIEWER role access to other resources', async () => { + // Create enforcer with actual policy + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + // Test that VIEWER can read other resources + const canReadDocker = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.READ_ANY); + const canReadArray = await enforcer.enforce(Role.VIEWER, Resource.ARRAY, AuthAction.READ_ANY); + const canReadConfig = await enforcer.enforce(Role.VIEWER, Resource.CONFIG, AuthAction.READ_ANY); + const canReadVms = await enforcer.enforce(Role.VIEWER, Resource.VMS, AuthAction.READ_ANY); + + expect(canReadDocker).toBe(true); + expect(canReadArray).toBe(true); + expect(canReadConfig).toBe(true); + expect(canReadVms).toBe(true); + + // But VIEWER cannot write to these resources + const canUpdateDocker = await enforcer.enforce( + Role.VIEWER, + Resource.DOCKER, + AuthAction.UPDATE_ANY + ); + const canDeleteArray = await enforcer.enforce( + Role.VIEWER, + Resource.ARRAY, + AuthAction.DELETE_ANY + ); + + expect(canUpdateDocker).toBe(false); + expect(canDeleteArray).toBe(false); + }); + + it('should allow ADMIN role full access to API_KEY resource', async () => { + // Create enforcer with actual policy + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + // Test that ADMIN can access API_KEY with all actions + const canReadApiKey = await enforcer.enforce(Role.ADMIN, Resource.API_KEY, AuthAction.READ_ANY); + const canCreateApiKey = await enforcer.enforce( + Role.ADMIN, + Resource.API_KEY, + AuthAction.CREATE_ANY + ); + const canUpdateApiKey = await enforcer.enforce( + Role.ADMIN, + Resource.API_KEY, + AuthAction.UPDATE_ANY + ); + const canDeleteApiKey = await enforcer.enforce( + Role.ADMIN, + Resource.API_KEY, + AuthAction.DELETE_ANY + ); + + expect(canReadApiKey).toBe(true); + expect(canCreateApiKey).toBe(true); + expect(canUpdateApiKey).toBe(true); + expect(canDeleteApiKey).toBe(true); + }); + + it('should ensure VIEWER permissions exclude API_KEY in generated policy', () => { + // Verify that the generated policy string doesn't contain VIEWER + API_KEY combination + expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}`); + expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}`); + expect(BASE_POLICY).not.toContain( + `p, ${Role.VIEWER}, ${Resource.API_KEY}, ${AuthAction.READ_ANY}` + ); + + // Count VIEWER permissions - should be total resources minus API_KEY + const viewerPermissionLines = BASE_POLICY.split('\n').filter((line) => + line.startsWith(`p, ${Role.VIEWER},`) + ); + const totalResources = Object.values(Resource).length; + expect(viewerPermissionLines.length).toBe(totalResources - 1); // All resources except API_KEY + }); + + it('should inherit GUEST permissions for VIEWER role', async () => { + // Create enforcer with actual policy + const model = new CasbinModel(); + model.loadModelFromText(CASBIN_MODEL); + const adapter = new StringAdapter(BASE_POLICY); + const enforcer = await newEnforcer(model, adapter); + + // VIEWER inherits from GUEST, so should have access to ME resource + const canReadMe = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY); + expect(canReadMe).toBe(true); + }); +}); diff --git a/api/src/unraid-api/auth/casbin/policy.ts b/api/src/unraid-api/auth/casbin/policy.ts index 230cd6305..615b9b89c 100644 --- a/api/src/unraid-api/auth/casbin/policy.ts +++ b/api/src/unraid-api/auth/casbin/policy.ts @@ -1,18 +1,26 @@ -import { Resource, Role } from '@unraid/shared/graphql.model.js'; -import { AuthAction } from 'nest-authz'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; + +// Generate VIEWER permissions for all resources except API_KEY +const viewerPermissions = Object.values(Resource) + .filter((resource) => resource !== Resource.API_KEY) + .map((resource) => `p, ${Role.VIEWER}, ${resource}, ${AuthAction.READ_ANY}`) + .join('\n'); export const BASE_POLICY = ` -# Admin permissions +# Admin permissions - full access p, ${Role.ADMIN}, *, * -# Connect Permissions -p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY} +# Connect permissions - inherits from VIEWER plus can manage remote access p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY} -# Guest permissions +# Guest permissions - basic profile access p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY} +# Viewer permissions - read-only access to all resources except API_KEY +${viewerPermissions} + # Role inheritance g, ${Role.ADMIN}, ${Role.GUEST} -g, ${Role.CONNECT}, ${Role.GUEST} +g, ${Role.CONNECT}, ${Role.VIEWER} +g, ${Role.VIEWER}, ${Role.GUEST} `; diff --git a/api/src/unraid-api/cli/apikey/api-key.command.ts b/api/src/unraid-api/cli/apikey/api-key.command.ts index 8826467f4..2fcdf0b71 100644 --- a/api/src/unraid-api/cli/apikey/api-key.command.ts +++ b/api/src/unraid-api/cli/apikey/api-key.command.ts @@ -1,5 +1,4 @@ -import { Resource, Role } from '@unraid/shared/graphql.model.js'; -import { AuthActionVerb } from 'nest-authz'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; import { Command, CommandRunner, InquirerService, Option } from 'nest-commander'; import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js'; @@ -75,7 +74,7 @@ export class ApiKeyCommand extends CommandRunner { 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(', ')}`, +ACTIONS: ${Object.values(AuthAction).join(', ')}`, }) parsePermissions(permissions: string): Array { return this.apiKeyService.convertPermissionsStringArrayToPermissions( diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 72e473bf6..e25fc4255 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -120,7 +120,7 @@ export type ActivationCode = { }; export type AddPermissionInput = { - actions: Array; + actions: Array; resource: Resource; }; @@ -143,24 +143,36 @@ export type ApiKey = Node & { createdAt: Scalars['String']['output']; description?: Maybe; id: Scalars['PrefixedID']['output']; + key: Scalars['String']['output']; name: Scalars['String']['output']; permissions: Array; roles: Array; }; +export type ApiKeyFormSettings = FormSchema & Node & { + __typename?: 'ApiKeyFormSettings'; + /** The data schema for the API key form */ + dataSchema: Scalars['JSON']['output']; + id: Scalars['PrefixedID']['output']; + /** The UI schema for the API key form */ + uiSchema: Scalars['JSON']['output']; + /** The current values of the API key form */ + values: Scalars['JSON']['output']; +}; + /** API Key related mutations */ export type ApiKeyMutations = { __typename?: 'ApiKeyMutations'; /** Add a role to an API key */ addRole: Scalars['Boolean']['output']; /** Create an API key */ - create: ApiKeyWithSecret; + create: ApiKey; /** Delete one or more API keys */ delete: Scalars['Boolean']['output']; /** Remove a role from an API key */ removeRole: Scalars['Boolean']['output']; /** Update an API key */ - update: ApiKeyWithSecret; + update: ApiKey; }; @@ -199,17 +211,6 @@ export type ApiKeyResponse = { valid: Scalars['Boolean']['output']; }; -export type ApiKeyWithSecret = Node & { - __typename?: 'ApiKeyWithSecret'; - createdAt: Scalars['String']['output']; - description?: Maybe; - id: Scalars['PrefixedID']['output']; - key: Scalars['String']['output']; - name: Scalars['String']['output']; - permissions: Array; - roles: Array; -}; - export type ArrayCapacity = { __typename?: 'ArrayCapacity'; /** Capacity in number of disks */ @@ -370,19 +371,24 @@ export enum ArrayStateInputState { STOP = 'STOP' } -/** Available authentication action verbs */ -export enum AuthActionVerb { - CREATE = 'CREATE', - DELETE = 'DELETE', - READ = 'READ', - UPDATE = 'UPDATE' -} - -/** Available authentication possession types */ -export enum AuthPossession { - ANY = 'ANY', - OWN = 'OWN', - OWN_ANY = 'OWN_ANY' +/** Authentication actions with possession (e.g., create:any, read:own) */ +export enum AuthAction { + /** Create any resource */ + CREATE_ANY = 'CREATE_ANY', + /** Create own resource */ + CREATE_OWN = 'CREATE_OWN', + /** Delete any resource */ + DELETE_ANY = 'DELETE_ANY', + /** Delete own resource */ + DELETE_OWN = 'DELETE_OWN', + /** Read any resource */ + READ_ANY = 'READ_ANY', + /** Read own resource */ + READ_OWN = 'READ_OWN', + /** Update any resource */ + UPDATE_ANY = 'UPDATE_ANY', + /** Update own resource */ + UPDATE_OWN = 'UPDATE_OWN' } /** Operators for authorization rule matching */ @@ -776,6 +782,15 @@ export type FlashBackupStatus = { status: Scalars['String']['output']; }; +export type FormSchema = { + /** The data schema for the form */ + dataSchema: Scalars['JSON']['output']; + /** The UI schema for the form */ + uiSchema: Scalars['JSON']['output']; + /** The current values of the form */ + values: Scalars['JSON']['output']; +}; + export type Info = Node & { __typename?: 'Info'; /** Motherboard information */ @@ -1053,7 +1068,7 @@ export type InfoVersions = Node & { core: CoreVersions; id: Scalars['PrefixedID']['output']; /** Software package versions */ - packages: PackageVersions; + packages?: Maybe; }; export type InitiateFlashBackupInput = { @@ -1519,7 +1534,7 @@ export type ParityCheck = { /** Speed of the parity check, in MB/s */ speed?: Maybe; /** Status of the parity check */ - status?: Maybe; + status: ParityCheckStatus; }; /** Parity check related mutations, WIP, response types and functionaliy will change */ @@ -1541,9 +1556,19 @@ export type ParityCheckMutationsStartArgs = { correct: Scalars['Boolean']['input']; }; +export enum ParityCheckStatus { + CANCELLED = 'CANCELLED', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + NEVER_RUN = 'NEVER_RUN', + PAUSED = 'PAUSED', + RUNNING = 'RUNNING' +} + export type Permission = { __typename?: 'Permission'; - actions: Array; + /** Actions allowed on this resource */ + actions: Array; resource: Resource; }; @@ -1613,6 +1638,12 @@ export type Query = { disks: Array; docker: Docker; flash: Flash; + /** Get JSON Schema for API key creation form */ + getApiKeyCreationFormSchema: ApiKeyFormSettings; + /** Get all available authentication actions with possession */ + getAvailableAuthActions: Array; + /** Get the actual permissions that would be granted by a set of roles */ + getPermissionsForRoles: Array; info: Info; isInitialSetup: Scalars['Boolean']['output']; isSSOEnabled: Scalars['Boolean']['output']; @@ -1632,6 +1663,8 @@ export type Query = { parityHistory: Array; /** List all installed plugins with their metadata */ plugins: Array; + /** Preview the effective permissions for a combination of roles and explicit permissions */ + previewEffectivePermissions: Array; /** Get public OIDC provider information for login buttons */ publicOidcProviders: Array; publicPartnerInfo?: Maybe; @@ -1665,6 +1698,11 @@ export type QueryDiskArgs = { }; +export type QueryGetPermissionsForRolesArgs = { + roles: Array; +}; + + export type QueryLogFileArgs = { lines?: InputMaybe; path: Scalars['String']['input']; @@ -1677,6 +1715,12 @@ export type QueryOidcProviderArgs = { }; +export type QueryPreviewEffectivePermissionsArgs = { + permissions?: InputMaybe>; + roles?: InputMaybe>; +}; + + export type QueryUpsDeviceByIdArgs = { id: Scalars['String']['input']; }; @@ -1869,10 +1913,14 @@ export enum Resource { /** Available roles for API keys and users */ export enum Role { + /** Full administrative access to all resources */ ADMIN = 'ADMIN', + /** Internal Role for Unraid Connect */ CONNECT = 'CONNECT', + /** Basic read access to user profile only */ GUEST = 'GUEST', - USER = 'USER' + /** Read-only access to all resources */ + VIEWER = 'VIEWER' } export type Server = Node & { @@ -2149,7 +2197,7 @@ export enum UrlType { WIREGUARD = 'WIREGUARD' } -export type UnifiedSettings = Node & { +export type UnifiedSettings = FormSchema & Node & { __typename?: 'UnifiedSettings'; /** The data schema for the settings */ dataSchema: Scalars['JSON']['output']; @@ -2173,6 +2221,8 @@ export type UnraidArray = Node & { id: Scalars['PrefixedID']['output']; /** Parity disks in the current array */ parities: Array; + /** Current parity check status */ + parityCheckStatus: ParityCheck; /** Current array state */ state: ArrayState; }; @@ -2527,7 +2577,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?: export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>; -export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages: { __typename?: 'PackageVersions', openssl?: string | null } } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null }; +export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages?: { __typename?: 'PackageVersions', openssl?: string | null } | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null }; export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/api/src/unraid-api/graph/auth/auth-action.enum.ts b/api/src/unraid-api/graph/auth/auth-action.enum.ts new file mode 100644 index 000000000..8b460f3e3 --- /dev/null +++ b/api/src/unraid-api/graph/auth/auth-action.enum.ts @@ -0,0 +1,3 @@ +// All enum registrations have been moved to @unraid/shared/graphql.model.js +// Just re-export AuthAction for convenience +export { AuthAction } from '@unraid/shared/graphql.model.js'; diff --git a/api/src/unraid-api/graph/auth/auth.enums.ts b/api/src/unraid-api/graph/auth/auth.enums.ts index 5c9536e2e..195201134 100644 --- a/api/src/unraid-api/graph/auth/auth.enums.ts +++ b/api/src/unraid-api/graph/auth/auth.enums.ts @@ -1,52 +1,3 @@ -import { DirectiveLocation, GraphQLDirective, GraphQLEnumType, GraphQLString } from 'graphql'; -import { AuthActionVerb, AuthPossession } from 'nest-authz'; - -// Create GraphQL enum types for auth action verbs and possessions -export const AuthActionVerbEnum = new GraphQLEnumType({ - name: 'AuthActionVerb', - description: 'Available authentication action verbs', - values: Object.entries(AuthActionVerb) - .filter(([key]) => isNaN(Number(key))) // Filter out numeric keys - .reduce( - (acc, [key]) => { - acc[key] = { value: key }; - return acc; - }, - {} as Record - ), -}); - -export const AuthPossessionEnum = new GraphQLEnumType({ - name: 'AuthPossession', - description: 'Available authentication possession types', - values: Object.entries(AuthPossession) - .filter(([key]) => isNaN(Number(key))) // Filter out numeric keys - .reduce( - (acc, [key]) => { - acc[key] = { value: key }; - return acc; - }, - {} as Record - ), -}); - -// Create the auth directive -export const AuthDirective = new GraphQLDirective({ - name: 'auth', - description: 'Directive to control access to fields based on authentication', - locations: [DirectiveLocation.FIELD_DEFINITION], - args: { - action: { - type: AuthActionVerbEnum, - description: 'The action verb required for access', - }, - resource: { - type: GraphQLString, - description: 'The resource required for access', - }, - possession: { - type: AuthPossessionEnum, - description: 'The possession type required for access', - }, - }, -}); +// Resource and Role enums are already registered in @unraid/shared/graphql.model.js +// Just re-export them here for convenience +export { Resource, Role } from '@unraid/shared/graphql.model.js'; diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 554489aaf..276610f16 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -12,6 +12,10 @@ import { NoUnusedVariablesRule } from 'graphql'; import { ENVIRONMENT } from '@app/environment.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; + +// Import enum registrations to ensure they're registered with GraphQL +import '@app/unraid-api/graph/auth/auth-action.enum.js'; + import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js'; import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js'; import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js'; diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key-form.resolver.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.resolver.ts new file mode 100644 index 000000000..2861bcd0b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.resolver.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { Query, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js'; +import { ApiKeyFormSettings } from '@app/unraid-api/graph/resolvers/settings/settings.model.js'; + +@Injectable() +@Resolver() +export class ApiKeyFormResolver { + constructor(private apiKeyFormService: ApiKeyFormService) {} + + @Query(() => ApiKeyFormSettings, { + description: 'Get JSON Schema for API key creation form', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.API_KEY, + }) + getApiKeyCreationFormSchema(): ApiKeyFormSettings { + return this.apiKeyFormService.getApiKeyCreationFormSchema(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.spec.ts new file mode 100644 index 000000000..913b46f75 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.spec.ts @@ -0,0 +1,247 @@ +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + ApiKeyFormData, + ApiKeyFormService, +} from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js'; + +describe('ApiKeyFormService', () => { + let service: ApiKeyFormService; + + beforeEach(() => { + service = new ApiKeyFormService(); + }); + + describe('convertFormDataToPermissions', () => { + describe('basic functionality', () => { + it('should merge roles and custom permissions', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + roles: [Role.ADMIN], + customPermissions: [ + { + resources: [Resource.NETWORK], + actions: [AuthAction.READ_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([Role.ADMIN]); + expect(result.permissions).toContainEqual({ + resource: Resource.NETWORK, + actions: [AuthAction.READ_ANY], + }); + }); + + it('should handle only roles when others are not provided', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + roles: [Role.GUEST, Role.VIEWER], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([Role.GUEST, Role.VIEWER]); + expect(result.permissions).toEqual([]); + }); + + it('should handle multiple roles', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + roles: [Role.GUEST, Role.VIEWER, Role.ADMIN], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([Role.GUEST, Role.VIEWER, Role.ADMIN]); + expect(result.permissions).toEqual([]); + }); + + it('should handle only custom permissions when others are not provided', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + customPermissions: [ + { + resources: [Resource.ARRAY, Resource.DISK], + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([]); + expect(result.permissions).toContainEqual({ + resource: Resource.ARRAY, + actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]), + }); + expect(result.permissions).toContainEqual({ + resource: Resource.DISK, + actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]), + }); + }); + + it('should handle empty form data', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([]); + expect(result.permissions).toEqual([]); + }); + }); + + describe('custom permissions handling', () => { + it('should merge custom permissions with same resource', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + customPermissions: [ + { + resources: [Resource.DOCKER], + actions: [AuthAction.READ_ANY], + }, + { + resources: [Resource.DOCKER], + actions: [AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.permissions).toEqual([ + { + resource: Resource.DOCKER, + actions: expect.arrayContaining([ + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ]), + }, + ]); + }); + + it('should deduplicate actions when merging', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + customPermissions: [ + { + resources: [Resource.NETWORK], + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY], + }, + { + resources: [Resource.NETWORK], + actions: [AuthAction.READ_ANY, AuthAction.DELETE_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + const networkPermission = result.permissions.find( + (p) => p.resource === Resource.NETWORK + ); + expect(networkPermission?.actions).toHaveLength(3); + expect(networkPermission?.actions).toContain(AuthAction.READ_ANY); + expect(networkPermission?.actions).toContain(AuthAction.UPDATE_ANY); + expect(networkPermission?.actions).toContain(AuthAction.DELETE_ANY); + }); + }); + + describe('edge cases', () => { + it('should handle resources as non-array in custom permissions', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + customPermissions: [ + { + resources: Resource.DOCKER as any, + actions: [AuthAction.READ_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.permissions).toEqual([ + { + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY], + }, + ]); + }); + + it('should handle actions as non-array in custom permissions', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + customPermissions: [ + { + resources: [Resource.DOCKER], + actions: AuthAction.READ_ANY as any, + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.permissions).toEqual([ + { + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY], + }, + ]); + }); + + it('should handle empty arrays gracefully', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + roles: [], + customPermissions: [], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([]); + expect(result.permissions).toEqual([]); + }); + + it('should handle both roles and custom permissions together', () => { + const formData: ApiKeyFormData = { + name: 'Test Key', + roles: [Role.VIEWER], + customPermissions: [ + { + resources: [Resource.DOCKER, Resource.VMS], + actions: [AuthAction.READ_ANY], + }, + { + resources: [Resource.NETWORK], + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY], + }, + ], + }; + + const result = service.convertFormDataToPermissions(formData); + + expect(result.roles).toEqual([Role.VIEWER]); + expect(result.permissions).toHaveLength(3); + expect(result.permissions).toContainEqual({ + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY], + }); + expect(result.permissions).toContainEqual({ + resource: Resource.VMS, + actions: [AuthAction.READ_ANY], + }); + expect(result.permissions).toContainEqual({ + resource: Resource.NETWORK, + actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]), + }); + }); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.ts new file mode 100644 index 000000000..0f9e8b0e7 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key-form.service.ts @@ -0,0 +1,374 @@ +import { Injectable } from '@nestjs/common'; + +import type { JsonSchema, LabelElement, UISchemaElement } from '@jsonforms/core'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js'; +import { normalizeAction } from '@unraid/shared/util/permissions.js'; +import { capitalCase } from 'change-case'; + +import type { SettingSlice } from '@app/unraid-api/types/json-forms.js'; +import { + createLabeledControl, + createSimpleLabeledControl, +} from '@app/unraid-api/graph/utils/form-utils.js'; + +// Helper to get GraphQL enum names for JSON Schema +// GraphQL expects the enum names (keys) not the values +function getAuthActionEnumNames(): string[] { + // Get only the "_ANY" actions (not "_OWN") + // e.g., CREATE_ANY, READ_ANY, UPDATE_ANY, DELETE_ANY + return Object.keys(AuthAction).filter((key) => key === key.toUpperCase() && key.endsWith('_ANY')); +} + +// Helper to create labels for AuthAction enum dynamically +function getAuthActionLabels(): Record { + const labels: Record = {}; + + for (const enumName of getAuthActionEnumNames()) { + // Convert CREATE_ANY -> Create (All) + // Convert READ_OWN -> Read (Own) + const [verb, possession] = enumName.split('_'); + const verbLabel = capitalCase(verb.toLowerCase()); + const possessionLabel = possession === 'ANY' ? 'All' : 'Own'; + labels[enumName] = `${verbLabel} (${possessionLabel})`; + } + + return labels; +} + +export interface ApiKeyFormData { + name: string; + description?: string; + roles?: Role[]; + permissionPresets?: string; // Single preset selection from dropdown + customPermissions?: Array<{ + resources: Resource[]; // Form uses array for multi-select + actions: string[]; + }>; + expiresAt?: string; +} + +@Injectable() +export class ApiKeyFormService { + /** + * Generate form schema for API key creation + */ + getApiKeyCreationFormSchema(): { + id: string; + dataSchema: Record; + uiSchema: Record; + values: Record; + } { + const slice = this.createApiKeyCreationSlice(); + const merged = mergeSettingSlices([slice]); + + return { + id: 'api-key-creation-form', + dataSchema: { + type: 'object', + required: ['name'], + properties: merged.properties, + }, + uiSchema: { + type: 'VerticalLayout', + elements: merged.elements, + }, + values: {}, + }; + } + + private createApiKeyCreationSlice(): SettingSlice { + const slice: SettingSlice = { + properties: { + name: { + type: 'string', + title: 'API Key Name', + description: 'A descriptive name for this API key', + minLength: 1, + maxLength: 100, + }, + description: { + type: 'string', + title: 'Description', + description: 'Optional description of what this key is used for', + maxLength: 500, + }, + roles: { + type: 'array', + title: 'Roles', + description: 'Select one or more roles to grant pre-defined permission sets', + items: { + type: 'string', + enum: this.getAvailableRoles(), + }, + uniqueItems: true, + }, + permissionPresets: { + type: 'string', + title: 'Add Permission Preset', + description: 'Quick add common permission sets', + enum: [ + 'none', + 'docker_manager', + 'vm_manager', + 'monitoring', + 'backup_manager', + 'network_admin', + ], + default: 'none', + }, + customPermissions: { + type: 'array', + title: 'Permissions', + description: 'Configure specific permissions', + items: { + type: 'object', + properties: { + resources: { + type: 'array', + title: 'Resources', + items: { + type: 'string', + enum: this.getAvailableResources(), + }, + uniqueItems: true, + minItems: 1, + default: [this.getAvailableResources()[0]], // Set a default value as array + }, + actions: { + type: 'array', + title: 'Actions', + items: { + type: 'string', + enum: getAuthActionEnumNames(), + }, + uniqueItems: true, + minItems: 1, + default: ['READ_ANY'], // Set a default action + }, + }, + required: ['resources', 'actions'], + }, + }, + // Commenting out expiration date until date picker is implemented + // expiresAt: { + // type: 'string', + // format: 'date-time', + // title: 'Expiration Date', + // description: 'Optional expiration date for this API key', + // }, + }, + elements: [ + createLabeledControl({ + scope: '#/properties/name', + label: 'API Key Name', + description: 'A descriptive name for this API key', + layoutType: 'VerticalLayout', + controlOptions: { + inputType: 'text', + }, + }), + createLabeledControl({ + scope: '#/properties/description', + label: 'Description', + description: 'Optional description of what this key is used for', + layoutType: 'VerticalLayout', + controlOptions: { + multi: true, + rows: 3, + }, + }), + // Permissions section header + { + type: 'Label', + text: 'Permissions Configuration', + options: { + format: 'title', + }, + } as LabelElement, + { + type: 'Label', + text: 'Select any combination of roles, permission groups, and custom permissions to define what this API key can access.', + options: { + format: 'description', + }, + } as LabelElement, + // Roles selection + createLabeledControl({ + scope: '#/properties/roles', + label: 'Roles', + description: 'Select one or more roles to grant pre-defined permission sets', + layoutType: 'VerticalLayout', + controlOptions: { + multiple: true, + labels: this.getAvailableRoles().reduce( + (acc, role) => ({ + ...acc, + [role]: capitalCase(role), + }), + {} + ), + descriptions: this.getRoleDescriptions(), + }, + }), + // Separator for permissions + { + type: 'Label', + text: 'Permissions', + options: { + format: 'subtitle', + }, + } as LabelElement, + { + type: 'Label', + text: 'Use the preset dropdown for common permission sets, or manually add custom permissions. You can select multiple resources that share the same actions.', + options: { + format: 'description', + }, + } as LabelElement, + // Permission preset dropdown + createLabeledControl({ + scope: '#/properties/permissionPresets', + label: 'Quick Add Presets', + description: 'Select a preset to quickly add common permission sets', + layoutType: 'VerticalLayout', + controlOptions: { + labels: { + none: '-- Select a preset --', + docker_manager: 'Docker Manager (Full Docker Control)', + vm_manager: 'VM Manager (Full VM Control)', + monitoring: 'Monitoring (Read-only System Info)', + backup_manager: 'Backup Manager (Flash & Share Control)', + network_admin: 'Network Admin (Network & Services Control)', + }, + }, + }), + // Custom permissions array - following OIDC pattern exactly + { + type: 'Control', + scope: '#/properties/customPermissions', + options: { + elementLabelFormat: 'Permission Entry', + itemTypeName: 'Permission', + detail: { + type: 'VerticalLayout', + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/resources', + label: 'Resources:', + description: 'Select the resources to grant permissions for', + controlOptions: { + multiple: true, + labels: this.getAvailableResources().reduce( + (acc, resource) => ({ + ...acc, + [resource]: capitalCase( + resource.toLowerCase().replace(/_/g, ' ') + ), + }), + {} + ), + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/actions', + label: 'Actions:', + description: 'Select the actions allowed on this resource', + controlOptions: { + multiple: true, + labels: getAuthActionLabels(), + }, + }), + ], + }, + }, + } as UISchemaElement, + // Note: Datetime inputs are not currently supported in the renderer + // Would need to implement a date picker component + // For now, commenting out the expiration date field + // createLabeledControl({ + // scope: '#/properties/expiresAt', + // label: 'Expiration Date:', + // description: 'Optional expiration date for this API key', + // controlOptions: { + // inputType: 'datetime-local', + // }, + // }), + ], + }; + + return slice; + } + + private getAvailableRoles(): Role[] { + return [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST]; + } + + private getRoleDescriptions(): Record { + return { + [Role.ADMIN]: 'Full administrative access to all resources', + [Role.VIEWER]: 'Read-only access to all resources', + [Role.CONNECT]: 'Internal Role for Unraid Connect', + [Role.GUEST]: 'Basic read access to user profile only', + }; + } + + private getAvailableResources(): Resource[] { + return Object.values(Resource); + } + + /** + * Convert form data back to permissions for API key creation + * The form provides: name, description, roles, and customPermissions + * Note: permissionPresets is only a UI helper that adds to customPermissions + */ + convertFormDataToPermissions(formData: ApiKeyFormData): { + roles: Role[]; + permissions: Array<{ resource: Resource; actions: AuthAction[] }>; + } { + const roles: Role[] = []; + const permissions = new Map>(); + + // 1. Add roles if provided + if (formData.roles && formData.roles.length > 0) { + roles.push(...formData.roles); + } + + // 2. Add custom permissions if provided + // This includes permissions added via the preset dropdown + if (formData.customPermissions && formData.customPermissions.length > 0) { + for (const perm of formData.customPermissions) { + // Handle resources as an array (form uses multi-select) + const resources = Array.isArray(perm.resources) + ? perm.resources + : [perm.resources as Resource]; + + // Handle actions as an array and normalize them + const rawActions = Array.isArray(perm.actions) ? perm.actions : [perm.actions]; + const normalizedActions: AuthAction[] = []; + + for (const rawAction of rawActions) { + const normalized = normalizeAction(rawAction); + if (normalized) { + normalizedActions.push(normalized); + } + } + + for (const resource of resources) { + if (!permissions.has(resource)) { + permissions.set(resource, new Set()); + } + normalizedActions.forEach((action) => permissions.get(resource)!.add(action)); + } + } + } + + return { + roles, + permissions: Array.from(permissions.entries()).map(([resource, actions]) => ({ + resource, + actions: Array.from(actions), + })), + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.ts new file mode 100644 index 000000000..e7882f5a3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; +import { + expandWildcardAction, + mergePermissionsIntoMap, + parseActionToAuthAction, +} from '@unraid/shared/util/permissions.js'; + +import { AuthService } from '@app/unraid-api/auth/auth.service.js'; +import { + AddPermissionInput, + Permission, +} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; + +@Injectable() +@Resolver() +export class ApiKeyPermissionsResolver { + constructor(private authService: AuthService) {} + + @Query(() => [Permission], { + description: 'Get the actual permissions that would be granted by a set of roles', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.PERMISSION, + }) + async getPermissionsForRoles( + @Args('roles', { type: () => [Role] }) roles: Role[] + ): Promise { + // Get the implicit permissions for each role from Casbin + const allPermissions = new Map>(); + + for (const role of roles) { + // Query Casbin for what permissions this role actually has + const rolePermissions = await this.authService.getImplicitPermissionsForRole(role); + mergePermissionsIntoMap(allPermissions, rolePermissions); + } + + // Convert to Permission array + const permissions: Permission[] = []; + for (const [resource, actions] of allPermissions) { + permissions.push({ + resource, + actions: Array.from(actions), + }); + } + + return permissions; + } + + @Query(() => [Permission], { + description: + 'Preview the effective permissions for a combination of roles and explicit permissions', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.PERMISSION, + }) + async previewEffectivePermissions( + @Args('roles', { type: () => [Role], nullable: true }) roles?: Role[], + @Args('permissions', { type: () => [AddPermissionInput], nullable: true }) + permissions?: AddPermissionInput[] + ): Promise { + const effectivePermissions = new Map>(); + + // Add permissions from roles + for (const role of roles ?? []) { + const rolePermissions = await this.authService.getImplicitPermissionsForRole(role); + mergePermissionsIntoMap(effectivePermissions, rolePermissions); + } + + // Add explicit permissions + if (permissions && permissions.length > 0) { + for (const perm of permissions) { + if (!effectivePermissions.has(perm.resource)) { + effectivePermissions.set(perm.resource, new Set()); + } + const resourceActions = effectivePermissions.get(perm.resource)!; + + perm.actions.forEach((action) => { + const actionStr = String(action); + + // Handle wildcard - expand to all CRUD actions + if (actionStr === '*' || actionStr.toLowerCase() === '*') { + expandWildcardAction().forEach((expandedAction) => { + resourceActions.add(expandedAction); + }); + } else { + // Use the shared helper to parse and validate the action + const parsedAction = parseActionToAuthAction(actionStr); + if (parsedAction) { + resourceActions.add(parsedAction); + } + } + }); + } + } + + // Convert to Permission array + const result: Permission[] = []; + for (const [resource, actions] of effectivePermissions) { + result.push({ + resource, + actions: Array.from(actions), + }); + } + + return result; + } + + @Query(() => [AuthAction], { + description: 'Get all available authentication actions with possession', + }) + getAvailableAuthActions(): AuthAction[] { + return Object.values(AuthAction); + } +} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts index 767c15a1b..122da9741 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts @@ -1,6 +1,6 @@ import { Field, InputType, ObjectType } from '@nestjs/graphql'; -import { Node, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Node, Resource, Role } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { Transform, Type } from 'class-transformer'; import { @@ -22,15 +22,21 @@ export class Permission { @IsEnum(Resource) resource!: Resource; - @Field(() => [String]) + @Field(() => [AuthAction], { + description: 'Actions allowed on this resource', + }) @IsArray() - @IsString({ each: true }) + @IsEnum(AuthAction, { each: true }) @ArrayMinSize(1) - actions!: string[]; + actions!: AuthAction[]; } @ObjectType({ implements: () => Node }) export class ApiKey extends Node { + @Field() + @IsString() + key!: string; + @Field() @IsString() @IsNotEmpty() @@ -58,24 +64,17 @@ export class ApiKey extends Node { permissions!: Permission[]; } -@ObjectType() -export class ApiKeyWithSecret extends ApiKey { - @Field() - @IsString() - key!: string; -} - @InputType() export class AddPermissionInput { @Field(() => Resource) @IsEnum(Resource) resource!: Resource; - @Field(() => [String]) + @Field(() => [AuthAction]) @IsArray() - @IsString({ each: true }) + @IsEnum(AuthAction, { each: true }) @ArrayMinSize(1) - actions!: string[]; + actions!: AuthAction[]; } @InputType() diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts index fa4a6c8a9..a202b0327 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts @@ -3,12 +3,23 @@ import { Module } from '@nestjs/common'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; +import { ApiKeyFormResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.resolver.js'; +import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js'; +import { ApiKeyPermissionsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.js'; import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; @Module({ imports: [AuthModule], - providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver], - exports: [ApiKeyResolver, ApiKeyService], + providers: [ + ApiKeyResolver, + ApiKeyService, + AuthService, + ApiKeyMutationsResolver, + ApiKeyPermissionsResolver, + ApiKeyFormService, + ApiKeyFormResolver, + ], + exports: [ApiKeyResolver, ApiKeyService, ApiKeyFormService], }) export class ApiKeyModule {} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts index 37bda7caa..ea1e05eea 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts @@ -8,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { CookieService } from '@app/unraid-api/auth/cookie.service.js'; import { ApiKey, - ApiKeyWithSecret, CreateApiKeyInput, DeleteApiKeyInput, } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; @@ -23,16 +22,7 @@ describe('ApiKeyMutationsResolver', () => { const mockApiKey: ApiKey = { id: 'test-api-id', - name: 'Test API Key', - description: 'Test API Key Description', - roles: [Role.GUEST], - createdAt: new Date().toISOString(), - permissions: [], - }; - - const mockApiKeyWithSecret: ApiKeyWithSecret = { - id: 'test-api-id', - key: 'test-api-key', + key: 'test-secret-key', name: 'Test API Key', description: 'Test API Key Description', roles: [Role.GUEST], @@ -61,12 +51,12 @@ describe('ApiKeyMutationsResolver', () => { permissions: [], }; - vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey); vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(); const result = await resolver.create(input); - expect(result).toEqual(mockApiKeyWithSecret); + expect(result).toEqual(mockApiKey); expect(apiKeyService.create).toHaveBeenCalledWith({ name: input.name, description: input.description, @@ -95,7 +85,7 @@ describe('ApiKeyMutationsResolver', () => { roles: [Role.GUEST], permissions: [], }; - vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey); vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed')); await expect(resolver.create(input)).rejects.toThrow('Sync failed'); }); diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts index d1ce77043..ae606fc94 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts @@ -1,24 +1,19 @@ import { Args, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { AddRoleForApiKeyInput, - ApiKeyWithSecret, + ApiKey, CreateApiKeyInput, DeleteApiKeyInput, RemoveRoleFromApiKeyInput, UpdateApiKeyInput, } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; -import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; @Resolver(() => ApiKeyMutations) export class ApiKeyMutationsResolver { @@ -28,12 +23,11 @@ export class ApiKeyMutationsResolver { ) {} @UsePermissions({ - action: AuthActionVerb.CREATE, + action: AuthAction.CREATE_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) - @ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' }) - async create(@Args('input') input: CreateApiKeyInput): Promise { + @ResolveField(() => ApiKey, { description: 'Create an API key' }) + async create(@Args('input') input: CreateApiKeyInput): Promise { const apiKey = await this.apiKeyService.create({ name: input.name, description: input.description ?? undefined, @@ -46,9 +40,8 @@ export class ApiKeyMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Add a role to an API key' }) async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise { @@ -56,9 +49,8 @@ export class ApiKeyMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Remove a role from an API key' }) async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise { @@ -66,9 +58,8 @@ export class ApiKeyMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.DELETE, + action: AuthAction.DELETE_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Delete one or more API keys' }) async delete(@Args('input') input: DeleteApiKeyInput): Promise { @@ -77,12 +68,11 @@ export class ApiKeyMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) - @ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' }) - async update(@Args('input') input: UpdateApiKeyInput): Promise { + @ResolveField(() => ApiKey, { description: 'Update an API key' }) + async update(@Args('input') input: UpdateApiKeyInput): Promise { const apiKey = await this.apiKeyService.update(input); await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles); return apiKey; diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts index f58279b4a..bdf483a60 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { CookieService } from '@app/unraid-api/auth/cookie.service.js'; -import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; describe('ApiKeyResolver', () => { @@ -18,16 +18,7 @@ describe('ApiKeyResolver', () => { const mockApiKey: ApiKey = { id: 'test-api-id', - name: 'Test API Key', - description: 'Test API Key Description', - roles: [Role.GUEST], - createdAt: new Date().toISOString(), - permissions: [], - }; - - const mockApiKeyWithSecret: ApiKeyWithSecret = { - id: 'test-api-id', - key: 'test-api-key', + key: 'test-secret-key', name: 'Test API Key', description: 'Test API Key Description', roles: [Role.GUEST], @@ -44,7 +35,7 @@ describe('ApiKeyResolver', () => { authzService = new AuthZService(enforcer); cookieService = new CookieService(); authService = new AuthService(cookieService, apiKeyService, authzService); - resolver = new ApiKeyResolver(authService, apiKeyService); + resolver = new ApiKeyResolver(apiKeyService); }); describe('apiKeys', () => { diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts index 0a9697f82..ddcf0841a 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts @@ -1,29 +1,20 @@ import { Args, Query, Resolver } from '@nestjs/graphql'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; -import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; @Resolver(() => ApiKey) export class ApiKeyResolver { - constructor( - private authService: AuthService, - private apiKeyService: ApiKeyService - ) {} + constructor(private apiKeyService: ApiKeyService) {} @Query(() => [ApiKey]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) async apiKeys(): Promise { return this.apiKeyService.findAll(); @@ -31,9 +22,8 @@ export class ApiKeyResolver { @Query(() => ApiKey, { nullable: true }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.API_KEY, - possession: AuthPossession.ANY, }) async apiKey( @Args('id', { type: () => PrefixedID }) @@ -44,9 +34,8 @@ export class ApiKeyResolver { @Query(() => [Role], { description: 'All possible roles for API keys' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.PERMISSION, - possession: AuthPossession.ANY, }) async apiKeyPossibleRoles(): Promise { return Object.values(Role); @@ -54,14 +43,13 @@ export class ApiKeyResolver { @Query(() => [Permission], { description: 'All possible permissions for API keys' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.PERMISSION, - possession: AuthPossession.ANY, }) async apiKeyPossiblePermissions(): Promise { - // Build all combinations of Resource and AuthActionVerb + // Build all combinations of Resource and AuthAction const resources = Object.values(Resource); - const actions = Object.values(AuthActionVerb); + const actions = Object.values(AuthAction); return resources.map((resource) => ({ resource, actions, diff --git a/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts index 9ad8a84be..4b7a04ce4 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts @@ -1,13 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { ArrayDisk, @@ -27,9 +23,8 @@ export class ArrayMutationsResolver { @ResolveField(() => UnraidArray, { description: 'Set array state' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async setState(@Args('input') input: ArrayStateInput): Promise { return this.arrayService.updateArrayState(input); @@ -37,9 +32,8 @@ export class ArrayMutationsResolver { @ResolveField(() => UnraidArray, { description: 'Add new disk to array' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async addDiskToArray(@Args('input') input: ArrayDiskInput): Promise { return this.arrayService.addDiskToArray(input); @@ -50,9 +44,8 @@ export class ArrayMutationsResolver { "Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.", }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async removeDiskFromArray(@Args('input') input: ArrayDiskInput): Promise { return this.arrayService.removeDiskFromArray(input); @@ -60,9 +53,8 @@ export class ArrayMutationsResolver { @ResolveField(() => ArrayDisk, { description: 'Mount a disk in the array' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async mountArrayDisk(@Args('id', { type: () => PrefixedID }) id: string): Promise { const array = await this.arrayService.mountArrayDisk(id); @@ -80,9 +72,8 @@ export class ArrayMutationsResolver { @ResolveField(() => ArrayDisk, { description: 'Unmount a disk from the array' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async unmountArrayDisk( @Args('id', { type: () => PrefixedID }) id: string @@ -102,9 +93,8 @@ export class ArrayMutationsResolver { @ResolveField(() => Boolean, { description: 'Clear statistics for a disk in the array' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async clearArrayDiskStatistics( @Args('id', { type: () => PrefixedID }) id: string diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index cfa7a6b9a..40734973e 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js'; @@ -17,9 +13,8 @@ export class ArrayResolver { @Query(() => UnraidArray) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async array() { return this.arrayService.getArrayData(); @@ -27,9 +22,8 @@ export class ArrayResolver { @Subscription(() => UnraidArray) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) public async arraySubscription() { return createSubscription(PUBSUB_CHANNEL.ARRAY); diff --git a/api/src/unraid-api/graph/resolvers/array/parity.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/array/parity.mutations.resolver.ts index ff72da865..897347722 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.mutations.resolver.ts @@ -1,11 +1,7 @@ import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js'; @@ -19,9 +15,8 @@ export class ParityCheckMutationsResolver { constructor(private readonly parityService: ParityService) {} @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @ResolveField(() => GraphQLJSON, { description: 'Start a parity check' }) async start(@Args('correct') correct: boolean): Promise { @@ -32,9 +27,8 @@ export class ParityCheckMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @ResolveField(() => GraphQLJSON, { description: 'Pause a parity check' }) async pause(): Promise { @@ -45,9 +39,8 @@ export class ParityCheckMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @ResolveField(() => GraphQLJSON, { description: 'Resume a parity check' }) async resume(): Promise { @@ -58,9 +51,8 @@ export class ParityCheckMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @ResolveField(() => GraphQLJSON, { description: 'Cancel a parity check' }) async cancel(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts index 7d13b6643..07b304c3c 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { PubSub } from 'graphql-subscriptions'; import { PUBSUB_CHANNEL } from '@app/core/pubsub.js'; @@ -23,9 +19,8 @@ export class ParityResolver { ) {} @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @Query(() => [ParityCheck]) async parityHistory(): Promise { @@ -33,9 +28,8 @@ export class ParityResolver { } @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ARRAY, - possession: AuthPossession.ANY, }) @Subscription(() => ParityCheck) parityHistorySubscription() { diff --git a/api/src/unraid-api/graph/resolvers/config/config.resolver.ts b/api/src/unraid-api/graph/resolvers/config/config.resolver.ts index 400303cea..caba50e18 100644 --- a/api/src/unraid-api/graph/resolvers/config/config.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/config/config.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getters } from '@app/store/index.js'; import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js'; @@ -14,9 +10,8 @@ import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js'; export class ConfigResolver { @Query(() => Config) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) public async config(): Promise { const emhttp = getters.emhttp(); diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index fe6abb56d..7a8750ef3 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -1,11 +1,7 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator @@ -23,9 +19,8 @@ export class CustomizationResolver { // Authenticated query @Query(() => Customization, { nullable: true }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CUSTOMIZATIONS, - possession: AuthPossession.ANY, }) async customization(): Promise { // We return an empty object because the fields are resolved by @ResolveField @@ -52,9 +47,8 @@ export class CustomizationResolver { @ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ACTIVATION_CODE, - possession: AuthPossession.ANY, }) async activationCode(): Promise { return this.customizationService.getActivationData(); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts index cfb660685..9b23bf987 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -1,12 +1,8 @@ import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; @@ -17,9 +13,8 @@ export class DisksResolver { @Query(() => [Disk]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DISK, - possession: AuthPossession.ANY, }) public async disks() { return this.disksService.getDisks(); @@ -27,9 +22,8 @@ export class DisksResolver { @Query(() => Disk) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DISK, - possession: AuthPossession.ANY, }) public async disk(@Args('id', { type: () => PrefixedID }) id: string) { return this.disksService.getDisk(id); diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 50a9b37f3..558c2b4be 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; @@ -16,9 +12,8 @@ export class DisplayResolver { constructor(private readonly displayService: DisplayService) {} @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DISPLAY, - possession: AuthPossession.ANY, }) @Query(() => Display) public async display(): Promise { @@ -27,9 +22,8 @@ export class DisplayResolver { @Subscription(() => Display) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DISPLAY, - possession: AuthPossession.ANY, }) public async displaySubscription() { return createSubscription(PUBSUB_CHANNEL.DISPLAY); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts index 156ec04b1..a1de7e0db 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts @@ -1,12 +1,8 @@ import { Args, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -21,9 +17,8 @@ export class DockerMutationsResolver { @ResolveField(() => DockerContainer, { description: 'Start a container' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) public async start(@Args('id', { type: () => PrefixedID }) id: string) { return this.dockerService.start(id); @@ -31,9 +26,8 @@ export class DockerMutationsResolver { @ResolveField(() => DockerContainer, { description: 'Stop a container' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) public async stop(@Args('id', { type: () => PrefixedID }) id: string) { return this.dockerService.stop(id); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 505774178..5948cc6e7 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -1,11 +1,7 @@ import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; import { @@ -25,9 +21,8 @@ export class DockerResolver { ) {} @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @Query(() => Docker) public docker() { @@ -37,9 +32,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @ResolveField(() => [DockerContainer]) public async containers( @@ -49,9 +43,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @ResolveField(() => [DockerNetwork]) public async networks( @@ -61,9 +54,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @ResolveField(() => ResolvedOrganizerV1) public async organizer() { @@ -71,9 +63,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @Mutation(() => ResolvedOrganizerV1) public async createDockerFolder( @@ -90,9 +81,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @Mutation(() => ResolvedOrganizerV1) public async setDockerFolderChildren( @@ -107,9 +97,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @Mutation(() => ResolvedOrganizerV1) public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) { @@ -120,9 +109,8 @@ export class DockerResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, - possession: AuthPossession.ANY, }) @Mutation(() => ResolvedOrganizerV1) public async moveDockerEntriesToFolder( diff --git a/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts b/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts index 869a1ab36..1c1ce6c3f 100644 --- a/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/flash/flash.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getters } from '@app/store/index.js'; import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js'; @@ -14,9 +10,8 @@ import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js'; export class FlashResolver { @Query(() => Flash) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.FLASH, - possession: AuthPossession.ANY, }) public async flash() { const emhttp = getters.emhttp(); diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts index b029a24c0..7928c0d71 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -1,11 +1,7 @@ import { GraphQLISODateTime, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { baseboard as getBaseboard, system as getSystem } from 'systeminformation'; import { getMachineId } from '@app/core/utils/misc/get-machine-id.js'; @@ -35,9 +31,8 @@ export class InfoResolver { @Query(() => Info) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.INFO, - possession: AuthPossession.ANY, }) public async info(): Promise> { return { diff --git a/api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts b/api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts index f342c4540..436993737 100644 --- a/api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts @@ -1,11 +1,7 @@ import { Args, Int, Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { LogFile, LogFileContent } from '@app/unraid-api/graph/resolvers/logs/logs.model.js'; @@ -17,9 +13,8 @@ export class LogsResolver { @Query(() => [LogFile]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.LOGS, - possession: AuthPossession.ANY, }) async logFiles(): Promise { return this.logsService.listLogFiles(); @@ -27,9 +22,8 @@ export class LogsResolver { @Query(() => LogFileContent) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.LOGS, - possession: AuthPossession.ANY, }) async logFile( @Args('path') path: string, @@ -41,9 +35,8 @@ export class LogsResolver { @Subscription(() => LogFileContent, { name: 'logFile' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.LOGS, - possession: AuthPossession.ANY, }) async logFileSubscription(@Args('path') path: string) { // Start watching the file diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index d8d11050d..abfff8077 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -1,12 +1,8 @@ import { OnModuleInit } from '@nestjs/common'; import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; @@ -50,9 +46,8 @@ export class MetricsResolver implements OnModuleInit { @Query(() => Metrics) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.INFO, - possession: AuthPossession.ANY, }) public async metrics(): Promise> { return { @@ -75,9 +70,8 @@ export class MetricsResolver implements OnModuleInit { resolve: (value) => value.systemMetricsCpu, }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.INFO, - possession: AuthPossession.ANY, }) public async systemMetricsCpuSubscription() { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); @@ -88,9 +82,8 @@ export class MetricsResolver implements OnModuleInit { resolve: (value) => value.systemMetricsMemory, }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.INFO, - possession: AuthPossession.ANY, }) public async systemMetricsMemorySubscription() { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index 38eba7c72..fe6e56ad6 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -1,12 +1,8 @@ import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { AppError } from '@app/core/errors/app-error.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; @@ -31,9 +27,8 @@ export class NotificationsResolver { @Query(() => Notifications, { description: 'Get all notifications' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.NOTIFICATIONS, - possession: AuthPossession.ANY, }) public async notifications(): Promise { return { @@ -153,9 +148,8 @@ export class NotificationsResolver { @Subscription(() => Notification) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.NOTIFICATIONS, - possession: AuthPossession.ANY, }) async notificationAdded() { return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED); @@ -163,9 +157,8 @@ export class NotificationsResolver { @Subscription(() => NotificationOverview) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.NOTIFICATIONS, - possession: AuthPossession.ANY, }) async notificationsOverview() { return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); diff --git a/api/src/unraid-api/graph/resolvers/online/online.resolver.ts b/api/src/unraid-api/graph/resolvers/online/online.resolver.ts index 23354489a..a45a82f47 100644 --- a/api/src/unraid-api/graph/resolvers/online/online.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/online/online.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js'; @@ -13,9 +9,8 @@ import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js'; export class OnlineResolver { @Query(() => Boolean) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ONLINE, - possession: AuthPossession.ANY, }) public async online() { return true; diff --git a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts index 00c298f51..c4f20ca5d 100644 --- a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts @@ -1,12 +1,8 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js'; @@ -17,9 +13,8 @@ export class OwnerResolver { constructor(private readonly configService: ConfigService) {} @Query(() => Owner) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.OWNER, - possession: AuthPossession.ANY, }) public async owner() { const config = this.configService.get('connect.config'); @@ -40,9 +35,8 @@ export class OwnerResolver { @Subscription(() => Owner) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.OWNER, - possession: AuthPossession.ANY, }) public ownerSubscription() { return createSubscription(PUBSUB_CHANNEL.OWNER); diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts index 199f318b2..2dbcc2e29 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts @@ -255,6 +255,7 @@ export function getProviderConfigSlice({ description: option.Help || undefined, controlOptions: controlOptions, rule: providerRule, + passScopeToLayout: true, }); return labeledControl; diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts index b62f04956..759089705 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.ts @@ -1,12 +1,8 @@ import { Logger } from '@nestjs/common'; import { Args, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { RCloneMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; @@ -27,9 +23,8 @@ export class RCloneMutationsResolver { @ResolveField(() => RCloneRemote, { description: 'Create a new RClone remote' }) @UsePermissions({ - action: AuthActionVerb.CREATE, + action: AuthAction.CREATE_ANY, resource: Resource.FLASH, - possession: AuthPossession.ANY, }) async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise { try { @@ -48,9 +43,8 @@ export class RCloneMutationsResolver { @ResolveField(() => Boolean, { description: 'Delete an existing RClone remote' }) @UsePermissions({ - action: AuthActionVerb.DELETE, + action: AuthAction.DELETE_ANY, resource: Resource.FLASH, - possession: AuthPossession.ANY, }) async deleteRCloneRemote(@Args('input') input: DeleteRCloneRemoteInput): Promise { try { diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts index f101ae4b9..21c462ac0 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts @@ -1,12 +1,8 @@ import { Logger } from '@nestjs/common'; import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js'; @@ -31,9 +27,8 @@ export class RCloneBackupSettingsResolver { @Query(() => RCloneBackupSettings) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.FLASH, - possession: AuthPossession.ANY, }) async rclone(): Promise { return {} as RCloneBackupSettings; diff --git a/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts b/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts index 346cb7764..eb167635d 100644 --- a/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/registration/registration.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getKeyFile } from '@app/core/utils/misc/get-key-file.js'; import { getters } from '@app/store/index.js'; @@ -19,9 +15,8 @@ import { export class RegistrationResolver { @Query(() => Registration, { nullable: true }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.REGISTRATION, - possession: AuthPossession.ANY, }) public async registration(): Promise { const emhttp = getters.emhttp(); diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index a21569165..f5e3e8779 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -2,12 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; @@ -24,9 +20,8 @@ export class ServerResolver { constructor(private readonly configService: ConfigService) {} @Query(() => ServerModel, { nullable: true }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.SERVERS, - possession: AuthPossession.ANY, }) public async server(): Promise { return this.getLocalServer()[0] || null; @@ -34,9 +29,8 @@ export class ServerResolver { @Query(() => [ServerModel]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.SERVERS, - possession: AuthPossession.ANY, }) public async servers(): Promise { return this.getLocalServer(); @@ -44,9 +38,8 @@ export class ServerResolver { @Subscription(() => ServerModel) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.SERVERS, - possession: AuthPossession.ANY, }) public async serversSubscription() { return createSubscription(PUBSUB_CHANNEL.SERVERS); diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.model.ts b/api/src/unraid-api/graph/resolvers/settings/settings.model.ts index 7e71e78b7..268f973fc 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.model.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.model.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, InterfaceType, ObjectType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; import { IsObject, ValidateNested } from 'class-validator'; @@ -6,10 +6,25 @@ import { GraphQLJSON } from 'graphql-scalars'; import { SsoSettings } from '@app/unraid-api/graph/resolvers/settings/sso-settings.model.js'; +@InterfaceType() +export abstract class FormSchema { + @Field(() => GraphQLJSON, { description: 'The data schema for the form' }) + @IsObject() + dataSchema!: Record; + + @Field(() => GraphQLJSON, { description: 'The UI schema for the form' }) + @IsObject() + uiSchema!: Record; + + @Field(() => GraphQLJSON, { description: 'The current values of the form' }) + @IsObject() + values!: Record; +} + @ObjectType({ - implements: () => Node, + implements: () => [Node, FormSchema], }) -export class UnifiedSettings extends Node { +export class UnifiedSettings extends Node implements FormSchema { @Field(() => GraphQLJSON, { description: 'The data schema for the settings' }) @IsObject() dataSchema!: Record; @@ -23,6 +38,23 @@ export class UnifiedSettings extends Node { values!: Record; } +@ObjectType({ + implements: () => [Node, FormSchema], +}) +export class ApiKeyFormSettings extends Node implements FormSchema { + @Field(() => GraphQLJSON, { description: 'The data schema for the API key form' }) + @IsObject() + dataSchema!: Record; + + @Field(() => GraphQLJSON, { description: 'The UI schema for the API key form' }) + @IsObject() + uiSchema!: Record; + + @Field(() => GraphQLJSON, { description: 'The current values of the API key form' }) + @IsObject() + values!: Record; +} + @ObjectType() export class UpdateSettingsResponse { @Field(() => Boolean, { diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts index b69713104..5b1b2a554 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts @@ -1,14 +1,10 @@ import { Logger } from '@nestjs/common'; import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { ApiConfig } from '@unraid/shared/services/api-config.js'; import { UserSettingsService } from '@unraid/shared/services/user-settings.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js'; @@ -101,9 +97,8 @@ export class UnifiedSettingsResolver { @Mutation(() => UpdateSettingsResponse) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) async updateSettings( @Args('input', { type: () => GraphQLJSON }) input: Record diff --git a/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts b/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts index 4d4da9dd7..63e0b962a 100644 --- a/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts @@ -1,12 +1,9 @@ import { Logger } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; @@ -73,9 +70,8 @@ export class SsoResolver { @Query(() => [OidcProvider], { description: 'Get all configured OIDC providers (admin only)' }) @UsePermissions({ - action: AuthActionVerb.READ, - resource: 'sso', - possession: AuthPossession.ANY, + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, }) public async oidcProviders(): Promise { return this.oidcConfig.getProviders(); @@ -83,9 +79,8 @@ export class SsoResolver { @Query(() => OidcProvider, { nullable: true, description: 'Get a specific OIDC provider by ID' }) @UsePermissions({ - action: AuthActionVerb.READ, - resource: 'sso', - possession: AuthPossession.ANY, + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, }) public async oidcProvider( @Args('id', { type: () => PrefixedID }) id: string @@ -97,9 +92,8 @@ export class SsoResolver { description: 'Validate an OIDC session token (internal use for CLI validation)', }) @UsePermissions({ - action: AuthActionVerb.READ, - resource: 'sso', - possession: AuthPossession.ANY, + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, }) public async validateOidcSession(@Args('token') token: string): Promise { return await this.oidcSessionService.validateSession(token); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts index 4b59dda0c..eab8fc9c0 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getters } from '@app/store/index.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; @@ -16,9 +12,8 @@ import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js'; export class VarsResolver { @Query(() => Vars) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.VARS, - possession: AuthPossession.ANY, }) public async vars() { return { diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/vms/vms.mutations.resolver.ts index 79b49bfc2..8deed7a84 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.mutations.resolver.ts @@ -1,12 +1,8 @@ import { Args, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { VmMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js'; @@ -19,9 +15,8 @@ export class VmMutationsResolver { constructor(private readonly vmsService: VmsService) {} @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Start a virtual machine' }) async start(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -29,9 +24,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Stop a virtual machine' }) async stop(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -39,9 +33,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Pause a virtual machine' }) async pause(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -49,9 +42,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Resume a virtual machine' }) async resume(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -59,9 +51,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Force stop a virtual machine' }) async forceStop(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -69,9 +60,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Reboot a virtual machine' }) async reboot(@Args('id', { type: () => PrefixedID }) id: string): Promise { @@ -79,9 +69,8 @@ export class VmMutationsResolver { } @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) @ResolveField(() => Boolean, { description: 'Reset a virtual machine' }) async reset(@Args('id', { type: () => PrefixedID }) id: string): Promise { diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts b/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts index 6604cea18..3b3b324f6 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts @@ -1,11 +1,7 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { VmDomain, Vms } from '@app/unraid-api/graph/resolvers/vms/vms.model.js'; import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js'; @@ -16,9 +12,8 @@ export class VmsResolver { @Query(() => Vms, { description: 'Get information about all VMs on the system' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.VMS, - possession: AuthPossession.ANY, }) public async vms() { return { diff --git a/api/src/unraid-api/graph/services/services.resolver.ts b/api/src/unraid-api/graph/services/services.resolver.ts index 4dc57aee9..6b1bf0a34 100644 --- a/api/src/unraid-api/graph/services/services.resolver.ts +++ b/api/src/unraid-api/graph/services/services.resolver.ts @@ -1,12 +1,8 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js'; import { API_VERSION } from '@app/environment.js'; @@ -48,9 +44,8 @@ export class ServicesResolver { @Query(() => [Service]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.SERVICES, - possession: AuthPossession.ANY, }) public services(): Service[] { const dynamicRemoteAccess = this.getDynamicRemoteAccessService(); diff --git a/api/src/unraid-api/graph/shares/shares.resolver.ts b/api/src/unraid-api/graph/shares/shares.resolver.ts index bf6e2bad8..f9155402a 100644 --- a/api/src/unraid-api/graph/shares/shares.resolver.ts +++ b/api/src/unraid-api/graph/shares/shares.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getShares } from '@app/core/utils/shares/get-shares.js'; import { Share } from '@app/unraid-api/graph/resolvers/array/array.model.js'; @@ -16,9 +12,8 @@ export class SharesResolver { @Query(() => [Share]) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.SHARE, - possession: AuthPossession.ANY, }) public async shares() { const userShares = getShares('users'); diff --git a/api/src/unraid-api/graph/user/user.resolver.spec.ts b/api/src/unraid-api/graph/user/user.resolver.spec.ts index e0ff470ab..55fcf9af8 100644 --- a/api/src/unraid-api/graph/user/user.resolver.spec.ts +++ b/api/src/unraid-api/graph/user/user.resolver.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { Resource, Role } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; import { AuthZService } from 'nest-authz'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -47,7 +47,7 @@ describe('MeResolver', () => { permissions: [ { resource: Resource.ME, - actions: ['read'], + actions: [AuthAction.READ_ANY], }, ], }; diff --git a/api/src/unraid-api/graph/user/user.resolver.ts b/api/src/unraid-api/graph/user/user.resolver.ts index a1000ee75..e6f8defc6 100644 --- a/api/src/unraid-api/graph/user/user.resolver.ts +++ b/api/src/unraid-api/graph/user/user.resolver.ts @@ -1,11 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphqlUser } from '@app/unraid-api/auth/user.decorator.js'; import { UserAccount } from '@app/unraid-api/graph/user/user.model.js'; @@ -16,9 +12,8 @@ export class MeResolver { @Query(() => UserAccount) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.ME, - possession: AuthPossession.ANY, }) public async me(@GraphqlUser() user: UserAccount): Promise { return user; diff --git a/api/src/unraid-api/graph/utils/form-utils.ts b/api/src/unraid-api/graph/utils/form-utils.ts index f3f46cc91..1e1dea73b 100644 --- a/api/src/unraid-api/graph/utils/form-utils.ts +++ b/api/src/unraid-api/graph/utils/form-utils.ts @@ -53,34 +53,44 @@ export function createLabeledControl({ controlOptions, labelOptions, layoutOptions, + layoutType = 'UnraidSettingsLayout', rule, + passScopeToLayout = false, }: { scope: string; label: string; description?: string; - controlOptions: ControlElement['options']; + controlOptions?: ControlElement['options']; labelOptions?: LabelElement['options']; layoutOptions?: Layout['options']; + layoutType?: 'UnraidSettingsLayout' | 'VerticalLayout' | 'HorizontalLayout'; rule?: Rule; + passScopeToLayout?: boolean; }): Layout { - const layout: Layout & { scope?: string } = { - type: 'UnraidSettingsLayout', // Use the specific Unraid layout type - scope: scope, // Apply scope to the layout for potential rules/visibility + const elements: Array = [ + { + type: 'Label', + text: label, + options: { ...labelOptions, description }, + } as LabelElement, + { + type: 'Control', + scope: scope, + options: controlOptions, + } as ControlElement, + ]; + + const layout: Layout = { + type: layoutType, options: layoutOptions, - elements: [ - { - type: 'Label', - text: label, - scope: scope, // Scope might be needed for specific label behaviors - options: { ...labelOptions, description }, - } as LabelElement, - { - type: 'Control', - scope: scope, - options: controlOptions, - } as ControlElement, - ], - }; + elements, + } as Layout; + + // Optionally add scope to the layout itself (for backward compatibility) + if (passScopeToLayout) { + (layout as any).scope = scope; + } + // Conditionally add the rule to the layout if provided if (rule) { layout.rule = rule; diff --git a/api/src/unraid-api/plugin/plugin.resolver.ts b/api/src/unraid-api/plugin/plugin.resolver.ts index f039c9f9e..2a2e4e412 100644 --- a/api/src/unraid-api/plugin/plugin.resolver.ts +++ b/api/src/unraid-api/plugin/plugin.resolver.ts @@ -2,11 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Resource } from '@unraid/shared/graphql.model.js'; -import { - AuthActionVerb, - AuthPossession, - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js'; import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js'; @@ -23,9 +19,8 @@ export class PluginResolver { @Query(() => [Plugin], { description: 'List all installed plugins with their metadata' }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) async plugins(): Promise { const plugins = await PluginService.getPlugins(); @@ -47,9 +42,8 @@ export class PluginResolver { 'Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required.', }) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) async addPlugin(@Args('input') input: PluginManagementInput): Promise { if (input.bundled) { @@ -76,9 +70,8 @@ export class PluginResolver { 'Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.', }) @UsePermissions({ - action: AuthActionVerb.DELETE, + action: AuthAction.DELETE_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) async removePlugin(@Args('input') input: PluginManagementInput): Promise { if (input.bundled) { diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index 0a7004234..3a60855ae 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Logger, Param, Query, Req, Res, UnauthorizedException } from '@nestjs/common'; -import { Resource } from '@unraid/shared/graphql.model.js'; -import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; @@ -24,9 +24,8 @@ export class RestController { @Get('/graphql/api/logs') @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.LOGS, - possession: AuthPossession.ANY, }) async getLogs(@Res() res: FastifyReply) { try { @@ -40,9 +39,8 @@ export class RestController { @Get('/graphql/api/customizations/:type') @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CUSTOMIZATIONS, - possession: AuthPossession.ANY, }) async getCustomizations(@Param('type') type: string, @Res() res: FastifyReply) { if (type !== 'banner' && type !== 'case') { diff --git a/packages/unraid-api-plugin-connect/src/authn/connect-api-key.service.ts b/packages/unraid-api-plugin-connect/src/authn/connect-api-key.service.ts index 9b60457e5..834cda322 100644 --- a/packages/unraid-api-plugin-connect/src/authn/connect-api-key.service.ts +++ b/packages/unraid-api-plugin-connect/src/authn/connect-api-key.service.ts @@ -1,9 +1,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import { ApiKey, ApiKeyWithSecret, Permission, Role } from '@unraid/shared/graphql.model.js'; -import { ApiKeyService } from '@unraid/shared/services/api-key.js'; +import { ApiKey, AuthAction, Permission, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { ApiKeyService, CreatePermissionsInput } from '@unraid/shared/services/api-key.js'; import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js'; -import { AuthActionVerb } from 'nest-authz'; @Injectable() export class ConnectApiKeyService implements ApiKeyService { @@ -22,15 +21,15 @@ export class ConnectApiKeyService implements ApiKeyService { return this.apiKeyService.findById(id); } - findByIdWithSecret(id: string): ApiKeyWithSecret | null { + findByIdWithSecret(id: string): ApiKey | null { return this.apiKeyService.findByIdWithSecret(id); } - findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null { + findByField(field: keyof ApiKey, value: string): ApiKey | null { return this.apiKeyService.findByField(field, value); } - findByKey(key: string): ApiKeyWithSecret | null { + findByKey(key: string): ApiKey | null { return this.apiKeyService.findByKey(key); } @@ -38,9 +37,9 @@ export class ConnectApiKeyService implements ApiKeyService { name: string; description?: string; roles?: Role[]; - permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[]; + permissions?: CreatePermissionsInput; overwrite?: boolean; - }): Promise { + }): Promise { return this.apiKeyService.create(input); } @@ -67,7 +66,7 @@ export class ConnectApiKeyService implements ApiKeyService { /** * Creates a local API key specifically for Connect */ - public async createLocalConnectApiKey(): Promise { + public async createLocalConnectApiKey(): Promise { try { return await this.create({ name: ConnectApiKeyService.CONNECT_API_KEY_NAME, diff --git a/packages/unraid-api-plugin-connect/src/connection-status/cloud.resolver.ts b/packages/unraid-api-plugin-connect/src/connection-status/cloud.resolver.ts index f7d397663..5c39ddb7e 100644 --- a/packages/unraid-api-plugin-connect/src/connection-status/cloud.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/connection-status/cloud.resolver.ts @@ -1,9 +1,7 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { - AuthActionVerb, - AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; @@ -22,9 +20,8 @@ export class CloudResolver { ) {} @Query(() => Cloud) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CLOUD, - possession: AuthPossession.ANY, }) public async cloud(): Promise { const minigraphql = this.cloudService.checkMothershipClient(); diff --git a/packages/unraid-api-plugin-connect/src/network/network.resolver.ts b/packages/unraid-api-plugin-connect/src/network/network.resolver.ts index 34a421af8..17644cc82 100644 --- a/packages/unraid-api-plugin-connect/src/network/network.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/network/network.resolver.ts @@ -1,10 +1,8 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { AccessUrl } from '@unraid/shared/network.model.js'; import { - AuthActionVerb, - AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; @@ -16,9 +14,8 @@ export class NetworkResolver { constructor(private readonly urlResolverService: UrlResolverService) {} @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.NETWORK, - possession: AuthPossession.ANY, }) @Query(() => Network) public async network(): Promise { diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index 2f64a3845..bcb422210 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -3,12 +3,11 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { type Layout } from '@jsonforms/core'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { DataSlice } from '@unraid/shared/jsonforms/settings.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; -import { AuthActionVerb, AuthPossession } from 'nest-authz'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsService } from './connect-settings.service.js'; @@ -62,9 +61,8 @@ export class ConnectSettingsResolver { @Query(() => RemoteAccess) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CONNECT, - possession: AuthPossession.ANY, }) public async remoteAccess(): Promise { return this.connectSettingsService.dynamicRemoteAccessSettings(); @@ -72,9 +70,8 @@ export class ConnectSettingsResolver { @Mutation(() => ConnectSettingsValues) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, - possession: AuthPossession.ANY, }) public async updateApiSettings(@Args('input') settings: ConnectSettingsInput) { this.logger.verbose(`Attempting to update API settings: ${JSON.stringify(settings, null, 2)}`); @@ -92,9 +89,8 @@ export class ConnectSettingsResolver { @Mutation(() => Boolean) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONNECT, - possession: AuthPossession.ANY, }) public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise { return this.connectSettingsService.signIn(input); @@ -102,9 +98,8 @@ export class ConnectSettingsResolver { @Mutation(() => Boolean) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONNECT, - possession: AuthPossession.ANY, }) public async connectSignOut() { this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' }); @@ -113,9 +108,8 @@ export class ConnectSettingsResolver { @Mutation(() => Boolean) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONNECT, - possession: AuthPossession.ANY, }) public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise { await this.connectSettingsService.syncSettings({ @@ -128,9 +122,8 @@ export class ConnectSettingsResolver { @Mutation(() => Boolean) @UsePermissions({ - action: AuthActionVerb.UPDATE, + action: AuthAction.UPDATE_ANY, resource: Resource.CONNECT__REMOTE_ACCESS, - possession: AuthPossession.ANY, }) public async enableDynamicRemoteAccess( @Args('input') dynamicRemoteAccessInput: EnableDynamicRemoteAccessInput diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect.resolver.ts index 404106d0c..1d7964b79 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect.resolver.ts @@ -2,10 +2,8 @@ import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { - AuthActionVerb, - AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; @@ -19,9 +17,8 @@ export class ConnectResolver { @Query(() => Connect) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.CONNECT, - possession: AuthPossession.ANY, }) public connect(): Connect { return { diff --git a/packages/unraid-shared/src/graphql-enums.ts b/packages/unraid-shared/src/graphql-enums.ts new file mode 100644 index 000000000..12c3bac2f --- /dev/null +++ b/packages/unraid-shared/src/graphql-enums.ts @@ -0,0 +1,107 @@ +// This file contains only the enum definitions without any NestJS dependencies +// Safe to import in both frontend and backend + +// Define our own AuthAction enum with matching keys and values +// This ensures GraphQL schema and runtime values are identical +export enum AuthAction { + CREATE_ANY = 'CREATE_ANY', + CREATE_OWN = 'CREATE_OWN', + READ_ANY = 'READ_ANY', + READ_OWN = 'READ_OWN', + UPDATE_ANY = 'UPDATE_ANY', + UPDATE_OWN = 'UPDATE_OWN', + DELETE_ANY = 'DELETE_ANY', + DELETE_OWN = 'DELETE_OWN', +} + +// Define Resource enum +export enum Resource { + /** Activation code management and validation */ + ACTIVATION_CODE = 'ACTIVATION_CODE', + /** API key management and administration */ + API_KEY = 'API_KEY', + /** Array operations and disk management */ + ARRAY = 'ARRAY', + /** Cloud storage and backup services */ + CLOUD = 'CLOUD', + /** System configuration and settings */ + CONFIG = 'CONFIG', + /** Unraid Connect service management */ + CONNECT = 'CONNECT', + /** Remote access functionality for Connect */ + CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS', + /** System customization and theming */ + CUSTOMIZATIONS = 'CUSTOMIZATIONS', + /** Dashboard and system overview */ + DASHBOARD = 'DASHBOARD', + /** Individual disk operations and management */ + DISK = 'DISK', + /** Display and UI settings */ + DISPLAY = 'DISPLAY', + /** Docker container management */ + DOCKER = 'DOCKER', + /** Flash drive operations and settings */ + FLASH = 'FLASH', + /** System information and status */ + INFO = 'INFO', + /** System logs and logging */ + LOGS = 'LOGS', + /** Current user profile and settings */ + ME = 'ME', + /** Network configuration and management */ + NETWORK = 'NETWORK', + /** System notifications and alerts */ + NOTIFICATIONS = 'NOTIFICATIONS', + /** Online services and connectivity */ + ONLINE = 'ONLINE', + /** Operating system operations and updates */ + OS = 'OS', + /** System ownership and licensing */ + OWNER = 'OWNER', + /** Permission management and administration */ + PERMISSION = 'PERMISSION', + /** System registration and activation */ + REGISTRATION = 'REGISTRATION', + /** My Servers management and configuration */ + SERVERS = 'SERVERS', + /** System services and daemons */ + SERVICES = 'SERVICES', + /** File share management */ + SHARE = 'SHARE', + /** System variables and environment */ + VARS = 'VARS', + /** Virtual machine management */ + VMS = 'VMS', + /** Welcome and onboarding features */ + WELCOME = 'WELCOME', +} + +export enum Role { + /** Full administrative access to all resources */ + ADMIN = 'ADMIN', + /** Read access to all resources with remote access management */ + CONNECT = 'CONNECT', + /** Basic read access to user profile only */ + GUEST = 'GUEST', + /** Read-only access to all resources */ + VIEWER = 'VIEWER', +} + +// Simple interfaces without decorators +export interface ApiKey { + id: string; + name: string; + description?: string; + roles?: Role[]; + permissions?: Permission[]; + createdAt: string; +} + +export interface ApiKeyWithSecret extends ApiKey { + key: string; +} + +export interface Permission { + resource: Resource; + actions: AuthAction[]; +} \ No newline at end of file diff --git a/packages/unraid-shared/src/graphql.model.ts b/packages/unraid-shared/src/graphql.model.ts index 7c4554d16..40f768ed1 100644 --- a/packages/unraid-shared/src/graphql.model.ts +++ b/packages/unraid-shared/src/graphql.model.ts @@ -1,49 +1,16 @@ +// This file is for backend use only - contains NestJS decorators import { Field, InterfaceType, registerEnumType } from '@nestjs/graphql'; - import { IsNotEmpty, IsString } from 'class-validator'; - import { PrefixedID } from './prefixed-id-scalar.js'; -import { AuthActionVerb } from 'nest-authz'; -// Register enums -export enum Resource { - ACTIVATION_CODE = 'ACTIVATION_CODE', - API_KEY = 'API_KEY', - ARRAY = 'ARRAY', - CLOUD = 'CLOUD', - CONFIG = 'CONFIG', - CONNECT = 'CONNECT', - CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS', - CUSTOMIZATIONS = 'CUSTOMIZATIONS', - DASHBOARD = 'DASHBOARD', - DISK = 'DISK', - DISPLAY = 'DISPLAY', - DOCKER = 'DOCKER', - FLASH = 'FLASH', - INFO = 'INFO', - LOGS = 'LOGS', - ME = 'ME', - NETWORK = 'NETWORK', - NOTIFICATIONS = 'NOTIFICATIONS', - ONLINE = 'ONLINE', - OS = 'OS', - OWNER = 'OWNER', - PERMISSION = 'PERMISSION', - REGISTRATION = 'REGISTRATION', - SERVERS = 'SERVERS', - SERVICES = 'SERVICES', - SHARE = 'SHARE', - VARS = 'VARS', - VMS = 'VMS', - WELCOME = 'WELCOME', -} +// Import enums from the shared file +import { AuthAction, Resource, Role } from './graphql-enums.js'; -export enum Role { - ADMIN = 'ADMIN', - USER = 'USER', - CONNECT = 'CONNECT', - GUEST = 'GUEST', -} +// Re-export for convenience +export { AuthAction, Resource, Role }; + +// Re-export types from graphql-enums +export type { ApiKey, ApiKeyWithSecret, Permission } from './graphql-enums.js'; @InterfaceType() export class Node { @@ -61,22 +28,51 @@ registerEnumType(Resource, { registerEnumType(Role, { name: 'Role', description: 'Available roles for API keys and users', + valuesMap: { + ADMIN: { + description: 'Full administrative access to all resources', + }, + CONNECT: { + description: 'Internal Role for Unraid Connect', + }, + GUEST: { + description: 'Basic read access to user profile only', + }, + VIEWER: { + description: 'Read-only access to all resources', + }, + }, }); -export interface ApiKey { - id: string; - name: string; - description?: string; - roles?: Role[]; - permissions?: Permission[]; - createdAt: string; -} +// Register AuthAction enum for GraphQL +registerEnumType(AuthAction, { + name: 'AuthAction', + description: 'Authentication actions with possession (e.g., create:any, read:own)', + valuesMap: { + CREATE_ANY: { + description: 'Create any resource', + }, + CREATE_OWN: { + description: 'Create own resource', + }, + READ_ANY: { + description: 'Read any resource', + }, + READ_OWN: { + description: 'Read own resource', + }, + UPDATE_ANY: { + description: 'Update any resource', + }, + UPDATE_OWN: { + description: 'Update own resource', + }, + DELETE_ANY: { + description: 'Delete any resource', + }, + DELETE_OWN: { + description: 'Delete own resource', + }, + }, +}); -export interface ApiKeyWithSecret extends ApiKey { - key: string; -} - -export interface Permission { - resource: Resource; - actions: AuthActionVerb[]; -} diff --git a/packages/unraid-shared/src/index.ts b/packages/unraid-shared/src/index.ts index f4d2cb5c9..7804ac524 100644 --- a/packages/unraid-shared/src/index.ts +++ b/packages/unraid-shared/src/index.ts @@ -3,4 +3,5 @@ export { SocketConfigService } from './services/socket-config.service.js'; export * from './graphql.model.js'; export * from './tokens.js'; export * from './use-permissions.directive.js'; +export * from './util/permissions.js'; export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js'; diff --git a/packages/unraid-shared/src/services/api-key.ts b/packages/unraid-shared/src/services/api-key.ts index c1664346a..2069eb882 100644 --- a/packages/unraid-shared/src/services/api-key.ts +++ b/packages/unraid-shared/src/services/api-key.ts @@ -1,6 +1,10 @@ -import { ApiKey, ApiKeyWithSecret, Permission } from '../graphql.model.js'; -import { Role } from '../graphql.model.js'; -import { AuthActionVerb } from 'nest-authz'; +import { ApiKey, Permission } from '../graphql.model.js'; +import { Role, AuthAction, Resource } from '../graphql.model.js'; + +/** + * Input type for creating API key permissions + */ +export type CreatePermissionsInput = Permission[] | Array<{ resource: Resource; actions: AuthAction[] }>; export interface ApiKeyService { /** @@ -9,19 +13,20 @@ export interface ApiKeyService { findById(id: string): Promise; /** - * Find an API key by its ID, including the secret key + * Find an API key by its ID + * Note: This returns ApiKey without the secret for security */ - findByIdWithSecret(id: string): ApiKeyWithSecret | null; + findByIdWithSecret(id: string): ApiKey | null; /** * Find an API key by a specific field */ - findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null; + findByField(field: keyof ApiKey, value: string): ApiKey | null; /** * Find an API key by its secret key */ - findByKey(key: string): ApiKeyWithSecret | null; + findByKey(key: string): ApiKey | null; /** * Create a new API key @@ -30,9 +35,9 @@ export interface ApiKeyService { name: string; description?: string; roles?: Role[]; - permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[]; + permissions?: CreatePermissionsInput; overwrite?: boolean; - }): Promise; + }): Promise; /** * Get all valid permissions that can be assigned to an API key diff --git a/packages/unraid-shared/src/use-permissions.directive.spec.ts b/packages/unraid-shared/src/use-permissions.directive.spec.ts new file mode 100644 index 000000000..37a3820b6 --- /dev/null +++ b/packages/unraid-shared/src/use-permissions.directive.spec.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { AuthAction, Resource } from './graphql-enums.js'; +import { UsePermissions } from './use-permissions.directive.js'; + +// Mock NestJS dependencies +vi.mock('nest-authz', () => ({ + UsePermissions: vi.fn(() => vi.fn()), +})); + +vi.mock('@nestjs/graphql', () => ({ + Directive: vi.fn(() => vi.fn()), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('UsePermissions Directive', () => { + describe('Resource Validation', () => { + it('should accept valid Resource enum values', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.API_KEY, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + + it('should accept valid Resource enum values', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.API_KEY, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + + it('should accept Resource enum values', () => { + const decorator = UsePermissions({ + action: AuthAction.CREATE_ANY, + resource: Resource.ACTIVATION_CODE, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + + it('should reject invalid resource values at runtime', () => { + // TypeScript prevents this at compile time, but we can test runtime validation + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'INVALID_RESOURCE' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + + it('should reject typos in resource names at runtime', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'API_KEYS' as any as Resource, // typo: should be API_KEY + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value: API_KEYS/); + }); + + it('should provide helpful error message listing valid resources', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'INVALID' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Must be one of:/); + }); + }); + + describe('SDL Injection Protection', () => { + it('should reject resources with special characters', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'API_KEY", malicious: "true' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + + it('should reject resources with GraphQL directive injection attempts', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'API_KEY") @skipAuth' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + + it('should reject resources with invalid lowercase names', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'api_key' as any as Resource, // lowercase not matching enum + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + + it('should validate SDL escape function rejects invalid characters', () => { + // This tests the escapeForSDL function indirectly + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, // Use the proper enum value + resource: Resource.API_KEY, + }); + + // The action should pass validation + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + }); + + describe('Action Validation', () => { + it('should accept valid AuthAction enum values', () => { + const decorator = UsePermissions({ + action: AuthAction.CREATE_OWN, + resource: Resource.API_KEY, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + + it('should reject invalid AuthAction values', () => { + const decorator = UsePermissions({ + action: 'invalid:action' as AuthAction, + resource: Resource.API_KEY, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow('Invalid AuthAction enum value: invalid:action'); + }); + + it('should reject invalid action combinations in old format', () => { + const decorator = UsePermissions({ + action: 'invalid' as any, + possession: 'any' as any, + resource: Resource.API_KEY, + } as any); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow('Invalid AuthAction enum value: invalid'); + }); + + it('should provide helpful error message listing valid actions', () => { + const decorator = UsePermissions({ + action: 'bad:action' as AuthAction, + resource: Resource.API_KEY, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Must be one of:/); + }); + }); + + describe('Legacy Format Support', () => { + it('should support old format with separate verb and possession', () => { + const decorator = UsePermissions({ + action: 'CREATE' as any, + possession: 'ANY' as any, + resource: Resource.API_KEY, + } as any); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow('Invalid AuthAction enum value: CREATE'); + }); + + it('should normalize verb and possession to AuthAction', () => { + const decorator = UsePermissions({ + action: 'READ' as any, + possession: 'OWN' as any, + resource: Resource.DASHBOARD, + } as any); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow('Invalid AuthAction enum value: READ'); + }); + }); + + describe('Error Message Clarity', () => { + it('should provide clear error for invalid resource', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'WRONG' as any as Resource, + }); + + try { + decorator({}, 'testMethod', {}); + } catch (error: any) { + expect(error.message).toContain('Invalid Resource enum value: WRONG'); + expect(error.message).toContain('Must be one of:'); + expect(error.message).toContain('API_KEY'); + expect(error.message).toContain('DASHBOARD'); + } + }); + + it('should provide clear error for invalid action', () => { + const decorator = UsePermissions({ + action: 'wrong:action' as AuthAction, + resource: Resource.API_KEY, + }); + + try { + decorator({}, 'testMethod', {}); + } catch (error: any) { + expect(error.message).toContain('Invalid AuthAction enum value: wrong:action'); + expect(error.message).toContain('Must be one of:'); + expect(error.message).toContain('CREATE_ANY'); + expect(error.message).toContain('READ_OWN'); + } + }); + + it('should provide clear error for invalid action combination', () => { + const decorator = UsePermissions({ + action: 'invalid' as any, + possession: 'wrong' as any, + resource: Resource.API_KEY, + } as any); + + try { + decorator({}, 'testMethod', {}); + } catch (error: any) { + expect(error.message).toContain('Invalid AuthAction enum value: invalid'); + expect(error.message).toContain('Must be one of:'); + } + }); + }); + + describe('Security Edge Cases', () => { + it('should handle resources with double underscores', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT__REMOTE_ACCESS, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).not.toThrow(); + }); + + it('should reject null or undefined resources', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: null as any, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(); + }); + + it('should reject empty string resources', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: '' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value: /); + }); + + it('should reject resources with newlines', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'API_KEY\n@skipAuth' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + + it('should reject resources with backslashes', () => { + const decorator = UsePermissions({ + action: AuthAction.READ_ANY, + resource: 'API_KEY\\", another: "value' as any as Resource, + }); + + expect(() => { + decorator({}, 'testMethod', {}); + }).toThrow(/Invalid Resource enum value/); + }); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/use-permissions.directive.ts b/packages/unraid-shared/src/use-permissions.directive.ts index 70ce9fe49..89d496614 100644 --- a/packages/unraid-shared/src/use-permissions.directive.ts +++ b/packages/unraid-shared/src/use-permissions.directive.ts @@ -4,80 +4,123 @@ import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; import { DirectiveLocation, GraphQLDirective, - GraphQLEnumType, GraphQLSchema, GraphQLString, } from 'graphql'; -import { AuthActionVerb, AuthPossession, UsePermissions as NestAuthzUsePermissions } from 'nest-authz'; +import { UsePermissions as NestAuthzUsePermissions } from 'nest-authz'; +// Import from graphql-enums.js to avoid NestJS dependencies +import { AuthAction, Resource } from './graphql-enums.js'; -// Re-export the types from nest-authz -export { AuthActionVerb, AuthPossession }; +// Re-export the types for convenience +export { AuthAction, Resource }; -const buildGraphQLEnum = ( - enumObj: Record, - name: string, - description: string -) => { - const values = Object.entries(enumObj) - .filter(([key]) => isNaN(Number(key))) - .reduce( - (acc, [key]) => { - acc[key] = { value: key }; - return acc; - }, - {} as Record - ); - - return new GraphQLEnumType({ name, description, values }); -}; - -// Create GraphQL enum types for auth action verbs and possessions -const AuthActionVerbEnum = buildGraphQLEnum( - AuthActionVerb, - 'AuthActionVerb', - 'Available authentication action verbs' -); - -const AuthPossessionEnum = buildGraphQLEnum( - AuthPossession, - 'AuthPossession', - 'Available authentication possession types' -); - -// Create the auth directive +/** + * GraphQL Directive Definition for @usePermissions + * + * IMPORTANT: GraphQL directives MUST use scalar types (String, Int, Boolean) for their arguments + * according to the GraphQL specification. This is why action and resource are defined as GraphQLString + * even though we use enum types in TypeScript. + * + * Type safety is enforced at: + * 1. Compile-time: TypeScript decorator requires AuthAction and Resource enum types + * 2. Runtime: The decorator validates that string values match valid enum values + * + * The generated schema will show: + * directive @usePermissions(action: String, resource: String) on FIELD_DEFINITION + * + * But the actual usage in code requires proper enum types for type safety. + */ export const UsePermissionsDirective = new GraphQLDirective({ name: 'usePermissions', description: 'Directive to document required permissions for fields', locations: [DirectiveLocation.FIELD_DEFINITION], args: { action: { - type: AuthActionVerbEnum, - description: 'The action verb required for access', + type: GraphQLString, + description: 'The action required for access (must be a valid AuthAction enum value)', }, resource: { type: GraphQLString, - description: 'The resource required for access', - }, - possession: { - type: AuthPossessionEnum, - description: 'The possession type required for access', + description: 'The resource required for access (must be a valid Resource enum value)', }, }, }); -// Create a decorator that combines both the GraphQL directive and UsePermissions -export const UsePermissions = (permissions: { - action: AuthActionVerb; - resource: string; - possession: AuthPossession; -}) => { - return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { - // Apply UsePermissions for actual authorization - NestAuthzUsePermissions(permissions)(target, propertyKey, descriptor); +/** + * Permissions interface for the UsePermissions decorator + */ +export interface Permissions { + action: AuthAction; + resource: Resource; +} - // Apply GraphQL directive using NestJS's @Directive decorator +/** + * UsePermissions Decorator + * + * Applies permission-based authorization to GraphQL resolvers and adds schema documentation. + * + * @example + * ```typescript + * @Query(() => [User]) + * @UsePermissions({ + * action: AuthAction.READ_ANY, + * resource: Resource.USERS + * }) + * async getUsers() { ... } + * ``` + * + * The decorator: + * 1. Enforces TypeScript type safety with enum types + * 2. Validates enum values at runtime + * 3. Applies nest-authz authorization checks + * 4. Adds @usePermissions directive to GraphQL schema + * + * Note: While the GraphQL schema shows String types for the directive, + * TypeScript ensures only valid enum values can be used. + */ +export function UsePermissions(permissions: Permissions): MethodDecorator { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const finalAction = permissions.action; + const finalResource = permissions.resource; + + // Runtime validation as a safety check + if (!Object.values(AuthAction).includes(finalAction)) { + throw new Error( + `Invalid AuthAction enum value: ${finalAction}. Must be one of: ${Object.values(AuthAction).join(', ')}` + ); + } + + if (!Object.values(Resource).includes(finalResource)) { + throw new Error( + `Invalid Resource enum value: ${finalResource}. Must be one of: ${Object.values(Resource).join(', ')}` + ); + } + + // Escape values for safe SDL injection + const escapeForSDL = (value: string): string => { + // Validate that the value only contains expected characters + // Allow letters, digits, underscores, colons, and hyphens (for actions like "READ_ANY", plugin-style values) + const allowedPattern = /^[A-Za-z0-9_:-]+$/; + + if (!allowedPattern.test(value)) { + throw new Error( + `Invalid characters in permission value: "${value}". Only letters, digits, underscores, colons, and hyphens are allowed.` + ); + } + + // Escape special characters for GraphQL string literals + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + }; + + const escapedAction = escapeForSDL(finalAction); + const escapedResource = escapeForSDL(finalResource); + + // Apply UsePermissions for actual authorization + NestAuthzUsePermissions({ action: finalAction, resource: finalResource })(target, propertyKey, descriptor); + + // Apply GraphQL directive using NestJS's @Directive decorator with escaped values Directive( - `@usePermissions(action: ${permissions.action.toUpperCase()}, resource: "${permissions.resource}", possession: ${permissions.possession.toUpperCase()})` + `@usePermissions(action: "${escapedAction}", resource: "${escapedResource}")` )(target, propertyKey, descriptor); return descriptor; @@ -93,10 +136,9 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) { const { action: actionValue, resource: resourceValue, - possession: possessionValue, } = usePermissionsDirective; - if (!actionValue || !resourceValue || !possessionValue) { + if (!actionValue || !resourceValue) { console.warn( `UsePermissions directive on ${typeName}.${fieldName} is missing required arguments.` ); @@ -108,8 +150,7 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) { #### Required Permissions: - Action: **${actionValue}** -- Resource: **${resourceValue}** -- Possession: **${possessionValue}**`; +- Resource: **${resourceValue}**`; const descriptionDoc = fieldConfig.description ? ` @@ -123,4 +164,4 @@ ${fieldConfig.description}` return fieldConfig; }, }); -} +} \ No newline at end of file diff --git a/packages/unraid-shared/src/util/__tests__/permissions.test.ts b/packages/unraid-shared/src/util/__tests__/permissions.test.ts new file mode 100644 index 000000000..1cd6e5546 --- /dev/null +++ b/packages/unraid-shared/src/util/__tests__/permissions.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest'; +import { + parseActionToAuthAction, + parseResourceToEnum, + parseRoleToEnum, + convertScopesToPermissions, + convertPermissionsToScopes, + normalizeLegacyAction +} from '../permissions.js'; +import { AuthAction, Resource, Role } from '../../graphql-enums.js'; + +describe('permissions utilities', () => { + describe('parseActionToAuthAction', () => { + it('handles valid AuthAction enum values', () => { + expect(parseActionToAuthAction('READ_ANY')).toBe(AuthAction.READ_ANY); + expect(parseActionToAuthAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN); + expect(parseActionToAuthAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY); + expect(parseActionToAuthAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN); + }); + + it('handles legacy colon format', () => { + expect(parseActionToAuthAction('read:any')).toBe(AuthAction.READ_ANY); + expect(parseActionToAuthAction('create:own')).toBe(AuthAction.CREATE_OWN); + expect(parseActionToAuthAction('update:any')).toBe(AuthAction.UPDATE_ANY); + expect(parseActionToAuthAction('delete:own')).toBe(AuthAction.DELETE_OWN); + }); + + it('handles simple verbs with default possession', () => { + expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY); + expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY); + expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY); + expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY); + }); + + it('handles simple verbs with OWN as default', () => { + expect(parseActionToAuthAction('read', 'OWN')).toBe(AuthAction.READ_OWN); + expect(parseActionToAuthAction('create', 'OWN')).toBe(AuthAction.CREATE_OWN); + expect(parseActionToAuthAction('update', 'OWN')).toBe(AuthAction.UPDATE_OWN); + expect(parseActionToAuthAction('delete', 'OWN')).toBe(AuthAction.DELETE_OWN); + }); + + it('handles mixed case input', () => { + expect(parseActionToAuthAction('Read')).toBe(AuthAction.READ_ANY); + expect(parseActionToAuthAction('CREATE')).toBe(AuthAction.CREATE_ANY); + expect(parseActionToAuthAction('Update:Any')).toBe(AuthAction.UPDATE_ANY); + expect(parseActionToAuthAction('DELETE:OWN')).toBe(AuthAction.DELETE_OWN); + }); + + it('handles null and undefined', () => { + expect(parseActionToAuthAction(null)).toBe(null); + expect(parseActionToAuthAction(undefined)).toBe(null); + expect(parseActionToAuthAction('')).toBe(null); + }); + + it('returns null for invalid actions', () => { + expect(parseActionToAuthAction('invalid')).toBe(null); + expect(parseActionToAuthAction('read:invalid')).toBe(null); + expect(parseActionToAuthAction('invalid:any')).toBe(null); + }); + + it('ensures backward compatibility for old API keys', () => { + // Old API keys might use these formats + expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY); + expect(parseActionToAuthAction('write')).toBe(null); // 'write' is not a valid verb + expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY); + expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY); + expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY); + }); + }); + + describe('parseResourceToEnum', () => { + it('parses valid resources', () => { + expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER); + expect(parseResourceToEnum('API_KEY')).toBe(Resource.API_KEY); + expect(parseResourceToEnum('ARRAY')).toBe(Resource.ARRAY); + }); + + it('handles case insensitive input', () => { + expect(parseResourceToEnum('docker')).toBe(Resource.DOCKER); + expect(parseResourceToEnum('Docker')).toBe(Resource.DOCKER); + expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER); + }); + + it('returns null for invalid resources', () => { + expect(parseResourceToEnum('invalid')).toBe(null); + expect(parseResourceToEnum('')).toBe(null); + }); + }); + + describe('parseRoleToEnum', () => { + it('parses valid roles', () => { + expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN); + expect(parseRoleToEnum('VIEWER')).toBe(Role.VIEWER); + expect(parseRoleToEnum('CONNECT')).toBe(Role.CONNECT); + expect(parseRoleToEnum('GUEST')).toBe(Role.GUEST); + }); + + it('handles case insensitive input', () => { + expect(parseRoleToEnum('admin')).toBe(Role.ADMIN); + expect(parseRoleToEnum('Admin')).toBe(Role.ADMIN); + expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN); + }); + + it('returns null for invalid roles', () => { + expect(parseRoleToEnum('invalid')).toBe(null); + expect(parseRoleToEnum('')).toBe(null); + }); + }); + + describe('convertScopesToPermissions', () => { + it('converts role scopes', () => { + const result = convertScopesToPermissions(['role:admin', 'role:viewer']); + expect(result.roles).toEqual([Role.ADMIN, Role.VIEWER]); + expect(result.permissions).toEqual([]); + }); + + it('converts permission scopes with actions', () => { + const result = convertScopesToPermissions([ + 'docker:read:any', + 'docker:update:any', + 'vms:create:own' + ]); + expect(result.roles).toEqual([]); + expect(result.permissions).toHaveLength(2); + + const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER); + expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY); + expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY); + + const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS); + expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_OWN]); + }); + + it('handles wildcard actions', () => { + const result = convertScopesToPermissions(['docker:*']); + expect(result.permissions).toHaveLength(1); + expect(result.permissions[0].resource).toBe(Resource.DOCKER); + expect(result.permissions[0].actions).toContain(AuthAction.CREATE_ANY); + expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY); + expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY); + expect(result.permissions[0].actions).toContain(AuthAction.DELETE_ANY); + }); + + it('merges permissions for same resource', () => { + const result = convertScopesToPermissions([ + 'docker:read:any', + 'docker:update:any' + ]); + expect(result.permissions).toHaveLength(1); + expect(result.permissions[0].resource).toBe(Resource.DOCKER); + expect(result.permissions[0].actions).toHaveLength(2); + expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY); + expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY); + }); + + it('handles legacy simple verb format', () => { + const result = convertScopesToPermissions([ + 'docker:read', + 'vms:create', + 'array:update' + ]); + expect(result.permissions).toHaveLength(3); + + const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER); + expect(dockerPerm?.actions).toEqual([AuthAction.READ_ANY]); + + const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS); + expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_ANY]); + + const arrayPerm = result.permissions.find(p => p.resource === Resource.ARRAY); + expect(arrayPerm?.actions).toEqual([AuthAction.UPDATE_ANY]); + }); + }); + + describe('convertPermissionsToScopes', () => { + it('converts roles to scopes', () => { + const scopes = convertPermissionsToScopes([], [Role.ADMIN, Role.VIEWER]); + expect(scopes).toContain('role:admin'); + expect(scopes).toContain('role:viewer'); + }); + + it('converts permissions to scopes', () => { + const scopes = convertPermissionsToScopes([ + { + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] + }, + { + resource: Resource.VMS, + actions: [AuthAction.CREATE_OWN] + } + ]); + expect(scopes).toContain('docker:read_any'); + expect(scopes).toContain('docker:update_any'); + expect(scopes).toContain('vms:create_own'); + }); + }); + + describe('normalizeLegacyAction', () => { + it('handles simple verbs', () => { + expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY); + }); + + it('handles uppercase with underscore', () => { + expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN); + }); + + it('handles lowercase with colon', () => { + expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN); + }); + + it('returns null for invalid actions', () => { + expect(normalizeLegacyAction('invalid')).toBe(null); + }); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/util/permissions-scopes.spec.ts b/packages/unraid-shared/src/util/permissions-scopes.spec.ts new file mode 100644 index 000000000..93b9a4e61 --- /dev/null +++ b/packages/unraid-shared/src/util/permissions-scopes.spec.ts @@ -0,0 +1,84 @@ +import { AuthAction, Resource, Role } from '../graphql-enums.js'; +import { convertScopesToPermissions } from './permissions.js'; +import { describe, expect, it } from 'vitest'; + +describe('convertScopesToPermissions', () => { + it('should correctly handle actions with colons like read:any', () => { + const scopes = [ + 'API_KEY:read:any', + 'DASHBOARD:create:own', + 'NETWORK:update:any' + ]; + + const result = convertScopesToPermissions(scopes); + + expect(result.permissions).toHaveLength(3); + + const apiKeyPerm = result.permissions.find(p => p.resource === Resource.API_KEY); + expect(apiKeyPerm).toBeDefined(); + expect(apiKeyPerm?.actions).toContain(AuthAction.READ_ANY); + + const dashboardPerm = result.permissions.find(p => p.resource === Resource.DASHBOARD); + expect(dashboardPerm).toBeDefined(); + expect(dashboardPerm?.actions).toContain(AuthAction.CREATE_OWN); + + const networkPerm = result.permissions.find(p => p.resource === Resource.NETWORK); + expect(networkPerm).toBeDefined(); + expect(networkPerm?.actions).toContain(AuthAction.UPDATE_ANY); + }); + + it('should handle wildcard actions', () => { + const scopes = ['DOCKER:*']; + + const result = convertScopesToPermissions(scopes); + + expect(result.permissions).toHaveLength(1); + const dockerPerm = result.permissions[0]; + expect(dockerPerm.resource).toBe(Resource.DOCKER); + expect(dockerPerm.actions).toContain(AuthAction.CREATE_ANY); + expect(dockerPerm.actions).toContain(AuthAction.READ_ANY); + expect(dockerPerm.actions).toContain(AuthAction.UPDATE_ANY); + expect(dockerPerm.actions).toContain(AuthAction.DELETE_ANY); + }); + + it('should handle role scopes', () => { + const scopes = ['role:ADMIN', 'role:VIEWER']; + + const result = convertScopesToPermissions(scopes); + + expect(result.roles).toHaveLength(2); + expect(result.roles).toContain(Role.ADMIN); + expect(result.roles).toContain(Role.VIEWER); + expect(result.permissions).toHaveLength(0); + }); + + it('should merge permissions for the same resource', () => { + const scopes = [ + 'VMS:read:any', + 'VMS:update:any' + ]; + + const result = convertScopesToPermissions(scopes); + + expect(result.permissions).toHaveLength(1); + const vmsPerm = result.permissions[0]; + expect(vmsPerm.resource).toBe(Resource.VMS); + expect(vmsPerm.actions).toHaveLength(2); + expect(vmsPerm.actions).toContain(AuthAction.READ_ANY); + expect(vmsPerm.actions).toContain(AuthAction.UPDATE_ANY); + }); + + it('should handle invalid scope formats gracefully', () => { + const scopes = [ + 'INVALID_SCOPE', // No colon + ':action', // Empty resource + 'RESOURCE:', // Empty action + 'UNKNOWN:read:any' // Unknown resource + ]; + + const result = convertScopesToPermissions(scopes); + + expect(result.permissions).toHaveLength(0); + expect(result.roles).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/util/permissions.spec.ts b/packages/unraid-shared/src/util/permissions.spec.ts new file mode 100644 index 000000000..e7c270a3f --- /dev/null +++ b/packages/unraid-shared/src/util/permissions.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; +import { AuthAction } from '../graphql-enums.js'; +import { normalizeLegacyAction, normalizeLegacyActions, parseActionToAuthAction } from './permissions.js'; + +describe('normalizeLegacyAction', () => { + describe('simple verb format (legacy)', () => { + it('should convert simple verbs to AuthAction enum values', () => { + expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY); + }); + + it('should handle uppercase simple verbs', () => { + expect(normalizeLegacyAction('CREATE')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('READ')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('UPDATE')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('DELETE')).toBe(AuthAction.DELETE_ANY); + }); + + it('should handle mixed case simple verbs', () => { + expect(normalizeLegacyAction('Create')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('Read')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('Update')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('Delete')).toBe(AuthAction.DELETE_ANY); + }); + }); + + describe('uppercase underscore format (GraphQL enum style)', () => { + it('should convert CREATE_ANY format to AuthAction enums', () => { + expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('READ_ANY')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('DELETE_ANY')).toBe(AuthAction.DELETE_ANY); + }); + + it('should convert CREATE_OWN format to AuthAction enums', () => { + expect(normalizeLegacyAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN); + expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN); + expect(normalizeLegacyAction('UPDATE_OWN')).toBe(AuthAction.UPDATE_OWN); + expect(normalizeLegacyAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN); + }); + + it('should handle mixed case underscore format', () => { + expect(normalizeLegacyAction('Create_Any')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('Read_Own')).toBe(AuthAction.READ_OWN); + }); + }); + + describe('already correct format (Casbin style)', () => { + it('should convert lowercase:colon format to enums', () => { + expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('read:any')).toBe(AuthAction.READ_ANY); + expect(normalizeLegacyAction('update:any')).toBe(AuthAction.UPDATE_ANY); + expect(normalizeLegacyAction('delete:any')).toBe(AuthAction.DELETE_ANY); + }); + + it('should normalize uppercase:colon to enums', () => { + expect(normalizeLegacyAction('CREATE:ANY')).toBe(AuthAction.CREATE_ANY); + expect(normalizeLegacyAction('READ:OWN')).toBe(AuthAction.READ_OWN); + }); + + it('should handle :own possession correctly', () => { + expect(normalizeLegacyAction('create:own')).toBe(AuthAction.CREATE_OWN); + expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN); + expect(normalizeLegacyAction('update:own')).toBe(AuthAction.UPDATE_OWN); + expect(normalizeLegacyAction('delete:own')).toBe(AuthAction.DELETE_OWN); + }); + }); + + describe('edge cases', () => { + it('should return null for unknown actions', () => { + expect(normalizeLegacyAction('unknown')).toBeNull(); + expect(normalizeLegacyAction('UNKNOWN')).toBeNull(); + expect(normalizeLegacyAction('some_other_action')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(normalizeLegacyAction('')).toBeNull(); + }); + + it('should return null for wildcards (not a valid AuthAction)', () => { + expect(normalizeLegacyAction('*')).toBeNull(); + }); + }); +}); + +describe('integration with parseActionToAuthAction', () => { + it('should produce valid AuthAction enum values after normalization', () => { + // Test that normalized actions can be parsed to valid enum values + const testCases = [ + { input: 'create', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY }, + { input: 'CREATE_ANY', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY }, + { input: 'read:own', normalized: AuthAction.READ_OWN, expected: AuthAction.READ_OWN }, + { input: 'UPDATE_OWN', normalized: AuthAction.UPDATE_OWN, expected: AuthAction.UPDATE_OWN }, + ]; + + for (const testCase of testCases) { + const normalized = normalizeLegacyAction(testCase.input); + expect(normalized).not.toBeNull(); + expect(normalized).toBe(testCase.normalized); + + // Since we've asserted normalized is not null, we can safely use it + if (normalized !== null) { + const parsed = parseActionToAuthAction(normalized); + expect(parsed).toBe(testCase.expected); + } + } + }); + + it('should handle all AuthAction enum values', () => { + // Ensure all enum values can round-trip through normalization + const allActions = Object.values(AuthAction); + + for (const action of allActions) { + // The enum value itself should normalize correctly + const normalized = normalizeLegacyAction(action); + expect(normalized).not.toBeNull(); + + if (normalized !== null) { + const parsed = parseActionToAuthAction(normalized); + expect(parsed).toBe(action); + } + } + }); +}); + +describe('normalizeLegacyActions (array helper)', () => { + it('should normalize an array of mixed format actions', () => { + const mixedActions = [ + 'create', // Simple verb + 'READ_ANY', // Uppercase underscore + 'update:own', // Already correct + 'DELETE', // Uppercase simple verb + 'invalid_action', // Invalid action + '', // Empty string + ]; + + const normalized = normalizeLegacyActions(mixedActions); + + expect(normalized).toEqual([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_OWN, + AuthAction.DELETE_ANY, + // invalid_action and empty string are filtered out + ]); + }); + + it('should handle empty array', () => { + expect(normalizeLegacyActions([])).toEqual([]); + }); + + it('should filter out all invalid actions', () => { + const invalidActions = ['invalid', 'unknown', 'some_other']; + expect(normalizeLegacyActions(invalidActions)).toEqual([]); + }); + + it('should preserve all valid actions', () => { + const validActions = [ + 'create:any', + 'read:any', + 'update:any', + 'delete:any', + 'create:own', + 'read:own', + 'update:own', + 'delete:own', + ]; + + const normalized = normalizeLegacyActions(validActions); + + expect(normalized).toEqual([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + AuthAction.CREATE_OWN, + AuthAction.READ_OWN, + AuthAction.UPDATE_OWN, + AuthAction.DELETE_OWN, + ]); + }); +}); + +describe('API key loading scenarios', () => { + it('should handle legacy simple verb format from old API keys', () => { + const legacyActions = ['create', 'read', 'update', 'delete']; + const normalized = normalizeLegacyActions(legacyActions); + + expect(normalized).toEqual([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ]); + }); + + it('should handle mixed format from partially migrated API keys', () => { + const mixedActions = [ + 'CREATE_ANY', + 'read:any', + 'update', + 'DELETE_OWN' + ]; + + const normalized = normalizeLegacyActions(mixedActions); + + expect(normalized).toEqual([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_OWN, + ]); + }); + + it('should handle current Casbin format', () => { + const currentActions = [ + 'create:any', + 'read:any', + 'update:any', + 'delete:any' + ]; + + const normalized = normalizeLegacyActions(currentActions); + + expect(normalized).toEqual([ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY, + ]); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/util/permissions.ts b/packages/unraid-shared/src/util/permissions.ts new file mode 100644 index 000000000..5e9bba0ce --- /dev/null +++ b/packages/unraid-shared/src/util/permissions.ts @@ -0,0 +1,378 @@ +// Import from graphql-enums to avoid NestJS dependencies +import { Resource, Role, AuthAction } from '../graphql-enums.js'; + +export interface ScopeConversion { + permissions: Array<{ resource: Resource; actions: AuthAction[] }>; + roles: Role[]; +} + +/** + * Normalize an action string to AuthAction enum value + * Handles various input formats: + * - Full AuthAction values: 'READ_ANY', 'CREATE_OWN' + * - Lowercase with colon: 'read:any', 'create:own' (legacy) + * - Simple verbs: 'read', 'create' (defaults to '_ANY') + * - Mixed case: 'Read', 'CREATE' + * + * @param action - The action string to normalize + * @param defaultPossession - Default possession if not specified ('ANY' or 'OWN') + * @returns The normalized action as AuthAction or null if invalid + */ +export function parseActionToAuthAction(action: string | null | undefined, defaultPossession: 'ANY' | 'OWN' = 'ANY'): AuthAction | null { + if (!action) return null; + + // First check if it's already a valid AuthAction value + if (Object.values(AuthAction).includes(action as AuthAction)) { + return action as AuthAction; + } + + // Normalize the input - handle both underscore and colon formats + let normalized = action.trim().toUpperCase(); + + // Convert colon format (read:any) to underscore format (READ_ANY) + if (normalized.includes(':')) { + const parts = normalized.split(':'); + if (parts.length === 2) { + const [verb, possession] = parts; + // Only accept valid possessions + if (possession !== 'ANY' && possession !== 'OWN') { + return null; + } + normalized = `${verb}_${possession}`; + } else { + return null; + } + } + + // Check if normalized version is valid + if (Object.values(AuthAction).includes(normalized as AuthAction)) { + return normalized as AuthAction; + } + + // Handle simple verbs without possession + const simpleVerbs = ['CREATE', 'READ', 'UPDATE', 'DELETE']; + const parts = normalized.split('_'); + const verb = parts[0]; + + // If there's already a possession part, don't add default + if (parts.length === 1 && simpleVerbs.includes(verb)) { + const withPossession = `${verb}_${defaultPossession}` as AuthAction; + if (Object.values(AuthAction).includes(withPossession)) { + return withPossession; + } + } + + return null; +} + +/** + * Convenience function to parse action to enum (alias for backward compatibility) + * @deprecated Use parseActionToAuthAction instead + */ +export const parseActionToEnum = parseActionToAuthAction; + + +/** + * Parse a resource string to Resource enum + * Handles special cases and variations + * + * @param resourceStr - The resource string to parse + * @returns The Resource enum value or null if invalid + */ +export function parseResourceToEnum(resourceStr: string): Resource | null { + const normalized = resourceStr.trim().toUpperCase(); + + // Direct enum lookup + const directMatch = Resource[normalized as keyof typeof Resource]; + if (directMatch) { + return directMatch; + } + + + return null; +} + +/** + * Parse a role string to Role enum + * + * @param roleStr - The role string to parse + * @returns The Role enum value or null if invalid + */ +export function parseRoleToEnum(roleStr: string): Role | null { + const normalized = roleStr.trim().toUpperCase(); + const role = Role[normalized as keyof typeof Role]; + return role || null; +} + +/** + * Convert scope strings to permissions and roles + * Scopes can be in format: + * - "role:admin" for roles + * - "docker:read" for resource permissions + * - "docker:*" for all actions on a resource + * + * @param scopes - Array of scope strings + * @returns Object containing parsed permissions and roles + */ +export function convertScopesToPermissions(scopes: string[]): ScopeConversion { + const permissions: Array<{ resource: Resource; actions: AuthAction[] }> = []; + const roles: Role[] = []; + + for (const scope of scopes) { + if (scope.startsWith('role:')) { + // Handle role scope + const roleStr = scope.substring(5); + const role = parseRoleToEnum(roleStr); + if (role) { + roles.push(role); + } else { + console.warn(`Unknown role in scope: ${scope}`); + } + } else { + // Handle permission scope - split only on first colon + const colonIndex = scope.indexOf(':'); + if (colonIndex === -1) { + console.warn(`Invalid scope format (missing colon): ${scope}`); + continue; + } + + const resourceStr = scope.substring(0, colonIndex); + const actionStr = scope.substring(colonIndex + 1).trim(); + + if (resourceStr && actionStr) { + const resource = parseResourceToEnum(resourceStr); + if (!resource) { + console.warn(`Unknown resource in scope: ${scope}`); + continue; + } + + // Handle wildcard or specific action + let actions: AuthAction[]; + if (actionStr === '*') { + actions = [ + AuthAction.CREATE_ANY, + AuthAction.READ_ANY, + AuthAction.UPDATE_ANY, + AuthAction.DELETE_ANY + ]; + } else { + // Actions like "read:any" should be preserved as-is + const action = parseActionToAuthAction(actionStr); + if (action) { + actions = [action]; + } else { + console.warn(`Unknown action in scope: ${scope}`); + continue; + } + } + + // Merge with existing permissions for the same resource + const existing = permissions.find(p => p.resource === resource); + if (existing) { + actions.forEach(a => { + if (!existing.actions.includes(a)) { + existing.actions.push(a); + } + }); + } else { + permissions.push({ resource, actions }); + } + } else { + console.warn(`Invalid scope format: ${scope}`); + } + } + } + + return { permissions, roles }; +} + +/** + * Convert permissions and roles back to scope strings + * Inverse of convertScopesToPermissions + * + * @param permissions - Array of resource/action pairs + * @param roles - Array of roles + * @returns Array of scope strings + */ +export function convertPermissionsToScopes( + permissions: Array<{ resource: Resource; actions: AuthAction[] }>, + roles: Role[] = [] +): string[] { + const scopes: string[] = []; + + // Add role scopes + for (const role of roles) { + scopes.push(`role:${role.toLowerCase()}`); + } + + // Add permission scopes + for (const perm of permissions) { + const resourceStr = perm.resource.toLowerCase(); + for (const action of perm.actions) { + const actionStr = action.toLowerCase(); + scopes.push(`${resourceStr}:${actionStr}`); + } + } + + return scopes; +} + +/** + * Create a scope string from a role + * @param role - The role to convert + * @returns Scope string like "role:admin" + */ +export function roleToScope(role: Role | string): string { + return `role:${role.toLowerCase()}`; +} + +/** + * Create a scope string from resource and action + * @param resource - The resource + * @param action - The action (can be verb, AuthAction, or wildcard) + * @returns Scope string like "docker:read" or "docker:*" + */ +export function permissionToScope(resource: Resource | string, action: string): string { + return `${resource.toLowerCase()}:${action.toLowerCase()}`; +} + +/** + * Check if a scope string represents a role + * @param scope - The scope string to check + * @returns True if the scope is a role scope + */ +export function isRoleScope(scope: string): boolean { + return scope.startsWith('role:'); +} + +/** + * Extract the role from a role scope string + * @param scope - The scope string like "role:admin" + * @returns The role name or null if not a role scope + */ +export function getRoleFromScope(scope: string): string | null { + if (!isRoleScope(scope)) return null; + return scope.substring(5); +} + +/** + * Normalize an action string to AuthAction format + * @param action - The action string to normalize + * @returns The normalized AuthAction or null if parsing fails + */ +export function normalizeAction(action: string): AuthAction | null { + return parseActionToAuthAction(action); +} + +/** + * Normalize legacy action formats to AuthAction enum values + * Handles multiple formats: + * - Simple verbs: "create", "read", "update", "delete" -> AuthAction.CREATE_ANY, etc. + * - Uppercase with underscore: "CREATE_ANY", "READ_ANY" -> AuthAction.CREATE_ANY, etc. + * - Already correct: "create:any", "read:any" -> AuthAction.CREATE_ANY, etc. + * + * @param action - The action string to normalize + * @returns The normalized AuthAction enum value or null if invalid + */ +export function normalizeLegacyAction(action: string): AuthAction | null { + const actionLower = action.toLowerCase(); + let normalizedString: string; + + // If it's already in lowercase:colon format, use it + if (actionLower.includes(':')) { + normalizedString = actionLower; + } + // If it's in uppercase_underscore format, convert to lowercase:colon + else if (action.includes('_')) { + normalizedString = actionLower.replace('_', ':'); + } + // If it's a simple verb without possession, add ":any" as default + else if (['create', 'read', 'update', 'delete'].includes(actionLower)) { + normalizedString = `${actionLower}:any`; + } + // Otherwise just use lowercase (for unknown actions) + else { + normalizedString = actionLower; + } + + // Convert the normalized string to AuthAction enum + return parseActionToAuthAction(normalizedString); +} + +/** + * Normalize an array of legacy action strings to AuthAction enum values + * Filters out any invalid actions that can't be normalized + * + * @param actions - Array of action strings in various formats + * @returns Array of valid AuthAction enum values + */ +export function normalizeLegacyActions(actions: string[]): AuthAction[] { + return actions + .map(action => normalizeLegacyAction(action)) + .filter((action): action is AuthAction => action !== null); +} + +/** + * Expand wildcard action (*) to all CRUD actions + * @returns Array of all CRUD AuthAction values + */ +export function expandWildcardAction(): AuthAction[] { + return [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY]; +} + +/** + * Reconcile wildcard permissions by expanding them to all resources + * @param permissionsWithSets - Map of resources to action sets, may include wildcard resource + */ +export function reconcileWildcardPermissions(permissionsWithSets: Map>): void { + if (permissionsWithSets.has('*' as Resource | '*')) { + const wildcardActions = permissionsWithSets.get('*' as Resource | '*')!; + permissionsWithSets.delete('*' as Resource | '*'); + + // Apply wildcard actions to ALL resources (not just existing ones) + for (const resource of Object.values(Resource)) { + if (!permissionsWithSets.has(resource)) { + permissionsWithSets.set(resource, new Set()); + } + const actionsSet = permissionsWithSets.get(resource)!; + wildcardActions.forEach((action) => actionsSet.add(action)); + } + } +} + +/** + * Merge permissions from source map into target map + * @param targetMap - Map to merge permissions into + * @param sourceMap - Map to merge permissions from + */ +export function mergePermissionsIntoMap( + targetMap: Map>, + sourceMap: Map +): void { + for (const [resource, actions] of sourceMap) { + if (!targetMap.has(resource)) { + targetMap.set(resource, new Set()); + } + const actionsSet = targetMap.get(resource)!; + actions.forEach((action) => actionsSet.add(action)); + } +} + +/** + * Convert permission sets to arrays, filtering out wildcards + * @param permissionsWithSets - Map of resources to action sets + * @returns Map of resources to action arrays (excludes wildcard resource) + */ +export function convertPermissionSetsToArrays( + permissionsWithSets: Map> +): Map { + const result = new Map(); + + for (const [resource, actionsSet] of permissionsWithSets) { + if (resource !== '*') { + result.set(resource as Resource, Array.from(actionsSet)); + } + } + + return result; +} \ No newline at end of file diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeyAuthorize.page b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeyAuthorize.page new file mode 100644 index 000000000..28116d005 --- /dev/null +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeyAuthorize.page @@ -0,0 +1,7 @@ +Menu="WebGui" +Title="API Key Authorization" +Icon="icon-u-shield-keyhole" +Tag="key" +Cond="false" +--- + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7219ec14..adad1dc7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13383,8 +13383,8 @@ packages: vue-component-type-helpers@3.0.4: resolution: {integrity: sha512-WtR3kPk8vqKYfCK/HGyT47lK/T3FaVyWxaCNuosaHYE8h9/k0lYRZ/PI/+T/z2wP+uuNKmL6z30rOcBboOu/YA==} - vue-component-type-helpers@3.0.5: - resolution: {integrity: sha512-uoNZaJ+a1/zppa/Vgmi8zIOP2PHXDN2rT8NyF+zQRK6ZG94lNB9prcV0GdLJbY9i9lrD47JOVIH92SaiA7oJ1A==} + vue-component-type-helpers@3.0.6: + resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -17892,7 +17892,7 @@ snapshots: storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.1(@types/node@22.17.1)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.18(typescript@5.9.2) - vue-component-type-helpers: 3.0.5 + vue-component-type-helpers: 3.0.6 '@stylistic/eslint-plugin@5.2.2(eslint@9.33.0(jiti@2.5.1))': dependencies: @@ -27724,7 +27724,7 @@ snapshots: vue-component-type-helpers@3.0.4: {} - vue-component-type-helpers@3.0.5: {} + vue-component-type-helpers@3.0.6: {} vue-demi@0.14.10(vue@3.5.18(typescript@5.9.2)): dependencies: diff --git a/unraid-ui/src/composables/useToast.ts b/unraid-ui/src/composables/useToast.ts new file mode 100644 index 000000000..94c721696 --- /dev/null +++ b/unraid-ui/src/composables/useToast.ts @@ -0,0 +1,99 @@ +import { toast as sonnerToast } from 'vue-sonner'; + +/** + * Composable for toast notifications using vue-sonner + * Provides a consistent API for showing toast messages + */ +export function useToast() { + /** + * Show a default toast notification + * @param message - The message to display + */ + const toast = (message: string) => { + sonnerToast(message); + }; + + /** + * Show a success toast + * @param message - The success message to display + */ + const success = (message: string) => { + sonnerToast.success(message); + }; + + /** + * Show an error toast + * @param message - The error message to display + */ + const error = (message: string) => { + sonnerToast.error(message); + }; + + /** + * Show a warning toast + * @param message - The warning message to display + */ + const warning = (message: string) => { + sonnerToast.warning(message); + }; + + /** + * Show an info toast + * @param message - The info message to display + */ + const info = (message: string) => { + sonnerToast.info(message); + }; + + /** + * Show a loading toast + * @param message - The loading message to display + */ + const loading = (message: string) => { + sonnerToast.loading(message); + }; + + /** + * Show a promise-based toast that updates based on promise state + * @param promise - The promise to track + * @param messages - Messages for different states + * @returns The toast ID with an unwrap method to get the original promise + */ + const promise = ( + promiseToHandle: Promise, + messages: { + loading: string; + success: string | ((data: T) => string); + error: string | ((error: unknown) => string); + } + ) => { + // Return vue-sonner's promise return type which includes the toast ID and unwrap method + return sonnerToast.promise(promiseToHandle, messages); + }; + + /** + * Dismiss a specific toast or all toasts + * @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts + */ + const dismiss = (toastId?: string | number) => { + sonnerToast.dismiss(toastId); + }; + + return { + // Allow calling the composable directly as toast() + toast, + // Named methods + success, + error, + warning, + info, + loading, + promise, + dismiss, + // Also expose the raw sonner toast for advanced usage + sonner: sonnerToast, + }; +} + +// Export type for the return value +export type ToastInstance = ReturnType; diff --git a/unraid-ui/src/forms/LabelRenderer.vue b/unraid-ui/src/forms/LabelRenderer.vue index 9bc80528d..e14a3604a 100644 --- a/unraid-ui/src/forms/LabelRenderer.vue +++ b/unraid-ui/src/forms/LabelRenderer.vue @@ -51,8 +51,12 @@ const labelClass = computed(() => { switch (labelFormat.value) { case 'title': return 'text-xl font-semibold mb-2'; // Example styling for title + case 'subtitle': + return 'text-base font-semibold mb-1'; // Styling for subtitle case 'heading': return 'text-lg font-semibold mt-4 mb-1'; // Example styling for heading + case 'description': + return 'text-sm text-muted-foreground'; // Description format should not be bold default: return 'font-semibold'; // Default label styling } diff --git a/unraid-ui/src/forms/MultiSelect.vue b/unraid-ui/src/forms/MultiSelect.vue new file mode 100644 index 000000000..85cb19d41 --- /dev/null +++ b/unraid-ui/src/forms/MultiSelect.vue @@ -0,0 +1,219 @@ + + + diff --git a/unraid-ui/src/forms/ObjectArrayField.vue b/unraid-ui/src/forms/ObjectArrayField.vue index 452540448..0298aba91 100644 --- a/unraid-ui/src/forms/ObjectArrayField.vue +++ b/unraid-ui/src/forms/ObjectArrayField.vue @@ -169,7 +169,7 @@ const updateItem = (index: number, newValue: unknown) => {
- + { :value="String(index)" class="mt-0 w-full" > -
+