feat: add permission documentation by using a custom decorator (#1355)

* usePermissions applies both authz + graphQL directive logic to allow
permissions and documentation in one place

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new GraphQL permission directive that documents required
permissions for API fields.
- Added enums for defining action verbs (create, update, delete, read)
and possession types (any, own, own any) to enable granular access
control.
- Added a new health field to the Query type for improved API health
monitoring.

- **Chores**
- Consolidated permission handling by updating import sources and
retiring legacy authorization tests and code, enhancing overall
maintainability.
  - Updated configuration version in the API settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-04-14 12:27:15 -04:00
committed by GitHub
parent e65775f878
commit 45ecab6914
35 changed files with 307 additions and 562 deletions

View File

@@ -1,5 +1,5 @@
[api]
version="4.6.6"
version="4.4.1"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"

View File

@@ -2,6 +2,18 @@
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
"""Directive to document required permissions for fields"""
directive @usePermissions(
"""The action verb required for access"""
action: AuthActionVerb
"""The resource required for access"""
resource: String
"""The possession type required for access"""
possession: AuthPossession
) on FIELD_DEFINITION
type ApiKeyResponse {
valid: Boolean!
error: String
@@ -1412,6 +1424,8 @@ type Query {
services: [Service!]!
shares: [Share!]!
vars: Vars!
"""Get information about all VMs on the system"""
vms: Vms!
parityHistory: [ParityCheck!]!
array: UnraidArray!
@@ -1421,6 +1435,7 @@ type Query {
docker: Docker!
disks: [Disk!]!
disk(id: String!): Disk!
health: String!
}
type Mutation {
@@ -1590,4 +1605,19 @@ type Subscription {
serversSubscription: Server!
parityHistorySubscription: ParityCheck!
arraySubscription: UnraidArray!
}
"""Available authentication action verbs"""
enum AuthActionVerb {
CREATE
UPDATE
DELETE
READ
}
"""Available authentication possession types"""
enum AuthPossession {
ANY
OWN
OWN_ANY
}

View File

@@ -1,241 +0,0 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import { Enforcer } from 'casbin';
import { GraphQLResolveInfo, GraphQLSchema } from 'graphql';
import { AuthActionVerb, AuthPossession, AuthZService, UsePermissions } from 'nest-authz';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
authSchemaTransformer,
getAuthEnumTypeDefs,
transformResolvers,
} from '@app/unraid-api/graph/directives/auth.directive.js';
// Mock UsePermissions function
vi.mock('nest-authz', () => ({
AuthActionVerb: {
READ: 'READ',
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
},
AuthPossession: {
OWN: 'OWN',
ANY: 'ANY',
},
UsePermissions: vi.fn(),
}));
describe.skip('Auth Directive', () => {
let schema: GraphQLSchema;
const typeDefs = `
${getAuthEnumTypeDefs()}
type Query {
protectedField: String @auth(action: READ, resource: "USER", possession: OWN)
unprotectedField: String
}
`;
const resolvers = {
Query: {
protectedField: () => 'protected data',
unprotectedField: () => 'public data',
},
};
beforeEach(() => {
const authZService = new AuthZService({} as Enforcer);
// Create schema for each test
schema = makeExecutableSchema({
typeDefs,
resolvers: transformResolvers(resolvers, authZService),
});
// Apply our auth schema transformer
schema = authSchemaTransformer(schema);
// Reset all mocks
vi.clearAllMocks();
});
describe('authSchemaTransformer', () => {
it('should add permission information to field description', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
expect(protectedField.description).toContain('Required Permissions');
expect(protectedField.description).toContain('Action: **READ**');
expect(protectedField.description).toContain('Resource: **USER**');
expect(protectedField.description).toContain('Possession: **OWN**');
});
it('should store permission requirements in field extensions', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
expect(protectedField.extensions).toBeDefined();
expect(protectedField.extensions.requiredPermissions).toEqual({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
});
it('should not modify fields without auth directive', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const unprotectedField = queryType.getFields().unprotectedField;
expect(unprotectedField.extensions?.requiredPermissions).toBeUndefined();
expect(unprotectedField.description).toBeFalsy();
});
});
describe('transformResolvers', () => {
it('should wrap resolvers to check permissions before execution', async () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
const mockSource = {};
const mockArgs = {};
const mockContext: { requiredPermissions?: any } = {};
// Instead of mocking GraphQLResolveInfo, we can invoke the wrapped resolver directly
// Create a simple function to extract the resolver and call it with our mock objects
const testResolver = async () => {
if (!schema.getQueryType()) return;
// Get the schema fields
const queryFields = schema.getQueryType()!.getFields();
// Call the manually wrapped resolver
if (queryFields.protectedField && queryFields.protectedField.resolve) {
const result = await queryFields.protectedField.resolve(
mockSource,
mockArgs,
mockContext,
{
fieldName: 'protectedField',
parentType: { name: 'Query' },
schema,
// We need these fields, but they aren't actually used in the auth directive code
fieldNodes: [] as any,
returnType: {} as any,
path: {} as any,
fragments: {} as any,
rootValue: null as any,
operation: {} as any,
variableValues: {} as any,
} as unknown as GraphQLResolveInfo
);
return result;
}
return;
};
await testResolver();
// Check that permissions were set in context
expect(mockContext).toHaveProperty('requiredPermissions');
expect(mockContext.requiredPermissions).toEqual({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
// Check that UsePermissions was called with the right params
expect(UsePermissions).toHaveBeenCalledWith({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
});
it('should not apply permissions for unprotected fields', async () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const unprotectedField = queryType.getFields().unprotectedField;
const mockSource = {};
const mockArgs = {};
const mockContext: { requiredPermissions?: any } = {};
// Instead of mocking GraphQLResolveInfo, we can invoke the wrapped resolver directly
const testResolver = async () => {
if (!schema.getQueryType()) return;
// Get the schema fields
const queryFields = schema.getQueryType()!.getFields();
// Call the manually wrapped resolver
if (queryFields.unprotectedField && queryFields.unprotectedField.resolve) {
const result = await queryFields.unprotectedField.resolve(
mockSource,
mockArgs,
mockContext,
{
fieldName: 'unprotectedField',
parentType: { name: 'Query' },
schema,
// We need these fields, but they aren't actually used in the auth directive code
fieldNodes: [] as any,
returnType: {} as any,
path: {} as any,
fragments: {} as any,
rootValue: null as any,
operation: {} as any,
variableValues: {} as any,
} as unknown as GraphQLResolveInfo
);
return result;
}
return;
};
await testResolver();
// Check that permissions were not set or checked
expect(mockContext.requiredPermissions).toBeUndefined();
expect(UsePermissions).not.toHaveBeenCalled();
});
it('should handle an array of resolvers', () => {
const authZService = new AuthZService({} as Enforcer);
const resolversArray = [
{ Query: { field1: () => 'data' } },
{ Mutation: { field2: () => 'data' } },
] as any; // Type assertion to avoid complex IResolvers typing
const transformed = transformResolvers(resolversArray, authZService);
expect(Array.isArray(transformed)).toBe(true);
expect(transformed).toHaveLength(2);
});
});
describe('getAuthEnumTypeDefs', () => {
it('should generate valid SDL for auth enums', () => {
const typeDefs = getAuthEnumTypeDefs();
expect(typeDefs).toContain('enum AuthActionVerb');
expect(typeDefs).toContain('enum AuthPossession');
expect(typeDefs).toContain('directive @auth');
// Check for enum values
Object.keys(AuthActionVerb)
.filter((key) => isNaN(Number(key)))
.forEach((key) => {
expect(typeDefs).toContain(key);
});
Object.keys(AuthPossession)
.filter((key) => isNaN(Number(key)))
.forEach((key) => {
expect(typeDefs).toContain(key);
});
});
});
});

View File

@@ -1,219 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { getDirective, IResolvers, MapperKind, mapSchema } from '@graphql-tools/utils';
import { GraphQLEnumType, GraphQLSchema } from 'graphql';
import { AuthActionVerb, AuthPossession, AuthZService, BatchApproval } from 'nest-authz';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
/**
* @wip : This function does not correctly apply permission to every field.
* @todo : Once we've determined how to fix the transformResolvers function, uncomment this.
*/
export function transformResolvers(
resolvers: IResolvers | IResolvers[],
authZService: AuthZService
): IResolvers | IResolvers[] {
if (Array.isArray(resolvers)) {
return resolvers.map((r) => transformResolvers(r, authZService)) as IResolvers[];
}
// Iterate over each type in the resolvers object
Object.keys(resolvers).forEach((typeName) => {
const typeResolvers = resolvers[typeName];
// Iterate over each field within the type
Object.keys(typeResolvers).forEach((fieldName) => {
const fieldResolver = typeResolvers[fieldName];
// Skip non-function resolvers (or if it's not defined)
if (typeof fieldResolver !== 'function') {
return;
}
// Check if this field has permission metadata in its extensions property
// We need to wrap the resolver in a function that checks if the user has the required permissions
const originalResolver = fieldResolver;
// Create a wrapped resolver that will extract permissions from info
typeResolvers[fieldName] = async (source, args, context, info) => {
// Access the extensions from the field definition in the schema
console.log(
'resolving',
info.fieldName,
info.parentType.name,
info.schema.getType(info.parentType.name).getFields()[info.fieldName].extensions
);
console.log('user', context?.req?.user);
const fieldExtensions = info.schema.getType(info.parentType.name).getFields()[
info.fieldName
].extensions;
if (fieldExtensions?.requiredPermissions && context?.req?.user) {
const { action, resource, possession } = fieldExtensions.requiredPermissions;
if (context) {
// Handle OWN_ANY possession by checking both ANY and OWN permissions
if (possession === AuthPossession.OWN_ANY) {
context.requiredPermissions = [
{
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: AuthPossession.ANY,
},
{
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: AuthPossession.OWN,
},
];
// For OWN_ANY, we want to check both ANY and OWN permissions
// If either check passes, the user has permission
const hasPermission = await authZService.enforce(
context.user,
resource.toUpperCase(),
action.toUpperCase(),
BatchApproval.ANY
);
if (!hasPermission) {
throw new UnauthorizedException(
'Unauthorized: User does not have required permissions'
);
}
} else {
context.requiredPermissions = {
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: possession.toUpperCase(),
};
// For regular permissions, we check the specific possession type
const hasPermission = await authZService.enforce(
context.user,
resource.toUpperCase(),
`${action.toUpperCase()}:${possession.toUpperCase()}`
);
if (!hasPermission) {
throw new UnauthorizedException(
'Unauthorized: User does not have required permissions'
);
}
}
}
}
// Call the original resolver after permission check
return await originalResolver(source, args, context, info);
};
});
});
return resolvers;
}
export function authSchemaTransformer(schema: GraphQLSchema): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const {
action: actionValue,
resource: resourceValue,
possession: possessionValue,
} = authDirective;
if (!actionValue || !resourceValue || !possessionValue) {
console.warn(
`Auth directive on ${typeName}.${fieldName} is missing required arguments.`
);
return fieldConfig;
}
// Append permission information to the field description
const permissionDoc = `
#### Required Permissions:
- Action: **${actionValue}**
- Resource: **${resourceValue}**
- Possession: **${possessionValue}**`;
const newDescription = fieldConfig.description
? `${fieldConfig.description}${permissionDoc}`
: permissionDoc;
fieldConfig.description = newDescription;
// Store the required permissions in the field config extensions
fieldConfig.extensions = {
...fieldConfig.extensions,
requiredPermissions: {
action: actionValue.toUpperCase() as AuthActionVerb,
resource: resourceValue.toUpperCase() as Resource,
possession: possessionValue.toUpperCase() as AuthPossession,
},
};
}
return fieldConfig;
},
});
}
/**
* Generates GraphQL SDL strings for the authentication enums.
*/
export function getAuthEnumTypeDefs(): string {
// Helper to generate enum values string part with descriptions
const getEnumValues = <T>(tsEnum: Record<string, T>): string => {
return Object.entries(tsEnum)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.map(([key]) => ` ${key}`)
.join('\n');
};
return `"""
Available authentication action verbs
"""
enum AuthActionVerb {
${getEnumValues(AuthActionVerb)}
}
"""
Available authentication possession types
"""
enum AuthPossession {
${getEnumValues(AuthPossession)}
}
directive @auth(
action: AuthActionVerb!,
resource: String!,
possession: AuthPossession!
) on FIELD_DEFINITION
`;
}
/**
* Generic function to convert TypeScript enums to GraphQL enums
* (Kept for potential other uses, but not used for Auth enums in schema generation anymore)
*/
export function createGraphQLEnumFromTSEnum<T>(
tsEnum: Record<string, T>,
name: string,
description: string
): GraphQLEnumType {
const enumValues = {};
Object.keys(tsEnum).forEach((key) => {
if (isNaN(Number(key))) {
// Skip numeric keys (enum in TS has both string and number keys)
enumValues[key] = { value: tsEnum[key] };
}
});
return new GraphQLEnumType({
name,
description,
values: enumValues,
});
}

View File

@@ -0,0 +1,126 @@
import { Directive } from '@nestjs/graphql';
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';
// Re-export the types from nest-authz
export { AuthActionVerb, AuthPossession };
const buildGraphQLEnum = (
enumObj: Record<string, string | number>,
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<string, { value: string }>
);
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
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',
},
resource: {
type: GraphQLString,
description: 'The resource required for access',
},
possession: {
type: AuthPossessionEnum,
description: 'The possession type required for access',
},
},
});
// 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);
// Apply GraphQL directive using NestJS's @Directive decorator
Directive(
`@usePermissions(action: ${permissions.action.toUpperCase()}, resource: "${permissions.resource}", possession: ${permissions.possession.toUpperCase()})`
)(target, propertyKey, descriptor);
return descriptor;
};
};
// Schema transformer to add permission documentation
export function usePermissionsSchemaTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const usePermissionsDirective = getDirective(schema, fieldConfig, 'usePermissions')?.[0];
if (usePermissionsDirective) {
const {
action: actionValue,
resource: resourceValue,
possession: possessionValue,
} = usePermissionsDirective;
if (!actionValue || !resourceValue || !possessionValue) {
console.warn(
`UsePermissions directive on ${typeName}.${fieldName} is missing required arguments.`
);
return fieldConfig;
}
// Append permission information to the field description
const permissionDoc = `
#### Required Permissions:
- Action: **${actionValue}**
- Resource: **${resourceValue}**
- Possession: **${possessionValue}**`;
const descriptionDoc = fieldConfig.description
? `
#### Description:
${fieldConfig.description}`
: '';
fieldConfig.description = permissionDoc + descriptionDoc;
}
return fieldConfig;
},
});
}

View File

@@ -9,6 +9,10 @@ import { JSONResolver, URLResolver } from 'graphql-scalars';
import { ENVIRONMENT } from '@app/environment.js';
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js';
import { getters } from '@app/store/index.js';
import {
UsePermissionsDirective,
usePermissionsSchemaTransformer,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
@@ -50,7 +54,9 @@ import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
},
buildSchemaOptions: {
dateScalarMode: 'isoDate',
directives: [UsePermissionsDirective],
},
transformSchema: usePermissionsSchemaTransformer,
validationRules: [NoUnusedVariablesRule],
};
},

View File

@@ -1,9 +1,12 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import {
AddRoleForApiKeyInput,
ApiKey,

View File

@@ -1,8 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import {
ArrayDisk,
ArrayDiskInput,

View File

@@ -1,10 +1,11 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { store } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';

View File

@@ -1,8 +1,12 @@
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { ParityCheckMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';

View File

@@ -1,10 +1,13 @@
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
import { PubSub } from 'graphql-subscriptions';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';

View File

@@ -1,11 +1,14 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { checkApi } from '@app/graphql/resolvers/query/cloud/check-api.js';
import { checkCloud } from '@app/graphql/resolvers/query/cloud/check-cloud.js';
import { checkMinigraphql } from '@app/graphql/resolvers/query/cloud/check-minigraphql.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Cloud } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';

View File

@@ -1,8 +1,11 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getters } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';

View File

@@ -3,11 +3,15 @@ import { Args, ID, Mutation, Query, ResolveField, Resolver } from '@nestjs/graph
import { Layout } from '@jsonforms/core';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
import { logoutUser, updateAllowedOrigins } from '@app/store/modules/config.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { ConnectSettingsService } from '@app/unraid-api/graph/resolvers/connect/connect-settings.service.js';
import {

View File

@@ -1,9 +1,12 @@
import { Logger } from '@nestjs/common';
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { store } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import {
Connect,

View File

@@ -1,7 +1,10 @@
import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.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';

View File

@@ -3,10 +3,13 @@ import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getters } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Display } from '@app/unraid-api/graph/resolvers/info/info.model.js';
@@ -60,12 +63,12 @@ const states = {
@Resolver(() => Display)
export class DisplayResolver {
@Query(() => Display)
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.DISPLAY,
possession: AuthPossession.ANY,
})
@Query(() => Display)
public async display(): Promise<Display> {
/**
* This is deprecated, remove it eventually

View File

@@ -1,7 +1,10 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.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';

View File

@@ -1,7 +1,10 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import {
Docker,

View File

@@ -1,8 +1,11 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getters } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';

View File

@@ -1,6 +1,5 @@
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { baseboard as getBaseboard, system as getSystem } from 'systeminformation';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
@@ -14,6 +13,11 @@ import {
generateOs,
generateVersions,
} from '@app/graphql/resolvers/query/info.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import {
Baseboard,

View File

@@ -1,8 +1,11 @@
import { Args, Int, Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { LogFile, LogFileContent } from '@app/unraid-api/graph/resolvers/logs/logs.model.js';
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';

View File

@@ -1,8 +1,11 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getServerIps } from '@app/graphql/resolvers/subscription/network.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { AccessUrl, Network } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';

View File

@@ -1,9 +1,12 @@
import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { AppError } from '@app/core/errors/app-error.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import {
Notification,

View File

@@ -1,7 +1,10 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js';

View File

@@ -1,9 +1,12 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getters } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js';

View File

@@ -1,11 +1,14 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getKeyFile } from '@app/core/utils/misc/get-key-file.js';
import { getters } from '@app/store/index.js';
import { FileLoadStatus } from '@app/store/types.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import {
Registration,

View File

@@ -1,9 +1,12 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getLocalServer } from '@app/graphql/schema/utils.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Server as ServerModel } from '@app/unraid-api/graph/resolvers/servers/server.model.js';

View File

@@ -1,8 +1,11 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getters } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js';

View File

@@ -1,7 +1,10 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.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';

View File

@@ -1,7 +1,10 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.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';
@@ -10,7 +13,7 @@ import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
export class VmsResolver {
constructor(private readonly vmsService: VmsService) {}
@Query(() => Vms)
@Query(() => Vms, { description: 'Get information about all VMs on the system' })
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.VMS,
@@ -35,12 +38,6 @@ export class VmsResolver {
@ResolveField(() => [VmDomain])
public async domain(): Promise<Array<VmDomain>> {
try {
return await this.vmsService.getDomains();
} catch (error) {
throw new Error(
`Failed to retrieve VM domains: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
return this.domains();
}
}

View File

@@ -1,10 +1,13 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
import { API_VERSION } from '@app/environment.js';
import { store } from '@app/store/index.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
import { Service } from '@app/unraid-api/graph/services/service.model.js';

View File

@@ -1,8 +1,11 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { getShares } from '@app/core/utils/shares/get-shares.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Share } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';

View File

@@ -1,8 +1,11 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { GraphqlUser } from '@app/unraid-api/auth/user.decorator.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';

View File

@@ -1,36 +0,0 @@
import { AuthPossession } from 'nest-authz';
import { AuthActionVerb } from 'nest-authz/dist/src/types.js';
/**
* Generates GraphQL SDL strings for the authentication enums.
*/
export function getAuthEnumTypeDefs(): string {
// Helper to generate enum values string part with descriptions
const getEnumValues = <T>(tsEnum: Record<string, T>): string => {
return Object.entries(tsEnum)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.map(([key]) => ` ${key}`)
.join('\n');
};
return `"""
Available authentication action verbs
"""
enum AuthActionVerb {
${getEnumValues(AuthActionVerb)}
}
"""
Available authentication possession types
"""
enum AuthPossession {
${getEnumValues(AuthPossession)}
}
directive @auth(
action: AuthActionVerb!,
resource: String!,
possession: AuthPossession!
) on FIELD_DEFINITION
`;
}