Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion generator/templates/resolver.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
16 changes: 5 additions & 11 deletions src/common/decorators/option.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ const addKeyValuesInObject = <Entity>({
relations,
select,
expandRelation,
hasCountType,
}: AddKeyValueInObjectProps<Entity>): GetInfoFromQueryProps<Entity> => {
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 };
}
Expand All @@ -46,7 +45,6 @@ const addKeyValuesInObject = <Entity>({
export function getOptionFromGqlQuery<Entity>(
this: Repository<Entity>,
query: string,
hasCountType?: boolean,
): GetInfoFromQueryProps<Entity> {
const splitted = query.split('\n');

Expand All @@ -65,7 +63,7 @@ export function getOptionFromGqlQuery<Entity>(

if (line.includes('{')) {
stack.push(replacedLine);
const isFirstLineDataType = hasCountType && replacedLine === DATA;
const isFirstLineDataType = replacedLine === DATA;

if (!isFirstLineDataType) {
lastMetadata = lastMetadata.relations.find(
Expand All @@ -78,11 +76,9 @@ export function getOptionFromGqlQuery<Entity>(
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)
Expand Down Expand Up @@ -110,7 +106,6 @@ export function getOptionFromGqlQuery<Entity>(
stack: addedStack,
relations: acc.relations,
select: acc.select,
hasCountType,
});
},
{
Expand All @@ -120,7 +115,7 @@ export function getOptionFromGqlQuery<Entity>(
);
}

const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
export const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
const { fieldName, path } = ctx.getArgByIndex(3) as {
fieldName: string;
path: { key: string };
Expand Down Expand Up @@ -159,7 +154,7 @@ const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
return stack.join('\n');
};

export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
export const GraphQLQueryToOption = <T>() =>
createParamDecorator((_: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
Expand All @@ -175,7 +170,6 @@ export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
const queryOption: GetInfoFromQueryProps<T> = getOptionFromGqlQuery.call(
repository,
query,
hasCountType,
);

return queryOption;
Expand Down
20 changes: 20 additions & 0 deletions src/common/decorators/query-guard.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown> = new (...args: unknown[]) => T;

export const PERMISSION = Symbol('PERMISSION');
export const INSTANCE = Symbol('INSTANCE');

export const UseQueryPermissionGuard = <T extends ClassConstructor>(
instance: T,
permission: FindOptionsSelect<InstanceType<T>>,
) =>
applyDecorators(
SetMetadata(INSTANCE, instance),
SetMetadata(PERMISSION, permission),
UseGuards(GraphqlQueryPermissionGuard<T>),
);
1 change: 0 additions & 1 deletion src/common/graphql/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,4 @@ export interface AddKeyValueInObjectProps<Entity>
extends GetInfoFromQueryProps<Entity> {
stack: string[];
expandRelation?: boolean;
hasCountType?: boolean;
}
57 changes: 57 additions & 0 deletions src/common/guards/graphql-query-permission.guard.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends ClassConstructor>(
permission: FindOptionsSelect<InstanceType<T>>,
select: FindOptionsSelect<InstanceType<T>>,
): 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<T extends ClassConstructor> {
constructor(
private reflector: Reflector,
private readonly dataSource: DataSource,
) {}

canActivate(context: ExecutionContext): boolean {
const permission = this.reflector.get<FindOptionsSelect<InstanceType<T>>>(
PERMISSION,
context.getHandler(),
);

const entity = this.reflector.get<T>(INSTANCE, context.getHandler());
const repository = this.dataSource.getRepository<T>(entity);

const ctx = GqlExecutionContext.create(context);
const query = getCurrentGraphQLQuery(ctx);

const { select }: GetInfoFromQueryProps<InstanceType<T>> =
getOptionFromGqlQuery.call(repository, query);

return checkPermission<T>(permission, select);
}
}
2 changes: 1 addition & 1 deletion src/user/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class UserResolver {
getManyUserList(
@Args({ name: 'input', nullable: true })
condition: GetManyInput<User>,
@GraphQLQueryToOption<User>(true)
@GraphQLQueryToOption<User>()
option: GetInfoFromQueryProps<User>,
) {
return this.userService.getMany({ ...condition, ...option });
Expand Down
Loading