diff --git a/README.md b/README.md index 2f56971..ffef196 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati } ``` +#### Example of some protected GraphQL + +- getMe (must be authenticated) +- All methods generated by the generator (must be authenticated and must be admin) + ### GraphQL Query To Select and relations #### Dynamic Query Optimization @@ -75,10 +80,31 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati - You can find example code in [/src/user/user.resolver.ts](/src/user/user.resolver.ts) -#### Example of some protected GraphQL +### Permission for specific field -- getMe (must be authenticated) -- All methods generated by the generator (must be authenticated and must be admin) +The [permission guard](/src/common/decorators/query-guard.decorator.ts) is used to block access to specific fields in client requests. + +#### Why it was created + +- In GraphQL, clients can request any field, which could expose sensitive information. This guard ensures that sensitive fields are protected. + +- It allows controlling access to specific fields based on the server's permissions. + +#### How to use + +```ts +@Query(()=>Some) +@UseQueryPermissionGuard(Some, { something: true }) +async getManySomeList(){ + return this.someService.getMany() +} +``` + +With this API, if the client request includes the field "something," a `Forbidden` error will be triggered. + +#### Note + +There might be duplicate code when using this guard alongside `other interceptors`(name: `UseRepositoryInterceptor`) in this boilerplate. In such cases, you may need to adjust the code to ensure compatibility. ## License diff --git a/generator/templates/resolver.hbs b/generator/templates/resolver.hbs index a28e399..0cc47af 100644 --- a/generator/templates/resolver.hbs +++ b/generator/templates/resolver.hbs @@ -20,7 +20,7 @@ export class {{pascalCase tableName}}Resolver { @UseRepositoryInterceptor({{pascalCase tableName}}) getMany{{pascalCase tableName}}List( @Args({ name: 'input', nullable: true }) condition: GetManyInput<{{pascalCase tableName}}>, - @GraphQLQueryToOption<{{pascalCase tableName}}>(true) + @GraphQLQueryToOption<{{pascalCase tableName}}>() option: GetInfoFromQueryProps<{{pascalCase tableName}}>, ) { return this.{{tableName}}Service.getMany({ ...condition, ...option }); diff --git a/src/common/decorators/option.decorator.ts b/src/common/decorators/option.decorator.ts index cedd5ca..2eb1c37 100644 --- a/src/common/decorators/option.decorator.ts +++ b/src/common/decorators/option.decorator.ts @@ -21,12 +21,11 @@ const addKeyValuesInObject = ({ relations, select, expandRelation, - hasCountType, }: AddKeyValueInObjectProps): GetInfoFromQueryProps => { if (stack.length) { let stackToString = stack.join('.'); - if (hasCountType) { + if (stack.length && stack[0] === DATA) { if (stack[0] !== DATA || (stack.length === 1 && stack[0] === DATA)) { return { relations, select }; } @@ -46,7 +45,6 @@ const addKeyValuesInObject = ({ export function getOptionFromGqlQuery( this: Repository, query: string, - hasCountType?: boolean, ): GetInfoFromQueryProps { const splitted = query.split('\n'); @@ -65,7 +63,7 @@ export function getOptionFromGqlQuery( if (line.includes('{')) { stack.push(replacedLine); - const isFirstLineDataType = hasCountType && replacedLine === DATA; + const isFirstLineDataType = replacedLine === DATA; if (!isFirstLineDataType) { lastMetadata = lastMetadata.relations.find( @@ -78,11 +76,9 @@ export function getOptionFromGqlQuery( relations: acc.relations, select: acc.select, expandRelation: true, - hasCountType, }); } else if (line.includes('}')) { - const hasDataTypeInStack = - hasCountType && stack.length && stack[0] === DATA; + const hasDataTypeInStack = stack.length && stack[0] === DATA; lastMetadata = stack.length < (hasDataTypeInStack ? 3 : 2) @@ -110,7 +106,6 @@ export function getOptionFromGqlQuery( stack: addedStack, relations: acc.relations, select: acc.select, - hasCountType, }); }, { @@ -120,7 +115,7 @@ export function getOptionFromGqlQuery( ); } -const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => { +export const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => { const { fieldName, path } = ctx.getArgByIndex(3) as { fieldName: string; path: { key: string }; @@ -159,7 +154,7 @@ const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => { return stack.join('\n'); }; -export const GraphQLQueryToOption = (hasCountType?: boolean) => +export const GraphQLQueryToOption = () => createParamDecorator((_: unknown, context: ExecutionContext) => { const ctx = GqlExecutionContext.create(context); const request = ctx.getContext().req; @@ -175,7 +170,6 @@ export const GraphQLQueryToOption = (hasCountType?: boolean) => const queryOption: GetInfoFromQueryProps = getOptionFromGqlQuery.call( repository, query, - hasCountType, ); return queryOption; diff --git a/src/common/decorators/query-guard.decorator.ts b/src/common/decorators/query-guard.decorator.ts new file mode 100644 index 0000000..15a2603 --- /dev/null +++ b/src/common/decorators/query-guard.decorator.ts @@ -0,0 +1,20 @@ +import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common'; + +import { FindOptionsSelect } from 'typeorm'; + +import { GraphqlQueryPermissionGuard } from '../guards/graphql-query-permission.guard'; + +export type ClassConstructor = new (...args: unknown[]) => T; + +export const PERMISSION = Symbol('PERMISSION'); +export const INSTANCE = Symbol('INSTANCE'); + +export const UseQueryPermissionGuard = ( + instance: T, + permission: FindOptionsSelect>, +) => + applyDecorators( + SetMetadata(INSTANCE, instance), + SetMetadata(PERMISSION, permission), + UseGuards(GraphqlQueryPermissionGuard), + ); diff --git a/src/common/graphql/utils/types.ts b/src/common/graphql/utils/types.ts index 14aa848..04cf6bb 100644 --- a/src/common/graphql/utils/types.ts +++ b/src/common/graphql/utils/types.ts @@ -115,5 +115,4 @@ export interface AddKeyValueInObjectProps extends GetInfoFromQueryProps { stack: string[]; expandRelation?: boolean; - hasCountType?: boolean; } diff --git a/src/common/guards/graphql-query-permission.guard.ts b/src/common/guards/graphql-query-permission.guard.ts new file mode 100644 index 0000000..048101e --- /dev/null +++ b/src/common/guards/graphql-query-permission.guard.ts @@ -0,0 +1,57 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +import { DataSource, FindOptionsSelect } from 'typeorm'; + +import { + getCurrentGraphQLQuery, + getOptionFromGqlQuery, +} from '../decorators/option.decorator'; +import { + ClassConstructor, + INSTANCE, + PERMISSION, +} from '../decorators/query-guard.decorator'; +import { GetInfoFromQueryProps } from '../graphql/utils/types'; + +const checkPermission = ( + permission: FindOptionsSelect>, + select: FindOptionsSelect>, +): boolean => { + return Object.entries(permission) + .filter((v) => !!v[1]) + .every(([key, value]) => { + if (typeof value === 'boolean') { + return select[key] ? false : true; + } + + return checkPermission(value, select[key]); + }); +}; + +@Injectable() +export class GraphqlQueryPermissionGuard { + constructor( + private reflector: Reflector, + private readonly dataSource: DataSource, + ) {} + + canActivate(context: ExecutionContext): boolean { + const permission = this.reflector.get>>( + PERMISSION, + context.getHandler(), + ); + + const entity = this.reflector.get(INSTANCE, context.getHandler()); + const repository = this.dataSource.getRepository(entity); + + const ctx = GqlExecutionContext.create(context); + const query = getCurrentGraphQLQuery(ctx); + + const { select }: GetInfoFromQueryProps> = + getOptionFromGqlQuery.call(repository, query); + + return checkPermission(permission, select); + } +} diff --git a/src/user/user.resolver.ts b/src/user/user.resolver.ts index 2a51a0e..64bda12 100644 --- a/src/user/user.resolver.ts +++ b/src/user/user.resolver.ts @@ -25,7 +25,7 @@ export class UserResolver { getManyUserList( @Args({ name: 'input', nullable: true }) condition: GetManyInput, - @GraphQLQueryToOption(true) + @GraphQLQueryToOption() option: GetInfoFromQueryProps, ) { return this.userService.getMany({ ...condition, ...option });