diff --git a/api/.eslintrc.ts b/api/.eslintrc.ts index f58333268a..7cb97ae287 100644 --- a/api/.eslintrc.ts +++ b/api/.eslintrc.ts @@ -4,7 +4,10 @@ import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'; import prettier from 'eslint-plugin-prettier'; import tseslint from 'typescript-eslint'; +import { graphqlAuthorizationConfig } from './eslint/graphql-authorization.config.mjs'; + export default tseslint.config( + ...graphqlAuthorizationConfig, eslint.configs.recommended, ...tseslint.configs.recommended, { diff --git a/api/eslint/graphql-authorization.config.mjs b/api/eslint/graphql-authorization.config.mjs new file mode 100644 index 0000000000..103cce0d2c --- /dev/null +++ b/api/eslint/graphql-authorization.config.mjs @@ -0,0 +1,31 @@ +import tseslint from 'typescript-eslint'; + +import requireGraphqlAuthorization from './require-graphql-authorization.mjs'; + +export const graphqlAuthorizationConfig = [ + { + files: ['**/*.ts'], + // Tests include deliberately unguarded fixtures; templates are not deployed handlers. + ignores: [ + '**/*.spec.ts', + '**/*.test.ts', + '**/__test__/**', + '**/__tests__/**', + '**/templates/**', + ], + languageOptions: { parser: tseslint.parser }, + plugins: { + 'unraid-auth': { rules: { 'require-graphql-authorization': requireGraphqlAuthorization } }, + }, + rules: { 'unraid-auth/require-graphql-authorization': 'error' }, + }, +]; + +// This focused pass does not evaluate the existing style-rule suppressions. +export default [ + ...graphqlAuthorizationConfig, + { + plugins: { '@typescript-eslint': tseslint.plugin }, + linterOptions: { reportUnusedDisableDirectives: 'off' }, + }, +]; diff --git a/api/eslint/require-graphql-authorization.mjs b/api/eslint/require-graphql-authorization.mjs new file mode 100644 index 0000000000..e41ca69aec --- /dev/null +++ b/api/eslint/require-graphql-authorization.mjs @@ -0,0 +1,96 @@ +const graphqlHandlers = new Set(['Query', 'Mutation', 'Subscription', 'ResolveField']); +const permissionSources = new Set(['@unraid/shared/use-permissions.directive.js', 'nest-authz']); +const accessMarkers = new Map([ + ['@app/unraid-api/auth/public.decorator.js', 'Public'], + ['@app/unraid-api/auth/authenticated.decorator.js', 'Authenticated'], +]); + +function importedDecorator(context, decorator) { + const call = decorator.expression; + if (call.type !== 'CallExpression') return; + const callee = call.callee; + const identifier = callee.type === 'Identifier' ? callee : callee.object; + if (identifier?.type !== 'Identifier') return; + let scope = context.sourceCode.getScope(decorator); + while (scope) { + const variable = scope.set.get(identifier.name); + if (variable) { + const definition = variable.defs.find((entry) => entry.type === 'ImportBinding'); + if (!definition) return; + const specifier = definition.node; + const source = definition.parent.source.value; + if (callee.type === 'Identifier' && specifier.type === 'ImportSpecifier') { + return { source, name: specifier.imported.name ?? specifier.imported.value, call }; + } + if (callee.type === 'MemberExpression' && specifier.type === 'ImportNamespaceSpecifier') { + const name = callee.computed ? callee.property.value : callee.property.name; + return { source, name, call }; + } + return; + } + scope = scope.upper; + } +} + +function hasPermissionArgument(call) { + return ( + call.arguments.length > 0 && + call.arguments.every((argument) => { + if (argument.type === 'ObjectExpression') { + const keys = new Set( + argument.properties + .filter((property) => property.type === 'Property') + .map((property) => property.key.name ?? property.key.value) + ); + return keys.has('action') && keys.has('resource'); + } + return !['Literal', 'ArrayExpression'].includes(argument.type); + }) + ); +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { description: 'Require an explicit access policy on each GraphQL handler' }, + schema: [], + messages: { + missing: + 'GraphQL handlers require @UsePermissions(...), @Public(), or @Authenticated() on the method. @UseGuards alone does not declare permissions.', + empty: '@UsePermissions must declare an action and resource; empty permission metadata is denied at runtime.', + }, + }, + create(context) { + return { + MethodDefinition(node) { + const decorators = (node.decorators ?? []) + .map((decorator) => importedDecorator(context, decorator)) + .filter(Boolean); + if ( + !decorators.some( + ({ source, name }) => source === '@nestjs/graphql' && graphqlHandlers.has(name) + ) + ) + return; + const permissions = decorators.filter( + ({ source, name }) => permissionSources.has(source) && name === 'UsePermissions' + ); + if (permissions.length) { + if (permissions.some(({ call }) => !hasPermissionArgument(call))) { + context.report({ node, messageId: 'empty' }); + } + return; + } + if ( + !decorators.some( + ({ source, name }) => + accessMarkers.has(source) && accessMarkers.get(source) === name + ) + ) { + context.report({ node, messageId: 'missing' }); + } + }, + }; + }, +}; diff --git a/api/eslint/require-graphql-authorization.spec.ts b/api/eslint/require-graphql-authorization.spec.ts new file mode 100644 index 0000000000..2fbbaab940 --- /dev/null +++ b/api/eslint/require-graphql-authorization.spec.ts @@ -0,0 +1,116 @@ +import { fileURLToPath } from 'node:url'; + +import { ESLint, RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import { describe, expect, it } from 'vitest'; + +import rule from './require-graphql-authorization.mjs'; + +RuleTester.describe = describe; +RuleTester.it = it; +const tester = new RuleTester({ languageOptions: { parser: tseslint.parser } }); +const handlerImports = "import { Query, Mutation, Subscription, ResolveField } from '@nestjs/graphql';"; +const permissionsImport = + "import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';"; +const publicImport = "import { Public } from '@app/unraid-api/auth/public.decorator.js';"; +const authenticatedImport = + "import { Authenticated } from '@app/unraid-api/auth/authenticated.decorator.js';"; +const permission = "@UsePermissions({ action: 'READ_ANY', resource: 'CONFIG' })"; + +const code = (decorators: string, imports = permissionsImport) => `${handlerImports} ${imports} +class Resolver { ${decorators} arbitraryName() { return true; } }`; + +tester.run('require-graphql-authorization', rule, { + valid: [ + ...['Query', 'Mutation', 'Subscription', 'ResolveField'].map((name) => + code(`@${name}(() => Boolean) ${permission}`) + ), + code(`${permission} @Query(() => Boolean)`), + code('@Query(() => Boolean) @Public()', publicImport), + code('@Mutation(() => Boolean) @Authenticated()', authenticatedImport), + code('@Query(() => Boolean) @UsePermissions(permission)', permissionsImport), + code(`@Query(() => Boolean) ${permission}`, "import { UsePermissions } from 'nest-authz';"), + "import { Query as Read } from '@nestjs/graphql'; import { UsePermissions as Policy } from 'nest-authz'; class R { @Read(() => Boolean) @Policy({ action: 'READ_ANY', resource: 'CONFIG' }) value() {} }", + "import * as gql from '@nestjs/graphql'; import * as auth from 'nest-authz'; class R { @gql.Query(() => Boolean) @auth.UsePermissions({ action: 'READ_ANY', resource: 'CONFIG' }) value() {} }", + "import * as gql from '@nestjs/graphql'; import * as auth from 'nest-authz'; class R { @(gql['Query'])(() => Boolean) @(auth['UsePermissions'])({ action: 'READ_ANY', resource: 'CONFIG' }) value() {} }", + code(''), + "import { Query } from 'unrelated'; class R { @Query() value() {} }", + ], + invalid: [ + ...['Query', 'Mutation', 'Subscription', 'ResolveField'].map((name) => ({ + code: code(`@${name}(() => Boolean)`), + errors: [{ messageId: 'missing' }], + })), + { + code: "import { Query as Read } from '@nestjs/graphql'; class R { @Read(() => Boolean) renamed() {} }", + errors: [{ messageId: 'missing' }], + }, + { + code: "import * as gql from '@nestjs/graphql'; class R { @gql.Mutation(() => Boolean) renamed() {} }", + errors: [{ messageId: 'missing' }], + }, + { + code: code('@Query(() => Boolean) @UseGuards(AuthZGuard)'), + errors: [{ messageId: 'missing' }], + }, + { + code: code('@Query(() => Boolean) @Public()', "import { Public } from 'unrelated';"), + errors: [{ messageId: 'missing' }], + }, + { + code: `${handlerImports} ${publicImport} @Public() class R { @Query(() => Boolean) value() {} }`, + errors: [{ messageId: 'missing' }], + }, + { + code: `${handlerImports} ${permissionsImport} function factory(UsePermissions) { return class { @Query(() => Boolean) ${permission} value() {} }; }`, + errors: [{ messageId: 'missing' }], + }, + ...['', '{}', '[]', 'null', "{ action: 'READ_ANY' }"].map((args) => ({ + code: code(`@Query(() => Boolean) @UsePermissions(${args})`), + errors: [{ messageId: 'empty' }], + })), + { + code: code( + '@Query(() => Boolean) @Public() @UsePermissions()', + `${publicImport} ${permissionsImport}` + ), + errors: [{ messageId: 'empty' }], + }, + ], +}); + +// Loading the full ESLint config includes TypeScript transpilation and plugin startup. +// Allow for that cold start while CI runs coverage across packages concurrently. +describe('authorization lint configuration', { timeout: 30_000 }, () => { + const cwd = fileURLToPath(new URL('..', import.meta.url)); + it.each([ + ['.eslintrc.ts', 'src/new-handler.ts'], + ['eslint/graphql-authorization.config.mjs', 'api/src/new-handler.ts'], + [ + 'eslint/graphql-authorization.config.mjs', + 'packages/unraid-api-plugin-connect/src/new-handler.ts', + ], + ])('rejects an unguarded endpoint using %s at %s', async (config, filePath) => { + const eslint = new ESLint({ + cwd: config === '.eslintrc.ts' ? cwd : fileURLToPath(new URL('../..', import.meta.url)), + overrideConfigFile: fileURLToPath(new URL(`../${config}`, import.meta.url)), + }); + const results = await eslint.lintText(code('@Mutation(() => Boolean)'), { filePath }); + expect( + results + .flatMap((result) => result.messages) + .filter((message) => message.ruleId === 'unraid-auth/require-graphql-authorization') + ).toHaveLength(1); + }); + it('keeps deliberate test fixtures outside the production policy', async () => { + const eslint = new ESLint({ cwd, overrideConfigFile: '.eslintrc.ts' }); + const results = await eslint.lintText(code('@Query(() => Boolean)'), { + filePath: 'src/negative-fixture.spec.ts', + }); + expect( + results + .flatMap((result) => result.messages) + .filter((message) => message.ruleId === 'unraid-auth/require-graphql-authorization') + ).toHaveLength(0); + }); +}); diff --git a/api/package.json b/api/package.json index e09497633b..f8f3e621b0 100644 --- a/api/package.json +++ b/api/package.json @@ -33,8 +33,9 @@ "// Internationalization": "", "i18n:extract": "node ./scripts/extract-translations.mjs", "// Code Quality": "", - "lint": "eslint --config .eslintrc.ts src/", - "lint:fix": "eslint --fix --config .eslintrc.ts src/", + "lint": "eslint --config .eslintrc.ts src/ && pnpm lint:authorization", + "lint:authorization": "cd .. && eslint --config api/eslint/graphql-authorization.config.mjs api/src/ packages/unraid-api-plugin-*/src/", + "lint:fix": "eslint --fix --config .eslintrc.ts src/ && pnpm lint:authorization", "pretype-check": "pnpm --filter @unraid/shared build", "type-check": "tsc --noEmit", "// Testing": "", diff --git a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts index 8ca743610c..3dfa0ae691 100644 --- a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts +++ b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts @@ -2,13 +2,13 @@ import { INestApplication } from '@nestjs/common'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import { Test, TestingModule } from '@nestjs/testing'; -import { AuthZGuard } from 'nest-authz'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AppModule } from '@app/unraid-api/app/app.module.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js'; +import { AuthorizationGuard } from '@app/unraid-api/auth/authorization.guard.js'; // Mock the store before importing it vi.mock('@app/store/index.js', () => ({ @@ -76,7 +76,7 @@ describe('AppModule Integration Tests', () => { canActivate: () => true, }) // Override authorization guard - .overrideGuard(AuthZGuard) + .overrideGuard(AuthorizationGuard) .useValue({ canActivate: () => true, }) diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index 8617b0c3d8..12a87914ea 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -4,7 +4,6 @@ import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule } from '@nestjs/throttler'; -import { AuthZGuard } from 'nest-authz'; import { LoggerModule } from 'nestjs-pino'; import { apiLogger } from '@app/core/log.js'; @@ -12,6 +11,7 @@ import { LOG_LEVEL } from '@app/environment.js'; import { PubSubModule } from '@app/unraid-api/app/pubsub.module.js'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js'; +import { AuthorizationGuard } from '@app/unraid-api/auth/authorization.guard.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { CronModule } from '@app/unraid-api/cron/cron.module.js'; import { JobModule } from '@app/unraid-api/cron/job.module.js'; @@ -67,7 +67,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u }, { provide: APP_GUARD, - useClass: AuthZGuard, + useClass: AuthorizationGuard, }, ], }) diff --git a/api/src/unraid-api/auth/authenticated.decorator.ts b/api/src/unraid-api/auth/authenticated.decorator.ts new file mode 100644 index 0000000000..f0a8d54046 --- /dev/null +++ b/api/src/unraid-api/auth/authenticated.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_AUTHENTICATED_ENDPOINT_KEY = 'isAuthenticatedEndpoint'; + +// For empty mutation namespaces; their child handlers enforce resource permissions. +export const Authenticated = (): MethodDecorator => SetMetadata(IS_AUTHENTICATED_ENDPOINT_KEY, true); diff --git a/api/src/unraid-api/auth/authorization.guard.graphql.spec.ts b/api/src/unraid-api/auth/authorization.guard.graphql.spec.ts new file mode 100644 index 0000000000..24be5e0b50 --- /dev/null +++ b/api/src/unraid-api/auth/authorization.guard.graphql.spec.ts @@ -0,0 +1,264 @@ +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { APP_GUARD } from '@nestjs/core'; +import { + Field, + GraphQLModule, + Mutation, + ObjectType, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; + +import type { FastifyRequest } from 'fastify'; +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { UserSettingsService } from '@unraid/shared/services/user-settings.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; +import { PubSub } from 'graphql-subscriptions'; +import { AUTHZ_ENFORCER, AuthZModule } from 'nest-authz'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js'; +import { Authenticated } from '@app/unraid-api/auth/authenticated.decorator.js'; +import { AuthorizationGuard } from '@app/unraid-api/auth/authorization.guard.js'; +import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js'; +import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js'; +import { Public } from '@app/unraid-api/auth/public.decorator.js'; +import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; +import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; +import { + SettingsResolver, + SsoSettingsResolver, + UnifiedSettingsResolver, +} from '@app/unraid-api/graph/resolvers/settings/settings.resolver.js'; +import { ApiSettings } from '@app/unraid-api/graph/resolvers/settings/settings.service.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js'; +import { + AuthorizationOperator, + OidcProvider, +} from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js'; +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/session/oidc-session.service.js'; +import { SsoResolver } from '@app/unraid-api/graph/resolvers/sso/sso.resolver.js'; +import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js'; +import { UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js'; +import { getRequest } from '@app/utils.js'; + +const effect = vi.fn(() => true); + +@ObjectType() +class AuthorizationProbe { + @Field(() => Boolean) + allowed!: boolean; + + @Field(() => Boolean) + missing!: boolean; +} + +@Resolver(() => AuthorizationProbe) +class AuthorizationProbeResolver { + @Query(() => Boolean) + unmarkedQuery() { + return effect(); + } + + @Mutation(() => Boolean) + unmarkedMutation() { + return effect(); + } + + @Mutation(() => AuthorizationProbe) + @Authenticated() + probe() { + return {}; + } + + @ResolveField(() => Boolean) + @UsePermissions({ action: AuthAction.UPDATE_ANY, resource: Resource.ARRAY }) + allowed() { + return effect(); + } + + @ResolveField(() => Boolean) + missing() { + return effect(); + } + + @Query(() => Boolean) + @Public() + publicProbe() { + return true; + } +} + +type GraphQLResult = { + data?: Record | null; + errors?: { extensions: { code: string } }[]; +}; + +describe('GraphQL authorization boundary', () => { + let app: NestFastifyApplication; + const provider: OidcProvider = { + id: 'test-provider', + name: 'Test provider', + clientId: 'test-client', + clientSecret: 'synthetic-secret-for-authorization-test', + scopes: ['openid'], + authorizationRules: [ + { claim: 'email', operator: AuthorizationOperator.EQUALS, value: ['test@example.com'] }, + ], + }; + const oidcConfig = { + getProviders: vi.fn(async () => [provider]), + getProvider: vi.fn(async () => provider), + getConfig: vi.fn(async () => ({ providers: [provider] })), + }; + const notifications = { + archiveNotification: vi.fn(async () => ({ id: 'test-notification' })), + getOverview: vi.fn(async () => ({ unread: { total: 0 } })), + recalculateOverview: vi.fn(async () => ({ overview: { unread: { total: 0 } } })), + }; + const ups = { + configureUPS: vi.fn(async () => undefined), + getUPSData: vi.fn(async () => ({ MODEL: 'test-ups' })), + }; + const userSettings = { + getAllValues: vi.fn(async () => ({ sso: { providers: [provider] } })), + }; + + beforeAll(async () => { + const enforcer = await new CasbinService().initializeEnforcer(CASBIN_MODEL, BASE_POLICY); + vi.stubGlobal('getServerIdentifier', () => 'test-server'); + await enforcer.addPolicy('array-update-only', Resource.ARRAY, AuthAction.UPDATE_ANY); + const module = await Test.createTestingModule({ + imports: [ + AuthZModule.register({ + enablePossession: false, + enforcerProvider: { provide: AUTHZ_ENFORCER, useValue: enforcer }, + userFromContext: (context) => getRequest(context)?.user?.id ?? '', + }), + GraphQLModule.forRoot({ + driver: ApolloDriver, + autoSchemaFile: true, + fieldResolverEnhancers: ['guards'], + context: (req: FastifyRequest) => { + const id = req.headers['x-api-key']; + return { req: { user: typeof id === 'string' ? { id } : undefined } }; + }, + }), + ], + providers: [ + { provide: APP_GUARD, useClass: AuthorizationGuard }, + PrefixedID, + NotificationsResolver, + UPSResolver, + SettingsResolver, + UnifiedSettingsResolver, + SsoSettingsResolver, + SsoResolver, + AuthorizationProbeResolver, + { provide: NotificationsService, useValue: notifications }, + { provide: UPSService, useValue: ups }, + { provide: PubSub, useValue: new PubSub() }, + { provide: OidcConfigPersistence, useValue: oidcConfig }, + { provide: ApiSettings, useValue: {} }, + { provide: UserSettingsService, useValue: userSettings }, + { provide: LifecycleService, useValue: {} }, + { provide: OidcSessionService, useValue: {} }, + ], + }).compile(); + app = module.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + beforeEach(() => vi.clearAllMocks()); + afterAll(async () => { + await app?.close(); + vi.unstubAllGlobals(); + }); + + async function execute(query: string, key?: string): Promise { + const response = await app.inject({ + method: 'POST', + url: '/graphql', + headers: key ? { 'x-api-key': key } : {}, + payload: { query }, + }); + expect(response.statusCode, response.body).toBe(200); + return response.json(); + } + + const protectedOperations = [ + 'mutation { archiveNotification(id: "does-not-exist") { id } }', + 'mutation { recalculateOverview { unread { total } } }', + 'mutation { configureUps(config: {killUps: YES}) }', + 'query { settings { sso { oidcProviders { clientSecret } } } }', + 'query { settings { unified { values } } }', + 'query { oidcProviders { clientSecret } }', + 'query { oidcProvider(id: "test-provider") { clientSecret } }', + 'query { oidcConfiguration { providers { clientSecret } } }', + 'query { aliased: settings { ...Secrets } } fragment Secrets on Settings { sso { oidcProviders { clientSecret } } }', + ]; + for (const query of protectedOperations) { + it.each([Role.VIEWER, Role.GUEST])(`denies %s: ${query}`, async (role) => { + const result = await execute(query, role); + expect(result.errors?.[0].extensions.code).toBe('FORBIDDEN'); + for (const service of [notifications, ups, oidcConfig, userSettings]) { + for (const method of Object.values(service)) expect(method).not.toHaveBeenCalled(); + } + expect(JSON.stringify(result)).not.toContain(provider.clientSecret); + }); + it(`allows ADMIN: ${query}`, async () => { + const result = await execute(query, Role.ADMIN); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeNull(); + }); + } + + it.each([ + 'query { unmarkedQuery }', + 'mutation { unmarkedMutation }', + 'mutation { probe { missing } }', + ])('denies an unmarked handler for ADMIN: %s', async (query) => { + expect((await execute(query, Role.ADMIN)).errors?.[0].extensions.code).toBe('FORBIDDEN'); + expect(effect).not.toHaveBeenCalled(); + }); + it('enforces nested mutation permissions while preserving narrowly scoped keys', async () => { + expect( + (await execute('mutation { probe { allowed } }', Role.VIEWER)).errors?.[0].extensions.code + ).toBe('FORBIDDEN'); + expect(effect).not.toHaveBeenCalled(); + expect((await execute('mutation { probe { allowed } }', 'array-update-only')).data).toEqual({ + probe: { allowed: true }, + }); + expect(effect).toHaveBeenCalledOnce(); + }); + it('does not permit unauthenticated mutation namespaces', async () => { + expect((await execute('mutation { probe { __typename } }')).errors?.[0].extensions.code).toBe( + 'FORBIDDEN' + ); + }); + it('preserves VIEWER monitoring and nested notification reads', async () => { + const result = await execute( + 'query { upsDevices { id } notifications { overview { unread { total } } } }', + Role.VIEWER + ); + expect(result.errors).toBeUndefined(); + expect(notifications.getOverview).toHaveBeenCalledOnce(); + expect(ups.getUPSData).toHaveBeenCalledOnce(); + }); + it('preserves public login information without exposing provider secrets', async () => { + const result = await execute( + 'query { isSSOEnabled publicOidcProviders { id name } publicProbe }' + ); + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + isSSOEnabled: true, + publicOidcProviders: [{ id: 'test-provider', name: 'Test provider' }], + publicProbe: true, + }); + expect(JSON.stringify(result)).not.toContain(provider.clientSecret); + }); +}); diff --git a/api/src/unraid-api/auth/authorization.guard.ts b/api/src/unraid-api/auth/authorization.guard.ts new file mode 100644 index 0000000000..8397391070 --- /dev/null +++ b/api/src/unraid-api/auth/authorization.guard.ts @@ -0,0 +1,33 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { GqlContextType } from '@nestjs/graphql'; + +import type { Permission } from 'nest-authz'; +import { AuthZGuard, PERMISSIONS_METADATA } from 'nest-authz'; + +import { IS_AUTHENTICATED_ENDPOINT_KEY } from '@app/unraid-api/auth/authenticated.decorator.js'; +import { IS_PUBLIC_ENDPOINT_KEY } from '@app/unraid-api/auth/public.decorator.js'; +import { getRequest } from '@app/utils.js'; + +@Injectable() +export class AuthorizationGuard extends AuthZGuard { + override async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'graphql') { + return super.canActivate(context); + } + + const handler = context.getHandler(); + const permissions = this.reflector.get(PERMISSIONS_METADATA, handler); + if (permissions !== undefined) { + return Array.isArray(permissions) && permissions.length > 0 + ? super.canActivate(context) + : false; + } + if (this.reflector.get(IS_PUBLIC_ENDPOINT_KEY, handler)) { + return true; + } + if (this.reflector.get(IS_AUTHENTICATED_ENDPOINT_KEY, handler)) { + return Boolean(getRequest(context)?.user); + } + return false; + } +} diff --git a/api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts b/api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts index a98ee3047b..79fe13dbfa 100644 --- a/api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts +++ b/api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts @@ -4,20 +4,30 @@ import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-hos import type { Enforcer } from 'casbin'; import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; -import { AuthZGuard, BatchApproval } from 'nest-authz'; +import { BatchApproval, PERMISSIONS_METADATA } from 'nest-authz'; import { beforeAll, describe, expect, it } from 'vitest'; +import { AuthorizationGuard } from '@app/unraid-api/auth/authorization.guard.js'; import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js'; import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js'; import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js'; import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js'; import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js'; +import { FlashBackupResolver } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.js'; +import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; +import { + SettingsResolver, + SsoSettingsResolver, + UnifiedSettingsResolver, +} from '@app/unraid-api/graph/resolvers/settings/settings.resolver.js'; +import { SsoResolver } from '@app/unraid-api/graph/resolvers/sso/sso.resolver.js'; +import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js'; import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; import { getRequest } from '@app/utils.js'; -type Handler = (...args: any[]) => unknown; +type Handler = (...args: never[]) => unknown; type TestUser = { id?: string; @@ -53,7 +63,7 @@ function createExecutionContext( } describe('AuthZGuard + Casbin policies', () => { - let guard: AuthZGuard; + let guard: AuthorizationGuard; let enforcer: Enforcer; beforeAll(async () => { @@ -62,8 +72,9 @@ describe('AuthZGuard + Casbin policies', () => { await enforcer.addGroupingPolicy('api-key-viewer', Role.VIEWER); await enforcer.addGroupingPolicy('api-key-admin', Role.ADMIN); + await enforcer.addGroupingPolicy('api-key-guest', Role.GUEST); - guard = new AuthZGuard(new Reflector(), enforcer, { + guard = new AuthorizationGuard(new Reflector(), enforcer, { enablePossession: false, batchApproval: BatchApproval.ALL, userFromContext: (ctx: ExecutionContext) => { @@ -130,4 +141,142 @@ describe('AuthZGuard + Casbin policies', () => { await expect(guard.canActivate(context)).resolves.toBe(true); }); + const protectedHandlers: [Type, Handler, Resource, AuthAction][] = [ + ...(['createNotification', 'notifyIfUnique'] as const).map( + (name): [Type, Handler, Resource, AuthAction] => [ + NotificationsResolver, + NotificationsResolver.prototype[name], + Resource.NOTIFICATIONS, + AuthAction.CREATE_ANY, + ] + ), + ...(['deleteNotification', 'deleteArchivedNotifications'] as const).map( + (name): [Type, Handler, Resource, AuthAction] => [ + NotificationsResolver, + NotificationsResolver.prototype[name], + Resource.NOTIFICATIONS, + AuthAction.DELETE_ANY, + ] + ), + ...( + [ + 'archiveNotification', + 'archiveNotifications', + 'archiveAll', + 'unreadNotification', + 'unarchiveNotifications', + 'unarchiveAll', + 'recalculateOverview', + ] as const + ).map((name): [Type, Handler, Resource, AuthAction] => [ + NotificationsResolver, + NotificationsResolver.prototype[name], + Resource.NOTIFICATIONS, + AuthAction.UPDATE_ANY, + ]), + [UPSResolver, UPSResolver.prototype.configureUps, Resource.CONFIG, AuthAction.UPDATE_ANY], + [ + FlashBackupResolver, + FlashBackupResolver.prototype.initiateFlashBackup, + Resource.FLASH, + AuthAction.CREATE_ANY, + ], + [ + UnifiedSettingsResolver, + UnifiedSettingsResolver.prototype.values, + Resource.CONFIG, + AuthAction.UPDATE_ANY, + ], + [ + SsoSettingsResolver, + SsoSettingsResolver.prototype.oidcProviders, + Resource.CONFIG, + AuthAction.UPDATE_ANY, + ], + [SsoResolver, SsoResolver.prototype.oidcProviders, Resource.CONFIG, AuthAction.UPDATE_ANY], + [SsoResolver, SsoResolver.prototype.oidcProvider, Resource.CONFIG, AuthAction.UPDATE_ANY], + [SsoResolver, SsoResolver.prototype.oidcConfiguration, Resource.CONFIG, AuthAction.UPDATE_ANY], + ]; + + for (const [resolver, handler, resource, action] of protectedHandlers) { + describe(`${resolver.name}.${handler.name}`, () => { + it.each([Role.VIEWER, Role.GUEST])('denies %s before the service runs', async (role) => { + const context = createExecutionContext( + handler, + resolver, + [role], + `api-key-${role.toLowerCase()}` + ); + await expect(guard.canActivate(context)).resolves.toBe(false); + }); + it('allows ADMIN', async () => { + await expect( + guard.canActivate( + createExecutionContext(handler, resolver, [Role.ADMIN], 'api-key-admin') + ) + ).resolves.toBe(true); + }); + it('requires the matching permission for a roleless key', async () => { + const id = `scoped-${resolver.name}-${handler.name}`; + const context = createExecutionContext(handler, resolver, [], id); + await expect(guard.canActivate(context)).resolves.toBe(false); + await enforcer.addPolicy(id, resource, action); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + }); + } + + it('denies missing permission metadata even for ADMIN', async () => { + const handler = () => true; + await expect( + guard.canActivate(createExecutionContext(handler, null, [Role.ADMIN], 'api-key-admin')) + ).resolves.toBe(false); + }); + + it.each([ + [UPSResolver, UPSResolver.prototype.upsDevices], + [UPSResolver, UPSResolver.prototype.upsDeviceById], + [UPSResolver, UPSResolver.prototype.upsConfiguration], + [UPSResolver, UPSResolver.prototype.upsUpdates], + [SettingsResolver, SettingsResolver.prototype.settings], + [NotificationsResolver, NotificationsResolver.prototype.overview], + ] satisfies [Type, Handler][])( + 'allows VIEWER and denies GUEST on %s.%s reads', + async (resolver, handler) => { + await expect( + guard.canActivate( + createExecutionContext(handler, resolver, [Role.VIEWER], 'api-key-viewer') + ) + ).resolves.toBe(true); + await expect( + guard.canActivate( + createExecutionContext(handler, resolver, [Role.GUEST], 'api-key-guest') + ) + ).resolves.toBe(false); + } + ); + + it('denies an empty permission list', async () => { + const handler = () => true; + Reflect.defineMetadata(PERMISSIONS_METADATA, [], handler); + await expect( + guard.canActivate(createExecutionContext(handler, null, [Role.ADMIN], 'api-key-admin')) + ).resolves.toBe(false); + }); + + it('preserves existing REST authorization behavior', async () => { + const context = new ExecutionContextHost([], null, () => true); + context.setType('http'); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('denies a CONFIG read-only key access to secret-bearing queries', async () => { + await enforcer.addPolicy('config-reader', Resource.CONFIG, AuthAction.READ_ANY); + for (const [resolver, handler, resource] of protectedHandlers) { + if (resource !== Resource.CONFIG) continue; + await expect( + guard.canActivate(createExecutionContext(handler, resolver, [], 'config-reader')) + ).resolves.toBe(false); + } + }); }); diff --git a/api/src/unraid-api/auth/resolver-authorization.spec.ts b/api/src/unraid-api/auth/resolver-authorization.spec.ts new file mode 100644 index 0000000000..a5c4501560 --- /dev/null +++ b/api/src/unraid-api/auth/resolver-authorization.spec.ts @@ -0,0 +1,61 @@ +import type { Type } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { LazyMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storages/lazy-metadata.storage.js'; +import { TypeMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storages/type-metadata.storage.js'; + +import type { Permission } from 'nest-authz'; +import { PERMISSIONS_METADATA } from 'nest-authz'; +import { describe, expect, it, vi } from 'vitest'; + +import { IS_AUTHENTICATED_ENDPOINT_KEY } from '@app/unraid-api/auth/authenticated.decorator.js'; +import { IS_PUBLIC_ENDPOINT_KEY } from '@app/unraid-api/auth/public.decorator.js'; + +const modules = import.meta.glob>([ + '../graph/**/*resolver.ts', + '../graph/**/*.module.ts', + '../../../../packages/unraid-api-plugin-*/src/**/*resolver.ts', + '!../../../../packages/**/templates/**', +]); + +describe('GraphQL authorization coverage', () => { + it('requires an explicit access policy on every schema handler, including nested fields and plugins', async () => { + const fieldRegistration = vi.spyOn(TypeMetadataStorage, 'addResolverPropertyMetadata'); + try { + const exports = await Promise.all([ + import('@app/unraid-api/graph/resolvers/resolvers.module.js'), + ...Object.values(modules).map((load) => load()), + ]); + const reflector = new Reflector(); + const classes = exports + .flatMap((module) => Object.values(module)) + .filter((value): value is Type => typeof value === 'function'); + const providers = classes + .flatMap((target) => reflector.get('providers', target) ?? []) + .filter((value): value is Type => typeof value === 'function'); + LazyMetadataStorage.load([...classes, ...providers]); + const missing: string[] = []; + const entries = [ + ...TypeMetadataStorage.getQueriesMetadata(), + ...TypeMetadataStorage.getMutationsMetadata(), + ...TypeMetadataStorage.getSubscriptionsMetadata(), + ...fieldRegistration.mock.calls.map(([metadata]) => metadata), + ]; + for (const entry of entries) { + const handler = Reflect.get(entry.target.prototype, entry.methodName); + expect(typeof handler).toBe('function'); + const permissions = reflector.get(PERMISSIONS_METADATA, handler); + const isPublic = reflector.get(IS_PUBLIC_ENDPOINT_KEY, handler); + const authenticated = reflector.get(IS_AUTHENTICATED_ENDPOINT_KEY, handler); + if (authenticated) expect(entry.target.name).toBe('RootMutationsResolver'); + if (!permissions?.length && !isPublic && !authenticated) { + missing.push(`${entry.target.name}.${entry.methodName}`); + } + } + expect(entries.length).toBeGreaterThan(100); + expect(fieldRegistration).toHaveBeenCalled(); + expect(missing.sort()).toEqual([]); + } finally { + fieldRegistration.mockRestore(); + } + }, 30000); +}); 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 index e7882f5a33..372a2c681a 100644 --- 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 @@ -111,6 +111,10 @@ export class ApiKeyPermissionsResolver { return result; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.PERMISSION, + }) @Query(() => [AuthAction], { description: 'Get all available authentication actions with possession', }) 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 2bec50a536..9e78e67ca1 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -38,11 +38,19 @@ export class DisksResolver { return this.disksService.getDisk(id); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DISK, + }) @ResolveField(() => Int) public async temperature(@Parent() disk: Disk) { return this.disksService.getTemperature(disk.device); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DISK, + }) @ResolveField(() => Boolean) public async isSpinning(@Parent() disk: Disk) { return disk.isSpinning; diff --git a/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts index 5055666004..40a445df5e 100644 --- a/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.ts @@ -1,6 +1,9 @@ import { Inject, Logger } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + import { FlashBackupStatus, InitiateFlashBackupInput, @@ -13,6 +16,10 @@ export class FlashBackupResolver { constructor() {} + @UsePermissions({ + action: AuthAction.CREATE_ANY, + resource: Resource.FLASH, + }) @Mutation(() => FlashBackupStatus, { description: 'Initiates a flash drive backup using a configured remote.', }) diff --git a/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts index 427125f230..7059fdc8dd 100644 --- a/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts @@ -1,5 +1,8 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + import { InfoDevices, InfoGpu, @@ -13,21 +16,37 @@ import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/dev export class DevicesResolver { constructor(private readonly devicesService: DevicesService) {} + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [InfoGpu]) public async gpu(): Promise { return this.devicesService.generateGpu(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [InfoNetwork]) public async network(): Promise { return this.devicesService.generateNetwork(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [InfoPci]) public async pci(): Promise { return this.devicesService.generatePci(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [InfoUsb]) public async usb(): Promise { return this.devicesService.generateUsb(); 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 7928c0d71f..4a9b2f4d08 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -40,54 +40,94 @@ export class InfoResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => GraphQLISODateTime) public async time(): Promise { return new Date(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoBaseboard) public async baseboard(): Promise { const baseboard = await getBaseboard(); return { id: 'info/baseboard', ...baseboard } as InfoBaseboard; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoCpu) public async cpu(): Promise { return this.cpuService.generateCpu(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoDevices) public devices(): Partial { // Return minimal stub, let InfoDevicesResolver handle all fields return { id: 'info/devices' }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoDisplay) public async display(): Promise { return this.displayService.generateDisplay(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => String, { nullable: true }) public async machineId(): Promise { return getMachineId(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoMemory) public async memory(): Promise { return this.memoryService.generateMemory(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoOs) public async os(): Promise { return this.osService.generateOs(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoSystem) public async system(): Promise { const system = await getSystem(); return { id: 'info/system', ...system } as InfoSystem; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoVersions) public versions(): Partial { return this.versionsService.generateVersions(); diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts index 64751c313b..ec34a1eb2a 100644 --- a/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts @@ -20,6 +20,10 @@ export class InfoNetworkResolver { return this.networkService.getNetworkInterfaces(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [InfoNetworkInterface], { name: 'networkInterfaces', description: 'Network interfaces', @@ -28,6 +32,10 @@ export class InfoNetworkResolver { return this.networkService.getNetworkInterfaces(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => InfoNetworkInterface, { nullable: true, description: 'Primary management interface', diff --git a/api/src/unraid-api/graph/resolvers/info/versions/core-versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/core-versions.resolver.ts index 6ee8b24797..6148600aa7 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/core-versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/core-versions.resolver.ts @@ -1,11 +1,17 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { versions } from 'systeminformation'; import { CoreVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js'; @Resolver(() => CoreVersions) export class CoreVersionsResolver { + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => String, { nullable: true }) async kernel(): Promise { const softwareVersions = await versions(); diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts index a711a17dd1..2cb49ef7c3 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts @@ -1,6 +1,8 @@ import { ConfigService } from '@nestjs/config'; import { ResolveField, Resolver } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { versions } from 'systeminformation'; import { @@ -13,6 +15,10 @@ import { export class VersionsResolver { constructor(private readonly configService: ConfigService) {} + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => CoreVersions) core(): CoreVersions { const unraid = this.configService.get('store.emhttp.var.version') || 'unknown'; @@ -25,6 +31,10 @@ export class VersionsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => PackageVersions, { nullable: true }) async packages(): Promise { try { 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 3861b6e368..4bdf2458be 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -127,16 +127,28 @@ export class MetricsResolver implements OnModuleInit { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => CpuUtilization, { nullable: true }) public async cpu(): Promise { return this.cpuService.generateCpuLoad(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => MemoryUtilization, { nullable: true }) public async memory(): Promise { return this.memoryService.generateMemoryLoad(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => [NetworkMetrics]) public async network(): Promise { return this.networkMetricsService.getNetworkMetrics(); @@ -190,6 +202,10 @@ export class MetricsResolver implements OnModuleInit { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.NETWORK_UTILIZATION); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) @ResolveField(() => TemperatureMetrics, { nullable: true }) public async temperature(): Promise { return this.temperatureService.getMetrics(); diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 5ab6b2ad80..9285c8b818 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -1,5 +1,6 @@ import { Mutation, Resolver } from '@nestjs/graphql'; +import { Authenticated } from '@app/unraid-api/auth/authenticated.decorator.js'; import { ApiKeyMutations, ArrayMutations, @@ -15,46 +16,55 @@ import { @Resolver(() => RootMutations) export class RootMutationsResolver { + @Authenticated() @Mutation(() => ArrayMutations, { name: 'array' }) array(): ArrayMutations { return new ArrayMutations(); } + @Authenticated() @Mutation(() => DockerMutations, { name: 'docker' }) docker(): DockerMutations { return new DockerMutations(); } + @Authenticated() @Mutation(() => VmMutations, { name: 'vm' }) vm(): VmMutations { return new VmMutations(); } + @Authenticated() @Mutation(() => ParityCheckMutations, { name: 'parityCheck' }) parityCheck(): ParityCheckMutations { return new ParityCheckMutations(); } + @Authenticated() @Mutation(() => ApiKeyMutations, { name: 'apiKey' }) apiKey(): ApiKeyMutations { return new ApiKeyMutations(); } + @Authenticated() @Mutation(() => CustomizationMutations, { name: 'customization' }) customization(): CustomizationMutations { return new CustomizationMutations(); } + @Authenticated() @Mutation(() => RCloneMutations, { name: 'rclone' }) rclone(): RCloneMutations { return new RCloneMutations(); } + @Authenticated() @Mutation(() => OnboardingMutations, { name: 'onboarding' }) onboarding(): OnboardingMutations { return new OnboardingMutations(); } + @Authenticated() @Mutation(() => UnraidPluginsMutations, { name: 'unraidPlugins' }) unraidPlugins(): UnraidPluginsMutations { return new UnraidPluginsMutations(); 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 d3e0c6797b..1152af190f 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -36,11 +36,19 @@ export class NotificationsResolver { } as Notifications; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.NOTIFICATIONS, + }) @ResolveField(() => NotificationOverview) public async overview(): Promise { return this.notificationsService.getOverview(); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.NOTIFICATIONS, + }) @ResolveField(() => [Notification]) public async list( @Args('filter', { type: () => NotificationFilter }) @@ -49,6 +57,10 @@ export class NotificationsResolver { return await this.notificationsService.getNotifications(filters); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.NOTIFICATIONS, + }) @ResolveField(() => [Notification], { description: 'Deduplicated list of unread warning and alert notifications.', }) @@ -60,6 +72,10 @@ export class NotificationsResolver { * Mutations *=============================================**/ + @UsePermissions({ + action: AuthAction.CREATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => Notification, { description: 'Creates a new notification record' }) public createNotification( @Args('input', { type: () => NotificationData }) @@ -68,6 +84,10 @@ export class NotificationsResolver { return this.notificationsService.createNotification(data); } + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview) public async deleteNotification( @Args('id', { type: () => PrefixedID }) @@ -79,6 +99,10 @@ export class NotificationsResolver { return overview; } + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview, { description: 'Deletes all archived notifications on server.', }) @@ -86,6 +110,10 @@ export class NotificationsResolver { return this.notificationsService.deleteNotifications(NotificationType.ARCHIVE); } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => Notification, { description: 'Marks a notification as archived.' }) public archiveNotification( @Args('id', { type: () => PrefixedID }) @@ -94,6 +122,10 @@ export class NotificationsResolver { return this.notificationsService.archiveNotification({ id }); } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview) public async archiveNotifications( @Args('ids', { type: () => [PrefixedID] }) @@ -103,6 +135,10 @@ export class NotificationsResolver { return this.notificationsService.getOverview(); } + @UsePermissions({ + action: AuthAction.CREATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => Notification, { nullable: true, description: @@ -115,6 +151,10 @@ export class NotificationsResolver { return this.notificationsService.notifyIfUnique(data); } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview) public async archiveAll( @Args('importance', { type: () => NotificationImportance, nullable: true }) @@ -124,6 +164,10 @@ export class NotificationsResolver { return overview; } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => Notification, { description: 'Marks a notification as unread.' }) public unreadNotification( @Args('id', { type: () => PrefixedID }) @@ -132,6 +176,10 @@ export class NotificationsResolver { return this.notificationsService.markAsUnread({ id }); } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview) public async unarchiveNotifications( @Args('ids', { type: () => [PrefixedID] }) @@ -141,6 +189,10 @@ export class NotificationsResolver { return this.notificationsService.getOverview(); } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview) public async unarchiveAll( @Args('importance', { type: () => NotificationImportance, nullable: true }) @@ -150,6 +202,10 @@ export class NotificationsResolver { return overview; } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.NOTIFICATIONS, + }) @Mutation(() => NotificationOverview, { description: 'Reads each notification to recompute & update the overview.', }) 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 21c462ac03..d8b8b0c2c5 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts @@ -34,6 +34,10 @@ export class RCloneBackupSettingsResolver { return {} as RCloneBackupSettings; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.FLASH, + }) @ResolveField(() => RCloneBackupConfigForm) async configForm( @Parent() _parent: RCloneBackupSettings, @@ -48,6 +52,10 @@ export class RCloneBackupSettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.FLASH, + }) @ResolveField(() => [RCloneRemote]) async remotes(@Parent() _parent: RCloneBackupSettings): Promise { try { 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 b76fd43600..f2146cc3f9 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts @@ -26,6 +26,10 @@ export class SettingsResolver { private readonly oidcConfig: OidcConfigPersistence ) {} + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @Query(() => Settings) async settings() { return { @@ -33,6 +37,10 @@ export class SettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => ApiConfig, { description: 'The API setting values' }) async api() { return { @@ -41,6 +49,10 @@ export class SettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => UnifiedSettings) async unified() { return { @@ -48,6 +60,10 @@ export class SettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => SsoSettings) async sso() { return { @@ -72,6 +88,10 @@ export class UnifiedSettingsResolver { private readonly lifecycleService: LifecycleService ) {} + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => GraphQLJSON) async dataSchema() { const { properties } = await this.userSettings.getAllSettings(['api', 'sso']); @@ -81,6 +101,10 @@ export class UnifiedSettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => GraphQLJSON) async uiSchema() { const { elements } = await this.userSettings.getAllSettings(['api', 'sso']); @@ -90,8 +114,13 @@ export class UnifiedSettingsResolver { }; } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => GraphQLJSON) async values() { + // Unified settings include persisted OIDC client secrets. return this.userSettings.getAllValues(); } @@ -118,6 +147,10 @@ export class UnifiedSettingsResolver { export class SsoSettingsResolver { constructor(private readonly oidcConfig: OidcConfigPersistence) {} + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) @ResolveField(() => [OidcProvider], { description: 'List of configured OIDC providers' }) async oidcProviders(): Promise { return this.oidcConfig.getProviders(); 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 ac6018940d..e269ad8d00 100644 --- a/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts @@ -69,9 +69,10 @@ export class SsoResolver { })); } + // Persisted provider configuration includes secrets and requires configuration-write access. @Query(() => [OidcProvider], { description: 'Get all configured OIDC providers (admin only)' }) @UsePermissions({ - action: AuthAction.READ_ANY, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, }) public async oidcProviders(): Promise { @@ -80,7 +81,7 @@ export class SsoResolver { @Query(() => OidcProvider, { nullable: true, description: 'Get a specific OIDC provider by ID' }) @UsePermissions({ - action: AuthAction.READ_ANY, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, }) public async oidcProvider( @@ -91,7 +92,7 @@ export class SsoResolver { @Query(() => OidcConfiguration, { description: 'Get the full OIDC configuration (admin only)' }) @UsePermissions({ - action: AuthAction.READ_ANY, + action: AuthAction.UPDATE_ANY, resource: Resource.CONFIG, }) public async oidcConfiguration(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts index a00ffe1663..d794bcea30 100644 --- a/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts @@ -1,5 +1,7 @@ import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql'; +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { PubSub } from 'graphql-subscriptions'; import { UPSConfigInput } from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js'; @@ -42,6 +44,10 @@ export class UPSResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @Query(() => [UPSDevice]) async upsDevices(): Promise { const upsData = await this.upsService.getUPSData(); @@ -49,6 +55,10 @@ export class UPSResolver { return [this.createUPSDevice(upsData, upsData.MODEL || 'ups1')]; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @Query(() => UPSDevice, { nullable: true }) async upsDeviceById(@Args('id') id: string): Promise { const upsData = await this.upsService.getUPSData(); @@ -59,6 +69,10 @@ export class UPSResolver { return null; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @Query(() => UPSConfiguration) async upsConfiguration(): Promise { const config = await this.upsService.getCurrentConfig(); @@ -80,6 +94,10 @@ export class UPSResolver { }; } + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) @Mutation(() => Boolean) async configureUps(@Args('config') config: UPSConfigInput): Promise { await this.upsService.configureUPS(config); @@ -89,6 +107,10 @@ export class UPSResolver { return true; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) @Subscription(() => UPSDevice) upsUpdates() { return this.pubSub.asyncIterableIterator('upsUpdates'); 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 3b3b324f65..13f066ce7f 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.resolver.ts @@ -21,6 +21,10 @@ export class VmsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.VMS, + }) @ResolveField(() => [VmDomain]) public async domains(): Promise> { try { @@ -32,6 +36,10 @@ export class VmsResolver { } } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.VMS, + }) @ResolveField(() => [VmDomain]) public async domain(): Promise> { return this.domains(); 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 17644cc82a..4a02ad430c 100644 --- a/packages/unraid-api-plugin-connect/src/network/network.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/network/network.resolver.ts @@ -2,9 +2,7 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { AccessUrl } from '@unraid/shared/network.model.js'; -import { - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { Network } from '../unraid-connect/connect.model.js'; import { UrlResolverService } from './url-resolver.service.js'; @@ -24,6 +22,10 @@ export class NetworkResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.NETWORK, + }) @ResolveField(() => [AccessUrl]) public async accessUrls(): Promise { const ips = this.urlResolverService.getServerIps(); 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 bcb422210e..75d820fa3f 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 @@ -31,11 +31,19 @@ export class ConnectSettingsResolver { private readonly eventEmitter: EventEmitter2 ) {} + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => PrefixedID) public async id(): Promise { return 'connectSettingsForm'; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => GraphQLJSON) public async dataSchema(): Promise<{ properties: DataSlice; type: 'object' }> { const { properties } = await this.connectSettingsService.buildRemoteAccessSlice(); @@ -45,6 +53,10 @@ export class ConnectSettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => GraphQLJSON) public async uiSchema(): Promise { const { elements } = await this.connectSettingsService.buildRemoteAccessSlice(); @@ -54,6 +66,10 @@ export class ConnectSettingsResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => ConnectSettingsValues) public async values(): Promise { return await this.connectSettingsService.getCurrentSettings(); 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 1d7964b798..8457e55c12 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 @@ -3,9 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { Query, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; -import { - UsePermissions, -} from '@unraid/shared/use-permissions.directive.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { ConfigType, ConnectConfig, DynamicRemoteAccessType } from '../config/connect.config.js'; import { Connect, ConnectSettings, DynamicRemoteAccessStatus } from './connect.model.js'; @@ -26,6 +24,10 @@ export class ConnectResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => DynamicRemoteAccessStatus) public dynamicRemoteAccess(): DynamicRemoteAccessStatus { const state = this.configService.getOrThrow('connect'); @@ -36,6 +38,10 @@ export class ConnectResolver { }; } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONNECT, + }) @ResolveField(() => ConnectSettings) public async settings(): Promise { return {} as ConnectSettings;